merge: integrate main (D.2b paperdoll/inventory UI line) into the physics/collision branch

Brings the 134 D.2b UI commits onto the physics/collision development line so
main can fast-forward. Today's #149 (BSP-less static collision) + #150 (open
doors fully passable) + the full collision/streaming/dense-town-FPS arc meet
the paperdoll/inventory work.

# Conflicts:
#	docs/ISSUES.md
#	docs/architecture/retail-divergence-register.md
This commit is contained in:
Erik 2026-06-25 12:57:46 +02:00
commit 01594b4cfd
95 changed files with 17201 additions and 167 deletions

View file

@ -0,0 +1,367 @@
# D.2b drag-drop spine (Stream B.1) — design
**Date:** 2026-06-20
**Phase:** D.2b retail-UI engine → D.5 core panels. Stream **B.1** of the
2026-06-18 handoff (the shared drag-drop infra that BOTH the shortcut-drag stream
(B) and the inventory window (C) sit on). Build it once, well — it is the
critical-path lynchpin.
**Branch:** `claude/hopeful-maxwell-214a12` (== main == `31d7ffd`).
**Spec author:** lead-engineer brainstorm 2026-06-20 (4 design decisions confirmed
by the user).
**Read alongside:**
- `docs/research/2026-06-16-ui-item-slot-icon-dragdrop-spine-deep-dive.md` §5 / §5.7
(the pseudocode IS the spec; sub-element + flag anchors).
- `docs/research/2026-06-18-d53-bar-finish-and-inventory-handoff.md` §B.1
(current-code readiness).
- `claude-memory/project_d2b_retail_ui.md` (the toolkit), `feedback_ui_resolve_zero_magenta`
(the 0-id → magenta footgun; guard on the id, not the GL handle).
---
## 1. Goal & non-goals
**Goal.** Complete the widget-level drag-drop machine so a player can pick up an
item icon, see a translucent ghost track the cursor, watch hovered slots flip to
**accept (green) / reject (red)**, and drop — at which point the drop is dispatched
to the owning panel's handler with a fully-resolved payload. `UiRoot` already holds
the *device-level* drag state machine (`BeginDrag`/`UpdateDragHover`/`FinishDrag`,
promoted on >3 px move, live-wired to Silk.NET). This stream supplies the five
missing pieces (handoff §B.1): payload injection, the cursor ghost, drop-target
hooks on the cell, the accept/reject overlay, and the panel-handler interface.
**In scope (the spine — generic, shared):**
1. Payload injection into `UiRoot.BeginDrag` (today passes `payload: null`).
2. A cursor-following drag ghost, painted by `UiRoot`, item-agnostic.
3. Drop-target hooks on `UiItemSlot` (DragEnter/Over/DropReleased → accept/reject
overlay + dispatch).
4. The `IItemListDragHandler` interface + registration on `UiItemList`.
5. A typed `ItemDragPayload` describing what is dragged and where from.
6. Moving the item-cell's use-trigger from MouseDown → Click (drag/click
disambiguation; also more retail-faithful).
7. A **visible toolbar stub handler** so the chain is verifiable this session.
**Out of scope (explicitly deferred — later streams):**
- The `AddShortcut 0x019C` / `RemoveShortcut 0x019D` wire and the mutable
`ShortcutStore` (Stream **B.2**).
- The inventory window, `UiItemList` N-cell grid mode, window manager
(Streams **C / window-mgr**).
- Stack-split drag (the entry/slider `0x100001A3/A4`), give-to-NPC, drop-to-ground,
wield wire — all per-panel **HandleDropRelease** opcode selection (the deep-dive
§5.7 opcode table; lands with each consuming panel).
- The faithful `m_pDragIcon` (base+overlay-no-underlay) drag composite — MVP reuses
the existing full icon at reduced alpha (AP-47).
- Esc-to-cancel-drag (retail has it; future polish).
---
## 2. Retail grounding (the four confirmed decisions)
The named decomp (`acclient_2013_pseudo_c.txt`) was greped to anchor each piece;
the spine introduces **no new wire format** (opcodes are B.2/C), so the workflow's
grep→cross-ref→pseudocode step applies to the *event-chain semantics* and
`InqDropIconInfo`, both confirmed below.
### 2.1 The retail chain (deep-dive §5.15.5)
- The cell `UIElement_UIItem` is BOTH drag source and drop target. On
left-press-and-move it walks to its parent `UIElement_ItemList` and calls
`ItemList_BeginDrag` (`ListenToElementMessage` decomp 229344, msg `0x21`).
- Every cell is a drop target *by construction*`PostInit` sets the
`CatchDroppedItem` attribute `0x36` true (decomp 229744).
- On drag-over the cell forwards to `ItemList_DragOver`; the LIST routes to its
registered `m_dragHandler` (`RegisterItemListDragHandler`, decomp 230461;
confirmed at acclient 0x004a539e + the gmToolbarUI block 0x004bdd89).
- `InqDropIconInfo(dragElement, &objId, &containerId, &flags)` reads the dragged
element's properties (decomp 230533); the **flags** word (confirmed live at
`gmToolbarUI` 0x004bd162 / 0x004bd1af): **`flags & 0xE == 0`** ⇒ fresh-from-
inventory; **`flags & 4`** ⇒ within-list reorder. Accept/reject overlay state ids:
neutral `0x1000003f`, **reject `0x10000040`**, accept `0x10000041`
(`SetDragAcceptState`, confirmed 0x004bd16d).
- The drag ghost `m_dragIcon` (id `0x10000345`) is a root-level translucent copy of
the icon, tracking the cursor (decomp 229738; §5.5).
### 2.2 How the four picks map (all confirmed with the user)
| # | Decision | Pick | Retail basis |
|---|---|---|---|
| 1 | Payload shape | typed `ItemDragPayload` record, snapshotted at begin | mirrors `InqDropIconInfo`'s {objId, container, flags}; our `flags` derived at drop from SourceKind+target |
| 2 | Ghost render | painted by `UiRoot` via a generic `GetDragGhost()` hook | retail's root-level `m_dragElement` floats above all windows |
| 3 | Drop unit | **cell** hits + shows overlay; **list** owns the handler | retail cell→`ItemList_DragOver``m_dragHandler` |
| 4 | PR scope | infra + a **visible toolbar stub** handler | so the ghost/overlay/dispatch are confirmable now; wire is B.2 |
---
## 3. Architecture — components & boundaries
All code lives in `src/AcDream.App/UI/` (the retail UI tree; NOT the ImGui devtools
path). No new project references; `UiRoot` stays item-agnostic (it learns the
payload/ghost only through two `UiElement` virtuals).
```
Silk.NET mouse ─► UiRoot (device drag machine; UNCHANGED chain, + payload pull + ghost)
│ BeginDrag(source): payload = source.GetDragPayload(); cancel if null
│ Draw(): paint source.GetDragGhost() at the cursor (overlay layer)
UiItemSlot (the CELL — drag source + drop target + overlay owner)
│ GetDragPayload() → ItemDragPayload | null (null = empty cell, no drag)
│ GetDragGhost() → (tex,w,h) | null
│ OnEvent: Click→use; DragEnter→ask handler→overlay; DropReleased→dispatch
▼ (walks Parent to)
UiItemList (the GRID — owns the registered handler)
│ DragHandler : IItemListDragHandler
IItemListDragHandler (a panel controller implements this)
bool OnDragOver(list, cell, payload) → accept/reject (overlay only)
void HandleDropRelease(list, cell, payload) → the action (wire = per panel)
ToolbarController (registers itself; STUB this stream — logs, no wire = TS-33)
```
**Boundary rules honored:** Rule 1 (no fat feature body in `GameWindow` — all logic
in the UI classes + the controller); Rule 3 (panels target the toolkit, never a
backend); `UiRoot` depends on nothing item-specific (Rule-2-spirit: the generic
toolkit core doesn't reach up into the item layer — it pulls through virtuals).
---
## 4. New & changed types (precise signatures)
### 4.1 NEW — `ItemDragPayload` + `ItemDragSource` (`UI/ItemDragPayload.cs`)
```csharp
namespace AcDream.App.UI;
/// <summary>Where a dragged item came from — the retail InqDropIconInfo flag
/// distinction (flags&0xE==0 fresh-from-inventory vs flags&4 within-list reorder)
/// expressed as a typed enum. The drop handler maps SourceKind+target back to the
/// fresh-vs-reorder decision. Decomp: gmToolbarUI 0x004bd162 / 0x004bd1af.</summary>
public enum ItemDragSource { Inventory, ShortcutBar, Equipment, Ground }
/// <summary>Snapshot of a drag-in-progress, taken at drag-begin (so a server move
/// arriving mid-drag can't mutate it under us). Port of retail's m_dragElement +
/// InqDropIconInfo out-params (objId/container/flags, decomp 230533).
/// SourceContainer is NOT stored — the handler resolves the LIVE container from
/// ClientObjectTable.Get(ObjId).ContainerId at drop (single source of truth; same
/// value retail reads off the element, see §7).</summary>
public sealed record ItemDragPayload(
uint ObjId, // dragged weenie guid (retail itemID, +0x5FC)
ItemDragSource SourceKind, // what kind of slot it left
int SourceSlot, // the source cell's SlotIndex (retail m_lastShortcutNumDragged)
UiItemSlot SourceCell); // back-ref: reorder-restore / clear source state / ghost texture
```
### 4.2 CHANGED — `UiElement` (two new virtuals; default null)
```csharp
/// <summary>The data this element carries when a drag begins. UiRoot.BeginDrag pulls
/// this; a NULL return CANCELS the drag (retail: ItemList_BeginDrag only arms an
/// occupied cell). Default null = not draggable.</summary>
public virtual object? GetDragPayload() => null;
/// <summary>The texture UiRoot paints at the cursor while this element is the drag
/// source: (GL handle, width, height). Null = no ghost. Keeps UiRoot item-agnostic.</summary>
public virtual (uint tex, int w, int h)? GetDragGhost() => null;
```
### 4.3 CHANGED — `UiRoot`
- `BeginDrag(UiElement source)` (drop the `payload` param): pull
`var payload = source.GetDragPayload();` → if `null`, set `_dragCandidate = false`
and **return without arming** (no `DragSource`, no event). Else set
`DragSource`/`DragPayload`, fire `DragBegin` with the payload. Call site (line 188)
becomes `BeginDrag(Captured);`.
- `Draw(ctx)`: after `DrawOverlays`, still inside the overlay layer, draw the ghost:
```csharp
if (DragSource?.GetDragGhost() is { tex: var t, w: var gw, h: var gh } && t != 0)
ctx.DrawSprite(t, MouseX - gw/2f, MouseY - gh/2f, gw, gh, 0,0,1,1,
new Vector4(1,1,1, GhostAlpha)); // GhostAlpha = 0.6f
```
Root transform is at origin during `Draw`, so cursor coords are absolute. The
ghost is NOT a tree element → it never intercepts hit-tests.
### 4.4 CHANGED — `UiItemSlot`
- Add `public int SlotIndex { get; set; } = -1;` (the cell's own index within its
panel — 0..17 toolbar, container slot for inventory; distinct from `ShortcutNum`,
the 19 LABEL which is -1 on the bottom row).
- Add `public ItemDragSource SourceKind { get; set; } = ItemDragSource.Inventory;`
(controller overrides; toolbar = `ShortcutBar`).
- Add drag-accept overlay sprites (confirmed ids; configurable; guard `id != 0`
before resolving — the magenta footgun):
```csharp
public uint DragAcceptSprite { get; set; } = 0x060011F9u; // ItemSlot_DragOver_Accept
public uint DragRejectSprite { get; set; } = 0x060011F8u; // ItemSlot_DragOver_Reject
private enum DragAccept { None, Accept, Reject }
private DragAccept _dragAccept = DragAccept.None;
```
- `GetDragPayload()``ItemId != 0 ? new ItemDragPayload(ItemId, SourceKind, SlotIndex, this) : null`.
- `GetDragGhost()``ItemId != 0 && IconTexture != 0 ? (IconTexture, (int)Width, (int)Height) : null`.
- **Move use from MouseDown → Click** (the disambiguation fix): `OnEvent` keeps
`MouseDown``return true` (still consume the press, but DON'T fire use), and adds
`Click` (0x01) → `Clicked?.Invoke(); return true`. (`UiRoot` already suppresses the
post-drag Click at line 311, so a completed drag won't also use the item; a
press-release-without-move still emits Click → use.) `WireClick` in
`ToolbarController` is unchanged — it still assigns `Clicked`.
- `OnEvent` new cases (acdream's UiEventType is the contract — these are acdream's
DragEnter/DragOver/DropReleased, NOT retail's raw per-cell 0x21/0x3e/0x15 codes;
see §6 note):
```csharp
case UiEventType.DragBegin: return true; // we're the source; payload already pulled
case UiEventType.DragEnter: // pointer entered me mid-drag
_dragAccept = (FindList() is {} l && l.DragHandler is {} h
&& e.Payload is ItemDragPayload p && h.OnDragOver(l, this, p))
? DragAccept.Accept : DragAccept.Reject;
return true;
case UiEventType.DragOver: _dragAccept = DragAccept.None; return true; // UiRoot fires on LEAVE
case UiEventType.DropReleased:
_dragAccept = DragAccept.None;
if (e.Data0 == 1 // accepted = dropped on a DIFFERENT element (skips self/empty)
&& FindList() is {} dl && dl.DragHandler is {} dh
&& e.Payload is ItemDragPayload dp)
dh.HandleDropRelease(dl, this, dp);
return true;
```
`FindList()` walks `Parent` until a `UiItemList`. The accept/reject overlay draws
in `OnDraw` on top of the icon+digit, `id != 0` guarded.
### 4.5 NEW — `IItemListDragHandler` (`UI/IItemListDragHandler.cs`)
```csharp
/// <summary>A panel controller implements this and registers itself on each of its
/// UiItemLists. Port of retail's m_dragHandler vtable (RegisterItemListDragHandler,
/// decomp 230461). OnDragOver decides the accept/reject OVERLAY only (advisory);
/// HandleDropRelease is authoritative — it does the action (or no-ops to reject).</summary>
public interface IItemListDragHandler
{
bool OnDragOver(UiItemList targetList, UiItemSlot targetCell, ItemDragPayload payload);
void HandleDropRelease(UiItemList targetList, UiItemSlot targetCell, ItemDragPayload payload);
}
```
### 4.6 CHANGED — `UiItemList`
- Add `public IItemListDragHandler? DragHandler { get; private set; }` +
`public void RegisterDragHandler(IItemListDragHandler h) => DragHandler = h;`.
### 4.7 CHANGED — `ToolbarController` (the stub handler — TS-33)
- `: IItemListDragHandler`. In the ctor, after caching slots, set each cell's
`SlotIndex = i` and `SourceKind = ItemDragSource.ShortcutBar`, and
`list.RegisterDragHandler(this)`.
- `OnDragOver``payload.ObjId != 0` (accept any real item; retail's
`IsShortcutEligible` gate is B.2).
- `HandleDropRelease` → `Console.WriteLine($"[B.2 TODO] drop obj 0x{payload.ObjId:X8} " +
$"from {payload.SourceKind} slot {payload.SourceSlot} → toolbar slot {targetCell.SlotIndex}");`
No store mutation, no wire. (TS-33; replaced wholesale in B.2.)
---
## 5. Data flow — frame by frame
1. **Press** on an occupied toolbar slot → `UiRoot.OnMouseDown` sets `Captured = cell`,
`_dragCandidate = true` (cell isn't `CapturesPointerDrag`, isn't a window-drag).
2. **Move > 3 px**`UiRoot.OnMouseMove` calls `BeginDrag(cell)` → pulls
`cell.GetDragPayload()` = `ItemDragPayload{ObjId, ShortcutBar, srcSlot, cell}`
(non-null since occupied) → arms `DragSource`/`DragPayload`, fires `DragBegin`.
3. **Each move while dragging**`UpdateDragHover(x,y)` hit-tests; on target CHANGE
it fires `DragOver` (LEAVE) on the old cell → its overlay resets to neutral, and
`DragEnter` on the new cell → it asks its list's handler `OnDragOver` → overlay
flips accept/reject. Meanwhile `UiRoot.Draw` paints the ghost at the cursor.
4. **Release**`UiRoot.OnMouseUp`: `DragSource` set → `FinishDrag(x,y)` delivers
`DropReleased` to the cell under the cursor (`Data0 = 1` if a different element).
The cell → its list's `HandleDropRelease(list, cell, payload)` → (stub) logs.
`DragSource`/`DragPayload` cleared → ghost stops. Capture released.
5. **Empty-slot drag attempt** → step 2 `GetDragPayload()` returns null →
`_dragCandidate=false`, nothing armed; the eventual mouse-up emits Click (no-op,
`Clicked` guards `ItemId != 0`).
---
## 6. Edge cases & notes
- **Event-id namespace.** acdream's `UiEventType` (DragBegin 0x15, DragEnter 0x21,
DragOver 0x1C, DropReleased 0x3E) is the contract the cell handles — these are
`UiRoot`'s constants and differ from retail's raw per-cell `ListenToElementMessage`
codes (0x21 begin / 0x3e over / 0x15 drop). Do NOT try to match the retail per-cell
codes; the SEMANTICS match, the numbers are `UiRoot`'s. (IA-12 already covers "UI
toolkit mirrors behavior, not byte-layout.")
- **Hover re-eval cadence.** `UiRoot.UpdateDragHover` early-returns when the target
is unchanged, so accept/reject is computed once per cell-enter, not every move.
For item cells the decision is constant per (cell, payload), so this is
behavior-equivalent to retail's per-move `MouseOverTop` — no register row.
- **Self-drop / drop-on-nothing.** `FinishDrag` sets `Data0=0` when the target is the
source or null; the cell gates `HandleDropRelease` on `Data0==1`, so both are clean
no-ops (= "put it back").
- **Magenta footgun.** `DragAcceptSprite`/`DragRejectSprite` default non-zero; the
`OnDraw` overlay still guards `if (id != 0)` before `SpriteResolve`, never on the
returned GL handle (`feedback_ui_resolve_zero_magenta`).
- **No heavy diagnostics in the render loop.** The stub's `Console.WriteLine` fires
only on a discrete drop event (not per frame), so it can't eat dt-based animations.
---
## 7. Divergence register rows (add in the IMPLEMENTATION commit)
- **AP-47** (Documented approximation): *Drag ghost reuses the full composited
`m_pIcon` at reduced alpha (`GhostAlpha=0.6`) instead of retail's dedicated
`m_pDragIcon` (base + custom-overlay, NO underlay).* Where: `UiRoot.Draw` +
`UiItemSlot.GetDragGhost`. Why safe: cosmetic only — the dragged item is still
unambiguously identifiable; building the second composite is deferred polish. Risk:
the ghost shows the opaque type-default underlay backing rather than retail's
underlay-less translucent copy. Oracle: `IconData::RenderIcons` 407594-407625
(m_pDragIcon path); deep-dive §3.2/§5.5.
- **TS-33** (Temporary stopgap): *Toolbar `IItemListDragHandler.HandleDropRelease`
is a logging stub — no `AddShortcut 0x019C`/`RemoveShortcut 0x019D` wire, no
`ShortcutStore` mutation.* Where: `ToolbarController.HandleDropRelease`. Awaiting:
Stream B.2. Risk: a drop onto the bar visibly does nothing but log; reorder/add/
remove are inert until B.2. Oracle: `gmToolbarUI::HandleDropRelease`
acclient_2013_pseudo_c.txt:197971.
**Note (not a register row):** the payload omits `SourceContainer`; the handler
resolves the live container via `ClientObjectTable.Get(ObjId).ContainerId` at drop.
Same container id retail reads off the dragged element — single source of truth, no
stale snapshot → no observable behavior deviation.
---
## 8. Testing (conformance)
App-layer tests in `tests/AcDream.App.Tests/UI/` (no GL — pure logic):
1. `GetDragPayload` returns null for an empty cell, a correct `ItemDragPayload` for a
bound cell (ObjId/SourceKind/SourceSlot/SourceCell).
2. `BeginDrag` does NOT arm (`DragSource == null`) when the source's payload is null;
arms + fires `DragBegin` when non-null.
3. Drop dispatch: a fake `IItemListDragHandler` registered on a `UiItemList` receives
`OnDragOver` on DragEnter and `HandleDropRelease` on DropReleased with the right
`(list, cell, payload)`; NOT called on a `Data0==0` self/empty drop.
4. Accept/reject overlay state flips to Accept when the handler returns true, Reject
when false, None on leave.
5. Use-vs-drag: a Click (no intervening drag) fires `Clicked`; a completed drag does
not (drive `UiRoot` MouseDown→move>3px→MouseUp and assert `Clicked` didn't fire).
6. `FindList()` resolves the parent `UiItemList` through the cell's `Parent` chain.
Drive these through `UiRoot`'s public `OnMouseDown/OnMouseMove/OnMouseUp` where
possible (integration-level), so the device chain is exercised, not just the cell in
isolation.
---
## 9. Acceptance criteria
- [ ] `dotnet build` green; `dotnet test` green (existing + the new §8 tests).
- [ ] `UiRoot` has zero compile-time dependency on `UiItemSlot`/`ItemDragPayload`
(item-agnostic; only the two `UiElement` virtuals bridge).
- [ ] AP-47 + TS-33 rows added in the implementation commit; counts bumped.
- [ ] **Visual (user):** with `ACDREAM_RETAIL_UI=1`, in-world, grab a hotbar item
and drag it: a translucent ghost follows the cursor; the hovered slot shows the
accept (green) frame, a slot that rejects shows red; on release over another
slot the log prints `[B.2 TODO] drop obj … → toolbar slot N`; the source item
stays put (no wire yet). A plain click still USES the item; a drag does not.
- [ ] Memory updated if a durable lesson emerged; roadmap/ISSUES note if needed.
---
## 10. Subagent task slices (for the plan)
1. **Toolkit core**`ItemDragPayload`/`ItemDragSource`, the two `UiElement`
virtuals, `UiRoot.BeginDrag` payload pull + cancel, `UiRoot.Draw` ghost.
2. **Cell hooks**`UiItemSlot`: `SlotIndex`/`SourceKind`, `GetDragPayload`/
`GetDragGhost`, MouseDown→Click move, the DragEnter/Over/DropReleased cases +
accept/reject overlay; `IItemListDragHandler`; `UiItemList.DragHandler`.
3. **Toolbar stub + wiring**`ToolbarController : IItemListDragHandler`, register +
set SlotIndex/SourceKind, the logging `HandleDropRelease`; register rows.
4. **Tests** — the §8 suite.
(Slices 1→2 are sequential; 3 depends on 2; 4 can follow each.)

View file

@ -0,0 +1,235 @@
# Design — D.2b Sub-phase B-Grid (inventory sub-window mount + UiItemList grid mode)
**Date:** 2026-06-20
**Phase:** D.2b retail-UI arc, Sub-phase **B** (inventory window), step **B-Grid** — the first of
four (B-Grid → B-Controller → B-Wire → B-Drag). Decomposition approved 2026-06-20.
**Status:** approved design shape, dat-grounded, pre-implementation.
**Predecessor:** Sub-phase A (window manager) shipped; F12 toggles a hidden placeholder window
registered as `WindowNames.Inventory`. B-Grid makes that window show the real nested frame.
---
## 1. Context & goal
B-Grid makes `LayoutImporter.Import(0x21000023)` (gmInventoryUI) produce the **full nested
inventory frame** and gives `UiItemList` an **N-cell grid** so item cells tile. After B-Grid,
F12 shows the real bordered inventory frame — outer chrome + a backpack strip + a 3D-items area
+ an (empty) paperdoll panel — replacing the placeholder. Cells are empty until B-Controller
populates them; paperdoll *content* is Sub-phase C.
**Goal is structural, not interactive:** the frame renders with the correct nested geometry and
a working grid widget. No wire, no controller, no drag (those are B-Wire / B-Controller / B-Drag).
---
## 2. The dat-grounded finding (why this design)
Dumping `LayoutDesc 0x21000023` (via `AcDream.Cli dump-vitals-layout`) overturned the research
agent's "game-class Type + recursive Import" model. The actual structure:
- Root `0x100001CC`, Type `0x10000023` (gmInventoryUI), 300×362. Children:
- `0x100001CD` paperdoll panel — **Type 0**, `BaseElement 0x100001D4`, **`BaseLayoutId 0x21000024`**, childless, **no own media**, 224×214 @ (0,23)
- `0x100001CE` backpack panel — **Type 0**, `BaseElement 0x100001C8`, **`BaseLayoutId 0x21000022`**, childless, **no own media**, 61×339 @ (239,23)
- `0x100001CF` 3D-items panel — **Type 0**, `BaseElement 0x100001C4`, **`BaseLayoutId 0x21000021`**, childless, **no own media**, 234×120 @ (0,237)
- `0x100001D2` close button — Type 0, inherits, **has own media** (Normal/Normal_pressed)
- `0x100001D3` title — Type 0, inherits, **has own media** (DirectState)
- `0x100001D0` backdrop, `0x100001D1` bottom rule — Type 3, `BaseElement 0`, own media
So the three panels are **Type-0 pure-container leaves that nest via the existing
`BaseElement`+`BaseLayoutId` inheritance path** — *not* game-class Types. Their content lives in
the separate layouts `0x21000024/22/21`.
`LayoutImporter.Resolve` already loads `BaseLayoutId`, finds `BaseElement`, recurses it, and
`Merge`s — **but** `ElementReader.Merge` (`ElementReader.cs:162`) sets
`Children = new List(derived.Children)`, dropping the base's children. So today the panels import
as **empty containers**. The fix is a surgical attach of the base's resolved children to the
panel — not a new Type-dispatch mechanism.
---
## 3. Scope
**In scope (B-Grid):**
- **Sub-window mount**`LayoutImporter.Resolve` attaches a base element's resolved children to
a childless, media-less inheriting element (the panels), so `Import(0x21000023)` yields the
full nested tree.
- **`UiItemList` grid mode** — column count + cell pitch so `AddItem`/`OnDraw` tile cells in a
grid; single-cell (toolbar) behavior preserved.
- Unit tests; a regression guard that vitals/chat/toolbar import unchanged.
**Out of scope (deferred):**
- Populating cells from `ClientObjectTable`, the burden meter, find-by-id binding → **B-Controller**.
- Inventory wire gaps (`DropItem`/`ViewContents`/…) → **B-Wire**.
- Inventory cell as a drag source → **B-Drag**.
- Paperdoll *content* rendering (the `UiViewport` doll) → **Sub-phase C**. (B-Grid's mount will
pull in the paperdoll panel's equip-slot frames as ordinary elements, but the 3D doll viewport
is C.)
- `0x10000032` (UiItemSlot) factory registration — **not needed**: `UiItemList.ConsumesDatChildren`
is true, so the importer skips cell templates; cells are built procedurally.
- Cell pitch *values* + exact column counts per panel — read by **B-Controller** from the nested
layouts at bind time. B-Grid supplies the mechanism, not the numbers.
---
## 4. Component 1 — sub-window mount (`LayoutImporter.Resolve`)
Restructure `Resolve` to capture the base's children and attach them when the derived element is a
pure container that inherits from a base with content:
```csharp
private static ElementInfo Resolve(
DatCollection dats, ElementDesc d, HashSet<(uint, uint)> baseChain)
{
var self = ToInfo(d);
var result = self;
List<ElementInfo>? baseChildren = null;
if (d.BaseElement != 0 && d.BaseLayoutId != 0
&& baseChain.Add((d.BaseLayoutId, d.BaseElement)))
{
var baseLd = dats.Get<LayoutDesc>(d.BaseLayoutId);
var baseDesc = baseLd is null ? null : FindDesc(baseLd, d.BaseElement);
if (baseDesc is not null)
{
var baseInfo = Resolve(dats, baseDesc, baseChain);
result = ElementReader.Merge(baseInfo, self);
baseChildren = baseInfo.Children; // capture the base's resolved subtree
}
}
// Derived's own children (authoritative when present).
foreach (var kv in d.Children)
result.Children.Add(Resolve(dats, kv.Value, new HashSet<(uint, uint)>()));
// Sub-window mount: a PURE-CONTAINER leaf (no own children AND no own media) that inherits
// from a base WITH content attaches the base's subtree. This targets the gmInventoryUI
// panels (0x100001CD/CE/CF — Type-0, media-less, childless, BaseLayoutId → a gm*UI window)
// and is inert for: media-bearing inheritors (close button/title keep their own media,
// so self.StateMedia is non-empty → skipped), normal elements with their own children
// (result.Children already populated → skipped), and childless style-prototype inheritors
// (vitals/chat/toolbar text — the base prototype has no children → baseChildren empty).
if (LayoutImporter.ShouldMountBaseChildren(d.Children.Count, self.StateMedia.Count, baseChildren?.Count ?? 0))
result.Children.AddRange(baseChildren!);
return result;
}
/// <summary>True when a pure-container leaf should inherit its base's subtree (sub-window mount):
/// the derived element has no own children, no own state media, and the base resolved to ≥1 child.</summary>
internal static bool ShouldMountBaseChildren(int derivedChildCount, int derivedMediaCount, int baseChildCount)
=> derivedChildCount == 0 && derivedMediaCount == 0 && baseChildCount > 0;
```
The predicate is extracted as `ShouldMountBaseChildren` so it is **unit-testable without dats**.
**Coordinate correctness:** the panel inherits the base window-root's fields via `Merge` (so it
gets that window's size) and the base's children via the mount. The children's X/Y are relative to
the panel; `ScreenPosition` composes parent offsets, so a child at panel-local (x,y) with the panel
at (0,23) lands at the right screen pixel.
**Cycle safety:** `baseChain` already guards the inheritance recursion; the nested layouts are
distinct ids, so no cycle.
---
## 5. Component 2 — `UiItemList` grid mode
Add a column count + cell pitch; keep single-cell as the default.
```csharp
public int Columns { get; set; } = 1; // grid columns; 1 = single column
public float CellWidth { get; set; } // 0 = "fill the list" (single-cell legacy)
public float CellHeight { get; set; }
```
Layout rule (applied in `AddItem` and re-applied in `OnDraw` so a list resize reflows):
- **Fill mode** (`CellWidth <= 0`): the single cell fills the list — `cell[0]` at
`(0, 0, Width, Height)`. Unchanged toolbar behavior.
- **Grid mode** (`CellWidth > 0`): `cell[i]` at `(col·CellWidth, row·CellHeight, CellWidth,
CellHeight)` where `col = i % Columns`, `row = i / Columns`.
The toolbar constructor keeps adding its one cell with `CellWidth = 0` (defaults), so it stays in
fill mode. B-Controller sets `Columns` + `CellWidth/CellHeight` (read from the nested layout's
ItemList element + cell template) and `Flush()` + `AddItem()`s the grid cells.
The grid math is a pure helper for testing:
```csharp
internal static (float x, float y) CellOffset(int index, int columns, float cellW, float cellH)
{
int col = index % columns, row = index / columns;
return (col * cellW, row * cellH);
}
```
---
## 6. Testing
**Grid mode (pure, `tests/AcDream.App.Tests/UI/`):**
- `CellOffset(4, 3, 36, 36)``(36, 36)` (index 4 → col 1, row 1).
- Build a `UiItemList { Columns = 3, CellWidth = 36, CellHeight = 36 }`, `AddItem` 7 cells, assert
`GetItem(4)` is at `Left == 36 && Top == 36 && Width == 36`.
- Single-cell legacy: a default `UiItemList` (CellWidth 0) keeps its cell at the list size after
`OnDraw` — assert the existing toolbar behavior is unchanged.
**Mount predicate (pure):**
- `ShouldMountBaseChildren(0, 0, 5)` ⇒ true (panel: childless, media-less, base has 5 kids).
- `ShouldMountBaseChildren(0, 1, 5)` ⇒ false (close button: has own media).
- `ShouldMountBaseChildren(2, 0, 5)` ⇒ false (has own children).
- `ShouldMountBaseChildren(0, 0, 0)` ⇒ false (style prototype: base childless — vitals text).
**Regression guard (hard acceptance):** vitals (`0x2100006C`), chat (`0x21000006`), and toolbar
(`0x21000016`) must import + render unchanged. Their inherited bases are childless prototypes, so
the mount is inert for them — but this is verified, not assumed (the existing importer tests +
the visual check).
---
## 7. Divergence register
**No new row.** The mount makes `BaseElement`/`BaseLayoutId` inheritance carry the base's content
subtree, which is what retail must do for the gmInventoryUI panels to render at all (they are
childless in the dat — the content is only reachable through the base reference). This is
*completing* faithful inheritance, not diverging from it. IA-12 (toolkit-defined UI) already
covers the importer's reconstruction of keystone semantics. If a future retail-decomp pass shows
retail's child-inheritance differs in the children-present merge case (which no inventory element
exercises), that is a separate, later concern.
---
## 8. Acceptance criteria
- `dotnet build` green; `dotnet test` green (grid + mount-predicate tests added).
- `Import(0x21000023)` yields a tree where the three panels (`0x100001CD/CE/CF`) each have
children (the nested layout content), confirmed by a dat-backed assertion or the dump.
- Vitals/chat/toolbar import + render unchanged (regression guard).
- With `ACDREAM_RETAIL_UI=1`, F12 shows the real nested inventory frame (chrome + backpack strip +
3D-items area + empty paperdoll panel) instead of the blank placeholder.
- Visual verification by the user.
---
## 9. Open / confirmed-at-build items
- **Cell pitch + column counts** — B-Controller reads them from the nested layouts
(`0x21000022` backpack, `0x21000021` 3D-items) at bind time. Geometry from the outer dump
suggests the backpack strip is ~1 column and the 3D-items panel ~6 columns of 36×36, but the
exact values are B-Controller's to read, not B-Grid's to hardcode.
- The placeholder mount in `GameWindow` (Sub-phase A) is replaced here: instead of a bare
`UiNineSlicePanel`, the inventory window becomes `LayoutImporter.Import(0x21000023)`'s root,
registered under the same `WindowNames.Inventory`. (Mechanically a few lines in the RetailUi
block; the F12 wiring is untouched.)
---
## 10. References
- Dat dump: `AcDream.Cli dump-vitals-layout "<datdir>" 0x21000023` (the structure in §2).
- Format: `docs/research/2026-06-15-layoutdesc-format.md` (§8 Type table, §10/§12 inheritance).
- Code: `src/AcDream.App/UI/Layout/LayoutImporter.cs` (`Resolve`/`Merge` path),
`src/AcDream.App/UI/Layout/ElementReader.cs:162` (the children-drop), `UiItemList.cs`,
`UiItemSlot.cs` (the shipped drag spine — reused in B-Drag, untouched here).
- Handoff/decomposition: `docs/research/2026-06-20-window-manager-inventory-handoff.md` §4;
the Sub-phase B decomposition (B-Grid → B-Controller → B-Wire → B-Drag).

View file

@ -0,0 +1,181 @@
# D.2b toolbar collapse-to-one-row — design
**Date:** 2026-06-20
**Phase:** D.2b retail-UI, toolbar polish (follows D.5.3 B.1/B.2, both visually confirmed this session).
**Branch:** `claude/hopeful-maxwell-214a12`.
**Driver:** user request — the toolbar frame should resize vertically between **one row** (row 2 hidden,
the minimum) and **two rows** (row 2 shown), **snapping** between the two stops.
---
## 1. Goal & non-goals
**Goal.** The toolbar window can be collapsed to show only the top quickslot row (slots 19) or
expanded to show both rows (slots 118), by dragging its **bottom edge**. The drag **snaps** to the
nearer of two height stops — collapsed (row 2 hidden) or expanded (row 2 shown). Default = expanded
(today's look). Horizontal size stays fixed; the window still moves by grabbing empty cells / chrome
(IA-12). This is a toolkit UX defined from the user's retail observation — the real mechanism lives in
`keystone.dll` (no decomp); our research notes the dat just stacks two always-present rows, so the dat
encodes no collapse. Recorded as an amendment to **IA-17** (toolbar frame is toolkit-supplied).
**Non-goals:** a collapse/expand BUTTON (it's a bottom-edge resize); horizontal resize; persisting the
collapsed state across sessions (it resets to expanded each launch — persistence is the deferred
window-manager Plan-2); animating the snap.
---
## 2. Geometry (from the layout, not hardcoded)
The toolbar `LayoutDesc 0x21000016` root is **300×122**; the two rows are top `0x100001A7..AF` and
bottom `0x100006B7..BF`, with the bottom row's slots at content-y ≈ 90 (deep-dive §2a table, slot 9 at
`6,90`). Heights are computed at mount time from the actual layout, so there is no magic constant:
- `border` = `RetailChromeSprites.Border` (5 px).
- `ExpandedHeight` = `contentHeight + 2·border` (today's frame height; `contentHeight` = the imported
root's `Height`, 122).
- `CollapsedHeight` = `minRow2Top + 2·border`, where `minRow2Top` = the smallest `Top` among the nine
resolved row-2 slot elements (`0x100006B7..BF`). That cuts the frame just above row 2.
- `snapMidpoint` = `(CollapsedHeight + ExpandedHeight) / 2`.
---
## 3. Components
### 3.1 `UiElement``MaxHeight` + a `ResizableEdges` mask — `src/AcDream.App/UI/UiElement.cs`
Today resize clamps to a minimum only and (with `ResizeY`) treats BOTH vertical edges as grips. Add two
small generic members:
```csharp
/// <summary>Maximum height enforced while resizing (default unbounded). Pairs with MinHeight.</summary>
public float MaxHeight { get; set; } = float.MaxValue;
/// <summary>Which edges may start a resize, beyond the ResizeX/ResizeY axis gates. Default: all.
/// Set to e.g. ResizeEdges.Bottom to allow only a bottom-edge drag (the collapse toolbar).</summary>
public ResizeEdges ResizableEdges { get; set; } =
ResizeEdges.Left | ResizeEdges.Right | ResizeEdges.Top | ResizeEdges.Bottom;
```
(`MaxWidth` is YAGNI — only height needs it here.)
- **`UiRoot.HitEdges`** applies the mask at the end: `e &= w.ResizableEdges;` (after the existing
`ResizeX`/`ResizeY` masking). So a toolbar with `ResizableEdges = Bottom` only grips its bottom edge;
a press near the top edge falls through to window-move, not resize.
- **`UiRoot.ResizeRect`** gains a `maxH` parameter and clamps the Bottom/Top height branches:
`h = Math.Clamp(startH + dy, minH, maxH)` (Bottom) and the Top branch likewise. `OnMouseMove`'s resize
call passes `_resizeTarget.MaxHeight` (and `float.MaxValue` for the width's maxW). **This changes
`ResizeRect`'s signature — update its existing callers + the `UiRootInputTests.ResizeRect_*` tests to
pass the new `maxW`/`maxH` args** (`float.MaxValue` where unbounded, preserving their current
assertions).
### 3.2 `UiCollapsibleFrame : UiNineSlicePanel` (new) — `src/AcDream.App/UI/UiCollapsibleFrame.cs`
A toolbar-frame variant that snaps between two heights and toggles a set of "second-row" elements.
One clear responsibility: reconcile its height to a stop and the rows to that stop, every tick.
```csharp
public sealed class UiCollapsibleFrame : UiNineSlicePanel
{
public UiCollapsibleFrame(Func<uint,(uint,int,int)> resolveChrome) : base(resolveChrome) { }
public float CollapsedHeight { get; set; }
public float ExpandedHeight { get; set; }
/// <summary>Elements shown only when expanded (the row-2 slot lists). Hidden when collapsed.</summary>
public IReadOnlyList<UiElement> SecondRow { get; set; } = System.Array.Empty<UiElement>();
/// <summary>True when the frame is currently at (or nearer) the expanded stop.</summary>
public bool IsExpanded => Height >= (CollapsedHeight + ExpandedHeight) * 0.5f;
protected override void OnTick(double dt)
{
base.OnTick(dt);
if (ExpandedHeight <= CollapsedHeight) return; // not configured yet
// Snap to the nearer stop (the resize drag sets Height live; we resolve it to a stop so the
// frame always rests collapsed or expanded — never a half-row).
bool expanded = IsExpanded;
Height = expanded ? ExpandedHeight : CollapsedHeight;
// Row 2 is shown only when expanded. (No clipping needed — the dat content is top-anchored,
// so row-2 slots simply stop drawing when hidden; row 1 never moves.)
for (int i = 0; i < SecondRow.Count; i++) SecondRow[i].Visible = expanded;
}
}
```
Notes:
- The snap runs in `OnTick` (after the frame's `MinHeight`/`MaxHeight`-clamped resize drag set `Height`
that frame), so the rendered height is always a stop. With only two stops one row apart, this reads
as: drag the bottom edge past the midpoint → it jumps to the other stop + row 2 appears/hides.
- `IsExpanded`/the snap use the midpoint; `MinHeight`/`MaxHeight` (set by the mount) keep the drag
within `[Collapsed, Expanded]` so the midpoint test is well-defined.
### 3.3 GameWindow toolbar mount — `src/AcDream.App/Rendering/GameWindow.cs` (~line 2045)
- Build the frame as `UiCollapsibleFrame` instead of `UiNineSlicePanel` (same `ResolveChrome` ctor arg).
- After the content (`toolbarRoot`) is sized: compute `expandedH = toolbarContentH + 2·border`,
`collapsedH = minRow2Top + 2·border` where `minRow2Top` = `min` of the nine row-2 lists' `Top`
(resolve each via `toolbarLayout.FindElement(0x100006B7..BF)`; reuse `ToolbarController`'s row-2 id
list or inline the nine ids).
- Set on the frame: `Resizable = true; ResizableEdges = ResizeEdges.Bottom` (bottom-edge only — top
edge stays a move grip); `MinHeight = collapsedH; MaxHeight = expandedH; Height = expandedH` (default
expanded); `CollapsedHeight = collapsedH; ExpandedHeight = expandedH; SecondRow = <the nine row-2
elements>`. (`ResizeX`/`ResizeY` keep defaults; the `ResizableEdges = Bottom` mask is the operative
restriction.)
- Change `toolbarRoot.Anchors` from all-four-edges to **`Left | Top | Right`** (drop `Bottom`) so the
dat content keeps its full height and row 1 never reflows when the frame collapses; row 2 hides via
`Visible`. (Width is fixed — `ResizeX=false` — so the horizontal anchors are inert but harmless.)
---
## 4. Behavior walk-through
- **Launch:** frame at `ExpandedHeight`, both rows visible (unchanged from today).
- **Collapse:** grab the bottom edge, drag up past the midpoint → `OnTick` snaps `Height` to
`CollapsedHeight` and hides the nine row-2 slots. The frame is now a single-row bar; row 1 unchanged.
- **Expand:** drag the bottom edge down past the midpoint → snaps to `ExpandedHeight`, row 2 reappears.
- **Move:** unchanged — drag an empty cell / chrome to reposition (IA-12); occupied cells drag items
(B.1/B.2).
- **Edge cases:** `MinHeight`/`MaxHeight` clamp the drag to `[Collapsed, Expanded]`; the snap is
idempotent when not dragging (Height already at a stop). The collapsed state is per-session (resets
to expanded on relaunch).
---
## 5. Divergence register
**Amend IA-17** (toolbar window FRAME is toolkit-supplied): add that the frame also supports a
toolkit-defined **collapse-to-one-row** (bottom-edge resize snapping between a row-1-only and a
two-row height, row-2 visibility tied to the stop). Retail's real collapse mechanism is keystone.dll
(no decomp) and the dat encodes no collapse (both rows always present) — so this is our toolkit UX from
the user's retail observation, same justification class as the rest of IA-17. No new row; extend IA-17's
text + cite this spec.
---
## 6. Testing
`tests/AcDream.App.Tests/UI/`:
1. `UiCollapsibleFrame.OnTick` snap: set `CollapsedHeight=96`, `ExpandedHeight=128`; set `Height` just
below the midpoint (e.g. 100) + tick → `Height == 96` and every `SecondRow` element `Visible==false`;
set `Height` just above (e.g. 120) + tick → `Height == 128` and `SecondRow` `Visible==true`.
2. `UiCollapsibleFrame` not-configured guard: `ExpandedHeight==CollapsedHeight==0``OnTick` is a
no-op (no divide/no forced height).
3. `UiRoot.ResizeRect` MaxHeight clamp: a Bottom-edge resize with `dy` huge clamps `h` to `maxH`;
a Top-edge resize likewise; min still honored. (Drive via the existing `ResizeRect` static test
pattern in `UiRootInputTests`; also update the two pre-existing `ResizeRect_*` tests to the new
`maxW`/`maxH` signature — pass `float.MaxValue`, assertions unchanged.)
4. `UiRoot.HitEdges` honors `ResizableEdges`: a panel with `ResizableEdges = ResizeEdges.Bottom` returns
only `Bottom` when pressed near its bottom edge, and `None` near its top edge (which would otherwise
be a grip with `ResizeY` true).
A `UiCollapsibleFrame` needs a chrome resolver in tests — pass `_ => (1u,1,1)` (the existing
`UiNineSlicePanel` test pattern); `OnTick` doesn't draw, so no GL.
---
## 7. Acceptance
- [ ] `dotnet build` + `dotnet test` green.
- [ ] IA-17 amended.
- [ ] **Visual (user):** default shows both rows; dragging the toolbar's **bottom edge up** snaps it to
a single row (row 2 gone); dragging **down** snaps back to two rows; row 1 never moves/squishes;
the window still moves by dragging empty cells/chrome; item drag (B.1/B.2) still works.
---
## 8. Plan size
One small task (TDD): `UiElement.MaxHeight` + `ResizeRect` clamp → `UiCollapsibleFrame` + its tests →
the GameWindow mount swap + IA-17 amend. ~3 files + 1 test file. Suitable for a single implementer pass.

View file

@ -0,0 +1,386 @@
# D.2b toolbar shortcut drag interactivity (Stream B.2) — design
**Date:** 2026-06-20
**Phase:** D.2b retail-UI → D.5 core panels. Stream **B.2** of the 2026-06-18 handoff — make the
toolbar shortcut drag *functional* (reorder / remove) and *retail-faithful*, on top of the B.1
spine (`9d48346..acdefc2`, shipped + visually confirmed this session).
**Branch:** `claude/hopeful-maxwell-214a12`.
**Driver:** user visual-gate feedback 2026-06-20 (6 points) + the retail research below.
**Read alongside:**
- `docs/research/2026-06-16-action-bar-toolbar-deep-dive.md` §5 (HandleDropRelease/RemoveShortcut/wire).
- `docs/research/2026-06-16-ui-item-slot-icon-dragdrop-spine-deep-dive.md` §5 (the cell drag chain).
- `docs/superpowers/specs/2026-06-20-d2b-drag-drop-spine-design.md` (B.1 — what this extends).
- `claude-memory/project_d2b_retail_ui.md`.
---
## 1. Goal & non-goals
**Goal.** A player drags a hotbar shortcut, sees the item icon lift off the slot (slot empties) and
follow the cursor at full opacity with a green-cross drop indicator; dropping on another slot moves
it there (reordering, bumping any occupant back to the source slot); dropping anywhere else removes
it from the bar. All changes are sent to the server (`AddShortcut 0x019C` / `RemoveShortcut 0x019D`)
so they persist. This realizes the user's 6 visual-gate points as retail's single **remove-on-lift /
place-on-drop / no-restore** mechanism.
**In scope:**
1. **Spine extensions** (small, to the B.1 toolkit): a drag-LIFT hook to the source's handler;
full-opacity ghost snapshotted at drag-begin (survives the source emptying); `FinishDrag`
delivers a drop only when released on a real element (off-bar = no drop fires).
2. **`ShortcutStore`** — a mutable 18-slot model (port of retail `ShortCutManager::shortCuts_[18]`).
3. **`ToolbarController` as the live drag handler** — `OnDragLift` removes; `HandleDropRelease`
places + bumps displaced → source; both drive the store, the wire, and a re-`Populate()`.
4. **The wire** — fix `BuildAddShortcut` param names; add `WorldSession.SendAddShortcut` /
`SendRemoveShortcut`.
5. **Green-cross accept overlay** (`0x060011FA`) on toolbar cells (verified against the dat).
**Out of scope (later streams):**
- **Drag FROM inventory onto the bar** (the `flags & 0xE == 0` "fresh-from-inventory" branch) — needs
the inventory window as a drag source (Stream C). This spec is reorder-within-bar + remove only
(the `flags & 4` branch).
- Spell shortcuts (the `SpellId|Layer` payload) — items only (`spellId=layer=0`); the store carries a
spell field for forward-compat but the toolbar binds item guids.
- Server-pushed shortcut mutations after login (the store is client-authoritative post-`Load`).
- The selected-object mana meter / stack-split UI (issue #141 deferred bits).
---
## 2. Retail grounding (the model — CONFIRMED by research 2026-06-20)
Retail's toolbar drag is **remove-on-lift, place-on-drop, no-restore** (decomp trace, this session):
- **On drag-begin** (`UIElement_ItemList::ItemList_BeginDrag` 0x004e32d0 → broadcasts msg `0x21`
`gmToolbarUI::RecvNotice_ItemListBeginDrag` 0x004bd930): `PrepareDragIcon` makes the **full icon**
the cursor ghost (m_dragIcon), then `gmToolbarUI::RemoveShortcut` (0x004bd450) **removes the
shortcut**: `ItemList_Flush` → cell → empty state `0x1000001c`, `Event_RemoveShortCut` sends
`0x019D`, and `m_lastShortcutNumDragged` = the source slot. The slot is now empty; the item is "in
hand."
- **On mouse-up over a slot** (`UIElementManager::StopDragandDrop` 0x00459810 → success →
`gmToolbarUI::HandleDropRelease` 0x004be7c0): within-bar reorder branch (`flags & 4`):
`RemoveShortcutInSlotNum(target)` (evict the occupant, get its objId) → `AddShortcut(draggedObjId,
target)` (`0x019C`); if an item was displaced and the source slot is free, `AddShortcut(displaced,
m_lastShortcutNumDragged)` — the bumped item lands in the vacated source slot. (deep-dive §5.)
- **On mouse-up over nothing** (`m_pElementLastDragCursorOver == null` → success=0): no
`HandleDropRelease`, no re-add — the shortcut stays gone (already removed + `0x019D` sent at lift).
- **No cancel/restore path.** `gmToolbarUI` has no end-drag/restore vtable entry. Lifted-then-not-
landed = permanently removed (server already told).
- **Drop indicator** is a per-slot OVERLAY sprite on `m_elem_Icon_DragAccept` (`0x1000045A`), NOT a
cursor change (`UpdateCursorState` is combat/target-only). The toolbar accept sprite is the **green
cross `0x060011FA`** (verified by exporting the `0x060011F7..FA` family from `client_portal.dat`:
F7=green move-arrow, F8=red ∅ reject, F9=green **ring** [inventory], FA=green **cross** [toolbar]);
reject = `0x060011F8`.
**Wire (`ShortCutData`, CONFIRMED 3 refs — deep-dive §131-145):**
- `AddShortCut 0x019C` body after the 12-byte GameAction envelope: `Index(u32), ObjectId(u32),
SpellId(u16), Layer(u16)` (item → `ObjectId=guid, SpellId=Layer=0`).
- `RemoveShortCut 0x019D` body: `Index(u32)`.
- ACE handles the remove-then-add reorder pattern (`Player_Character.cs:254`).
---
## 3. Architecture — components & boundaries
```
press+move on OCCUPIED slot ─► UiRoot.BeginDrag
│ snapshot ghost (full icon) BEFORE the lift clears the source cell
│ fire DragBegin on the source cell
UiItemSlot.OnEvent(DragBegin) ─► FindList().DragHandler.OnDragLift(list, cell, payload)
ToolbarController.OnDragLift ─► ShortcutStore.Remove(srcSlot); SendRemoveShortcut(srcSlot); Populate()
(source slot now empty; ghost still shows the snapshot)
drag-over target slot ─► UiItemSlot DragEnter ─► handler.OnDragOver → green-cross accept overlay (FA)
release ─► UiRoot.FinishDrag:
├─ over a slot ─► that cell.OnEvent(DropReleased) ─► handler.HandleDropRelease(target, payload)
│ ShortcutStore place + bump displaced→srcSlot; SendAddShortcut×N; Populate()
└─ over nothing ─► no DropReleased delivered → lift's removal stands (item gone)
```
**Boundaries:** `UiRoot`/`UiElement`/`UiItemSlot`/`UiItemList` stay item-agnostic — they gain a lift
*hook* and a ghost snapshot, nothing item-specific. `ShortcutStore` is pure logic in `AcDream.Core`
(testable, no GL). The wire lives in `AcDream.Core.Net`. `ToolbarController` (App) orchestrates.
Honors structure Rules 1/2/3/6.
---
## 4. New & changed types (precise)
### 4.1 Spine — `IItemListDragHandler` gains the lift hook (`UI/IItemListDragHandler.cs`)
```csharp
public interface IItemListDragHandler
{
/// <summary>The drag STARTED from a cell in this list — retail's
/// RecvNotice_ItemListBeginDrag → RemoveShortcut (decomp 0x004bd930/0x004bd450): the handler
/// removes the lifted item from its model + wire, so the source slot empties immediately. The
/// item is "in hand" until HandleDropRelease (place) or the drag ends off-target (stays removed).</summary>
void OnDragLift(UiItemList sourceList, UiItemSlot sourceCell, ItemDragPayload payload);
bool OnDragOver(UiItemList targetList, UiItemSlot targetCell, ItemDragPayload payload);
void HandleDropRelease(UiItemList targetList, UiItemSlot targetCell, ItemDragPayload payload);
}
```
### 4.2 Spine — `UiItemSlot` (`UI/UiItemSlot.cs`)
- `OnEvent` `DragBegin` case changes from `return true` (no-op) to: notify the list's handler so it
can lift:
```csharp
case UiEventType.DragBegin:
if (FindList() is { DragHandler: { } h } list && e.Payload is ItemDragPayload p)
h.OnDragLift(list, this, p);
return true;
```
- `DropReleased` case: drop the `e.Data0 == 1` gate — reaching the cell means a real slot was hit
(`FinishDrag` only delivers on a hit). The handler is authoritative (place; drop-on-self re-adds):
```csharp
case UiEventType.DropReleased:
_dragAccept = DragAcceptState.None;
if (FindList() is { DragHandler: { } dh } dl && e.Payload is ItemDragPayload dp)
dh.HandleDropRelease(dl, this, dp);
return true;
```
(`DragEnter`/`DragOver` overlay handling unchanged from B.1.)
### 4.3 Spine — `UiRoot` (`UI/UiRoot.cs`)
- Add `private (uint tex, int w, int h)? _dragGhost;`. In `BeginDrag`, **snapshot the ghost before
firing `DragBegin`** (the lift will empty the source cell, so a live re-read would vanish):
```csharp
private void BeginDrag(UiElement source)
{
var payload = source.GetDragPayload();
if (payload is null) { _dragCandidate = false; return; }
DragSource = source;
DragPayload = payload;
_dragGhost = source.GetDragGhost(); // snapshot NOW — survives the source emptying
var e = new UiEvent(source.EventId, source, UiEventType.DragBegin, Payload: payload);
source.OnEvent(in e); // → cell → handler.OnDragLift (clears the source slot)
}
```
- `DrawDragGhost` uses the snapshot, not a live read; `GhostAlpha`**1.0** (full opacity, retail
m_dragIcon):
```csharp
private const float GhostAlpha = 1.0f;
private void DrawDragGhost(UiRenderContext ctx)
{
if (_dragGhost is not { } g || g.tex == 0) return;
ctx.DrawSprite(g.tex, MouseX - g.w / 2f, MouseY - g.h / 2f, g.w, g.h,
0f, 0f, 1f, 1f, new Vector4(1f, 1f, 1f, GhostAlpha));
}
```
- `FinishDrag` delivers a drop ONLY to a real hit element; off-bar (null hit) fires nothing; clear
the ghost:
```csharp
private void FinishDrag(int x, int y)
{
var (t, lx, ly) = HitTestTopDown(x, y);
if (t is not null)
{
var e = new UiEvent(DragSource!.EventId, t, UiEventType.DropReleased,
Data1: (int)lx, Data2: (int)ly, Payload: DragPayload);
t.OnEvent(in e); // the hit cell's handler places; a non-item target ignores it → item stays removed
}
// else: dropped off any element — no drop fires; the lift's removal stands (retail).
DragSource = null; DragPayload = null; _dragGhost = null; _lastDragHoverTarget = null;
}
```
(`GetDragGhost()` on `UiElement`/`UiItemSlot` stays as-is — `BeginDrag` calls it once.)
### 4.4 NEW — `ShortcutStore` (`src/AcDream.Core/Items/ShortcutStore.cs`)
Port of retail `ShortCutManager::shortCuts_[18]` (`acclient.h:36492`). Pure logic.
```csharp
public sealed class ShortcutStore
{
public const int SlotCount = 18;
private readonly uint[] _objIds = new uint[SlotCount]; // 0 = empty
/// <summary>Replace all slots from the login PlayerDescription shortcut list.</summary>
public void Load(IReadOnlyList<PlayerDescriptionParser.ShortcutEntry> entries)
{
System.Array.Clear(_objIds);
foreach (var e in entries)
if (e.Index < SlotCount && e.ObjectGuid != 0) _objIds[e.Index] = e.ObjectGuid;
}
public uint Get(int slot) => (uint)slot < SlotCount ? _objIds[slot] : 0u;
public bool IsEmpty(int slot) => Get(slot) == 0u;
public void Set(int slot, uint objId) { if ((uint)slot < SlotCount) _objIds[slot] = objId; }
public void Remove(int slot) { if ((uint)slot < SlotCount) _objIds[slot] = 0u; }
}
```
### 4.5 Wire — `InventoryActions.BuildAddShortcut` rename (`Core.Net/Messages/InventoryActions.cs`)
Byte layout is already correct; FIX the misleading param names (handoff §B.2; deep-dive §131-145):
```csharp
/// <summary>Pin an item/spell to a quickbar slot. ShortCutData = Index(u32),ObjectId(u32),
/// SpellId(u16),Layer(u16). For an item: objectGuid + spellId=layer=0.</summary>
public static byte[] BuildAddShortcut(uint seq, uint index, uint objectGuid, ushort spellId, ushort layer)
{
byte[] body = new byte[24];
BinaryPrimitives.WriteUInt32LittleEndian(body, GameActionEnvelope);
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(4), seq);
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(8), AddShortcutOpcode); // 0x019C
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(12), index);
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(16), objectGuid);
BinaryPrimitives.WriteUInt16LittleEndian(body.AsSpan(20), spellId);
BinaryPrimitives.WriteUInt16LittleEndian(body.AsSpan(22), layer);
return body;
}
```
(`BuildRemoveShortcut(seq, slotIndex)` is correct — unchanged.) Any existing caller of the old
4-arg `BuildAddShortcut` must be updated; grep first (likely none wired today).
### 4.6 Wire — `WorldSession` sends (`Core.Net/WorldSession.cs`, mirror `SendChangeCombatMode` :1134)
```csharp
public void SendAddShortcut(uint index, uint objectGuid, ushort spellId = 0, ushort layer = 0)
{
uint seq = NextGameActionSequence();
SendGameAction(InventoryActions.BuildAddShortcut(seq, index, objectGuid, spellId, layer));
}
public void SendRemoveShortcut(uint index)
{
uint seq = NextGameActionSequence();
SendGameAction(InventoryActions.BuildRemoveShortcut(seq, index));
}
```
### 4.7 `ToolbarController` — the live handler (`UI/Layout/ToolbarController.cs`)
- Holds a `ShortcutStore _store`. `Populate()` reads `_store` (18 slots → `repo.Get(objId)` → icon)
INSTEAD of the read-only `_shortcuts()` provider. **Lazy-load-once:** the PlayerDescription shortcut
list arrives AFTER Bind (at login), so load the store the first time `_shortcuts()` is non-empty,
then treat it as authoritative:
```csharp
// top of Populate(), before rendering:
if (!_storeLoaded && _shortcuts().Count > 0) { _store.Load(_shortcuts()); _storeLoaded = true; }
```
Bind's initial `Populate` (pre-login) sees an empty list and skips the load; the existing
`repo.ObjectAdded → Populate` path (shortcut items arriving via `CreateObject`, by which time the PD
shortcut list is set) triggers the one-time load. After load, drag ops mutate `_store` directly and
it is never reloaded within the session (a relog = a fresh process → `_storeLoaded` resets). No
GameWindow change is needed for loading.
- `IsShortcutGuid(guid)` (the repo-event gate) checks `_store` (any slot holds `guid`) rather than
`_shortcuts()`, so an item dragged ONTO the bar (a new guid) still gets its later updates rendered.
- Each toolbar cell: `DragAcceptSprite = 0x060011FAu` (green cross). (`DragRejectSprite` stays
`0x060011F8`.)
- Inject two `Action`s for the wire (so the controller stays testable without a live session):
`Action<uint,uint> sendAdd` = `(index, objId) => session.SendAddShortcut(index, objId)`,
`Action<uint> sendRemove` = `index => session.SendRemoveShortcut(index)`. Null in tests.
- `OnDragLift(sourceList, sourceCell, payload)` — retail `RemoveShortcut`:
```csharp
_store.Remove(payload.SourceSlot);
_sendRemove?.Invoke((uint)payload.SourceSlot);
Populate();
```
- `HandleDropRelease(targetList, targetCell, payload)` — retail reorder branch (deep-dive §5):
```csharp
int target = targetCell.SlotIndex;
uint evicted = _store.Get(target); // RemoveShortcutInSlotNum
if (evicted != 0) { _store.Remove(target); _sendRemove?.Invoke((uint)target); }
_store.Set(target, payload.ObjId); _sendAdd?.Invoke((uint)target, payload.ObjId); // AddShortcut
if (evicted != 0 && evicted != payload.ObjId && _store.IsEmpty(payload.SourceSlot))
{ // bump displaced → vacated source slot
_store.Set(payload.SourceSlot, evicted);
_sendAdd?.Invoke((uint)payload.SourceSlot, evicted);
}
Populate();
```
- `OnDragOver` unchanged from B.1 (accept any real item → green-cross overlay).
---
## 5. Data flow — frame by frame (reorder slot 3 → occupied slot 5)
1. Press+move on slot 3 (objId A). `BeginDrag`: snapshot ghost = A's icon; fire `DragBegin`.
2. Cell 3 → `OnDragLift`: `_store.Remove(3)` + `SendRemoveShortcut(3)` (0x019D) + `Populate()`
slot 3 shows empty. Ghost (A's full icon) follows the cursor.
3. Drag over slot 5 (objId B) → green-cross accept overlay on slot 5.
4. Release on slot 5 → `FinishDrag` delivers `DropReleased` to cell 5 → `HandleDropRelease`:
`evicted=B`; `Remove(5)`+`SendRemoveShortcut(5)`; `Set(5,A)`+`SendAddShortcut(5,A)`; source slot 3
is empty → `Set(3,B)`+`SendAddShortcut(3,B)`; `Populate()` → slot 5 = A, slot 3 = B (swapped).
5. (Off-bar variant) release over the 3D world → no `DropReleased` → A stays removed → slot 3 empty,
A gone from the bar (server already told at step 2).
---
## 6. Edge cases
- **Drop on self (slot 3 → slot 3):** at lift slot 3 emptied; at drop `evicted=0`, `Set(3,A)` +
`SendAddShortcut(3,A)` → A back at 3. Net no-op (2 wire msgs — retail does the same).
- **Drop on a non-item element** (chrome / another window): `FinishDrag` delivers `DropReleased` to
it; its `OnEvent` doesn't handle it → no place → A stays removed (off-bar). Correct.
- **Ghost lifetime:** snapshotted at `BeginDrag`, cleared in `FinishDrag` — independent of the source
cell emptying. A cancelled drag (mouse-up off-bar) still clears `_dragGhost`.
- **Empty-slot lift:** an empty slot is not a drag source (`IsDragSource => ItemId != 0` from B.1) →
no drag arms → no lift. (And empty slots still move the window — IA-12, from B.1.)
- **Spell shortcuts in the loaded list** (`ObjectGuid == 0`, a spell): `ShortcutStore.Load` skips
them (item-only this stream); they neither render nor drag. Forward-compat: the store could hold
spell entries later.
---
## 7. Divergence register (update in the implementation commits)
- **AP-47 (amend):** the ghost is now **full opacity** (retail m_dragIcon) — retire the
`GhostAlpha=0.6` reduced-alpha approximation. The row's REMAINING approximation: the ghost reuses
the full composited `m_pIcon` (which includes the type-default underlay) rather than retail's
dedicated underlay-less `m_pDragIcon`. Reword AP-47 to drop the alpha claim, keep the underlay note.
- **TS-33 (retire):** the toolbar `HandleDropRelease` logging stub is replaced by the real
store-mutation + `AddShortcut`/`RemoveShortcut` wire. Delete the row.
- **No new row** for remove-on-lift / off-bar-remove — it is the faithful retail mechanism (it
*reduces* divergence). The store being client-authoritative post-login matches retail's
`ShortCutManager` (client owns the array; server is notified via the wire).
---
## 8. Testing (conformance)
Core tests (`tests/AcDream.Core.Tests/`):
1. `ShortcutStore`: `Load` maps Index→ObjId (skips ObjectGuid==0 + out-of-range); `Set`/`Remove`/
`Get`/`IsEmpty`; bounds-safe.
2. `BuildAddShortcut`: 24-byte body, envelope+seq+`0x019C`+index+objectGuid+spellId(u16)+layer(u16) at
the right offsets (golden bytes). `BuildRemoveShortcut`: 16 bytes, index at +12.
App tests (`tests/AcDream.App.Tests/`):
3. Spine: `BeginDrag` snapshots the ghost so it survives the source cell being cleared mid-drag (clear
the source cell after begin; assert the drawn ghost source is still the snapshot — assert via a
testable `UiRoot` accessor for `_dragGhost`).
4. Spine: `FinishDrag` over null delivers NO `DropReleased` (a spy cell/handler isn't called); over a
real cell, delivers it (drop the Data0 gate — drop-on-self now dispatches).
5. `ToolbarController.OnDragLift`: removes the source slot from the store, invokes `sendRemove(src)`,
and the source cell empties after `Populate`.
6. `ToolbarController.HandleDropRelease` reorder onto an OCCUPIED target: store ends with dragged@target
+ displaced@source; `sendRemove`+`sendAdd` called with the retail sequence/args. Onto an EMPTY
target: dragged@target, source stays empty, displaced-branch not taken. Drop-on-self: re-adds to
source.
7. Update the B.1 tests changed by §4.2/§4.3: `DropReleased_notAccepted_skipsDispatch`
`DropReleased_alwaysDispatchesToHandler` (the cell dispatches whenever it receives a DropReleased);
the full-chain drag tests still pass (a completed reorder fires `HandleDropRelease`, not `Clicked`).
---
## 9. Acceptance criteria
- [ ] `dotnet build` + `dotnet test` green (Core + App).
- [ ] AP-47 reworded (no alpha); TS-33 deleted; wire builder renamed; no stray old-signature callers.
- [ ] `UiRoot`/`UiElement`/`UiItemSlot` remain item-agnostic (the lift hook is generic; only
`ToolbarController` knows shortcuts).
- [ ] **Visual (user), `ACDREAM_RETAIL_UI=1`, in-world:** lift a hotbar item → **slot empties
immediately**, **full-opacity** icon follows the cursor with a **green cross** over hovered
slots; drop on another slot → it **moves there** (occupant bumps to the source slot); drop
off-bar → it's **removed**; a single click still **uses** the item. Changes **persist after
relog** (the wire reached ACE).
- [ ] Memory crib + roadmap/ISSUES updated; #141's note refreshed if relevant.
---
## 10. Subagent task slices
1. **Core: `ShortcutStore` + wire**`ShortcutStore.cs` + `BuildAddShortcut` rename +
`WorldSession.SendAddShortcut`/`SendRemoveShortcut` + Core tests (§8.1-2).
2. **Spine extensions**`IItemListDragHandler.OnDragLift`; `UiItemSlot` DragBegin→lift + DropReleased
ungate; `UiRoot` ghost snapshot + full opacity + `FinishDrag` deliver-on-hit-only; update the B.1
tests (§8.3-4, §8.7).
3. **ToolbarController handler**`_store` + `Load` + `Populate`-from-store; green-cross sprite;
`OnDragLift`/`HandleDropRelease`; wire `Action`s; GameWindow injects the session sends; controller
tests (§8.5-6). Update AP-47, delete TS-33.
(1 and 2 are independent; 3 depends on both.)

View file

@ -0,0 +1,245 @@
# Design — D.2b window manager (open/close + F12 inventory toggle)
**Date:** 2026-06-20
**Phase:** D.2b retail-UI arc, Sub-phase **A** (window manager) — the first of three
(A window manager → B inventory window → C paperdoll). Handoff:
`docs/research/2026-06-20-window-manager-inventory-handoff.md` §3.
**Status:** approved design, pre-implementation.
---
## 1. Context & goal
Every retail-UI window today is **always-on at a hardcoded position**: vitals, chat, and
the toolbar are added to `_uiHost.Root` unconditionally at startup in the `_options.RetailUi`
block of `GameWindow.OnLoad` (grep `_uiHost.Root.AddChild`). `UiHost`/`UiRoot` have **no
open/close API** — `UiRoot` exposes the widget tree, input routing, focus, capture, modal,
and drag-drop, but nothing to show/hide/raise a named top-level window.
**Goal:** the minimal shared infrastructure to **open/close a top-level window** and a
keybind to **toggle the inventory window**. This is the prerequisite for Sub-phase B
(the inventory window) and C (paperdoll) — both need a window that starts hidden and is
summoned on demand.
**Non-goal:** the inventory window's *contents*. Sub-phase A toggles a throwaway
**placeholder** window; Sub-phase B replaces its body with the real `gmInventoryUI`
layout. See §9.
---
## 2. Scope
**In scope (A):**
- A named-window registry on `UiRoot` with `Show` / `Hide` / `Toggle` / `BringToFront`.
- Raise-on-click for top-level windows (retail-faithful window stacking).
- F12 → toggle the inventory window, via the **existing** `InputAction.ToggleInventoryPanel`.
- A minimal placeholder inventory window (default-hidden) so A is visually verifiable and
gives B a concrete mount point.
- Unit tests for the registry logic; divergence-register bookkeeping.
**Out of scope (deferred):**
- Inventory window contents, grid, sub-window mount, wire gaps, `InventoryController`**B**.
- Paperdoll / `UiViewport`**C**.
- Disk persistence of open-state + window positions → handoff "Plan-2".
- A toolbar inventory *button* (radar/menu buttons) — F12 only for now; the button can be a
later cheap addition once the inventory window exists.
- Spell bar — explicitly deferred by the handoff.
---
## 3. Architecture decision — registry on `UiRoot`
`UiRoot` already owns exactly the state a window manager manipulates: the top-level
`Children`, `ZOrder` (drives both paint order in `DrawSelfAndChildren` and hit-test order in
`HitTestTopDown`), keyboard focus, mouse capture, and the modal overlay. "Show / hide / raise
a top-level window" is the job it already does — so the registry lives there.
This mirrors retail, where the Keystone root (`DAT_00870c2c`) owns the widget tree and window
stacking. (Keystone is an external DLL with no decomp, so the manager is **toolkit-defined**,
not a byte-faithful port — see §7.)
**Alternatives considered and rejected:**
- **Dedicated `WindowManager` class wrapping `UiRoot`** — cleaner single-responsibility on
paper, but it must reach into `UiRoot.Children` / `ZOrder` to mount + raise, so it just
borrows `UiRoot`'s internals through a new seam. Ceremony for ~30 lines of logic.
- **Thin API on `UiHost`**`UiHost` is the facade `GameWindow` talks to, but the tree and
`ZOrder` live in `UiRoot`; this would be a pure forwarding layer with no added value.
`UiHost` gets **two convenience forwarders** (`ToggleWindow`, `RegisterWindow`) that delegate
to `Root`, because `GameWindow` holds a `UiHost`, not a `UiRoot`, at the call sites.
---
## 4. Components & interfaces
### 4.1 `UiRoot` window registry
A private `Dictionary<string, UiElement> _windows` plus:
```csharp
/// <summary>Register a top-level window under a name for Show/Hide/Toggle.
/// Idempotent (re-register replaces). The dict is the source of truth —
/// independent of UiElement.Name, which is init-only and not reassigned here.</summary>
public void RegisterWindow(string name, UiElement window);
/// <summary>Make the named window visible and raise it to the front.
/// No-op if unknown. Returns true if a window was shown.</summary>
public bool ShowWindow(string name);
/// <summary>Hide the named window (Visible = false). No-op if unknown.</summary>
public bool HideWindow(string name);
/// <summary>Flip the named window's visibility; Show (raise) if it was hidden,
/// Hide if it was visible. Returns the new IsVisible state.</summary>
public bool ToggleWindow(string name);
/// <summary>Set element.ZOrder to one above the current max among top-level
/// children, so it paints + hit-tests above its peers.</summary>
public void BringToFront(UiElement window);
```
- `Show`/`Hide` only flip `UiElement.Visible`; `Visible` already gates Draw, Tick, and
HitTest (all early-return when false), so a hidden window is fully inert — no draw, no
input, no tooltip timer.
- `RegisterWindow` does **not** add the element to the tree — the caller mounts it with
`AddChild` (so registration and tree-membership stay independent and the caller controls
initial `Visible`). Registration only records the name→element mapping.
- `BringToFront`: `window.ZOrder = 1 + max(child.ZOrder for child in Children)`. The existing
default windows mount at `ZOrder = 0`, so an opened/clicked window rises above them.
### 4.2 Raise-on-click
In `UiRoot.OnMouseDown`, after the existing `FindWindow(target)` resolves the top-level
window under the press, call `BringToFront(window)` so the clicked window comes to the top of
the stack (retail raises clicked windows). This is the existing `FindWindow` result reused —
~3 lines, no new traversal. Applies to **any** top-level window (vitals, chat, toolbar,
inventory), not just registered ones; the registry is only for named Show/Hide/Toggle.
### 4.3 `UiHost` forwarders
```csharp
public void RegisterWindow(string name, UiElement window) => Root.RegisterWindow(name, window);
public bool ToggleWindow(string name) => Root.ToggleWindow(name);
```
### 4.4 F12 wiring — `OnInputAction`
`InputAction.ToggleInventoryPanel` already exists, is already bound to **F12** in
`KeyBindings.RetailDefaults()` (matching `docs/research/named-retail/retail-default.keymap.txt`
line 149: `ToggleInventoryPanel [ "" [ 0 DIK_F12 ] ]`), and is already listed in the Settings
rebind UI — so it's user-rebindable with no new wiring. Today **nothing consumes it.**
Add one arm to the `switch (action)` in `GameWindow.OnInputAction` (the `InputDispatcher.Fired`
multicast subscriber; the switch begins at GameWindow.cs:11421):
```csharp
case AcDream.UI.Abstractions.Input.InputAction.ToggleInventoryPanel:
_uiHost?.ToggleWindow(WindowNames.Inventory);
break;
```
- Gating is already correct: the dispatcher is suppressed by `WantsKeyboard` when the chat
input holds focus, so F12 cannot toggle inventory while you're typing.
- `WindowNames.Inventory` is a `const string "inventory"` (a small `WindowNames` holder in
`AcDream.App.UI`), so the mount, the registry, and the toggle agree on one literal.
### 4.5 Placeholder inventory window mount
In the `_options.RetailUi` block of `GameWindow.OnLoad`, alongside the vitals/chat/toolbar
mounts, build a minimal framed window:
```csharp
var inventoryWindow = new AcDream.App.UI.UiNineSlicePanel(ResolveChrome)
{
Left = 200, Top = 120, Width = 320, Height = 400,
Visible = false, // starts hidden; F12 reveals it
Draggable = true,
Anchors = AcDream.App.UI.AnchorEdges.None,
};
_uiHost.Root.AddChild(inventoryWindow);
_uiHost.RegisterWindow(AcDream.App.UI.WindowNames.Inventory, inventoryWindow);
```
This uses the same `UiNineSlicePanel(ResolveChrome)` + `RetailChromeSprites.Border` chrome
the vitals/chat windows use, so the placeholder already has the retail beveled frame. It is
**throwaway scaffolding**: Sub-phase B replaces the body (and likely the whole construction)
with the real `gmInventoryUI 0x21000023` nested layout, keeping the same registry name so the
F12 wiring is untouched.
---
## 5. Persistence
In-memory for the session only. Open/closed state lives in `UiElement.Visible`; window
positions are the hardcoded mount positions (already reset each session). No disk persistence
— deferred to the handoff's Plan-2.
---
## 6. Testing
Unit tests in `tests/AcDream.App.Tests/UI/UiRootInputTests.cs` (or a sibling
`UiRootWindowManagerTests.cs`) — the registry logic is pure (no GL), so it tests directly:
- `RegisterWindow` + `ToggleWindow` flips `Visible` false→true→false and returns the new state.
- `ShowWindow` raises `ZOrder` above all peers; `HideWindow` sets `Visible=false` without
touching `ZOrder`.
- `BringToFront` on the lowest window makes its `ZOrder` strictly greatest among siblings.
- `ToggleWindow`/`ShowWindow`/`HideWindow` on an unknown name are no-ops returning false.
- Raise-on-click: an `OnMouseDown` on a registered window with a peer in front leaves the
clicked window with the greatest `ZOrder` (reuses the existing `UiRootInputTests` harness
pattern for synthesizing mouse events).
`dotnet build` + `dotnet test` green (full suite, currently 2752+).
---
## 7. Divergence register
The window manager is **toolkit-defined** — retail's window stacking lives in `keystone.dll`
(no decomp), so this is the existing IA-12 / IA-15 toolkit-adaptation umbrella, not a
byte-faithful port. Confirm whether an existing register row covers
"retail-UI windows are toolkit-managed, not Keystone-ported"; if not, add a one-line row in
`docs/architecture/retail-divergence-register.md` in the implementation commit. **F12 itself
is retail-faithful** (matches the retail default keymap) — no divergence for the keybind.
---
## 8. Acceptance criteria
- `dotnet build` green; `dotnet test` green.
- With `ACDREAM_RETAIL_UI=1`, pressing **F12** shows the placeholder inventory window above
vitals/chat/toolbar; pressing **F12** again hides it.
- Clicking any window (vitals, chat, toolbar, inventory) raises it above the others.
- F12 does **nothing** while the chat input is focused (write mode).
- Window manager logic is unit-tested; divergence-register row confirmed/added.
- Visual verification by the user (the toggle + raise behavior).
---
## 9. Seam for Sub-phase B
A leaves B a clean handoff:
- The registry name `WindowNames.Inventory` and the F12 → `ToggleWindow` wiring are stable;
B keeps both.
- B replaces the placeholder `UiNineSlicePanel` with the real `gmInventoryUI 0x21000023`
nested layout (paperdoll + backpack + 3D-items), re-registering it under the same name.
- B adds the dat **close button**`HideWindow(WindowNames.Inventory)` wiring (the manager
already exposes `HideWindow`; A leaves the placeholder closable only via F12).
- B wires the inventory cell as a drag **source**, completing B.2's drag-from-inventory via
the `SourceKind == Inventory` branch in `ToolbarController.HandleDropRelease`.
---
## 10. References
- Handoff: `docs/research/2026-06-20-window-manager-inventory-handoff.md` §3 (Sub-phase A).
- Retail keymap: `docs/research/named-retail/retail-default.keymap.txt` lines 139153
(panel-toggle commands; inventory = F12) + line 246 (`Laugh` = `DIK_I`).
- Toolkit: `src/AcDream.App/UI/UiRoot.cs`, `UiElement.cs`, `UiHost.cs`,
`UiNineSlicePanel.cs`; `claude-memory/project_d2b_retail_ui.md`.
- Input pipeline: `OnInputAction` switch at `src/AcDream.App/Rendering/GameWindow.cs:11421`;
`InputAction.ToggleInventoryPanel`; `KeyBindings.cs:205`;
`claude-memory/project_input_pipeline.md`.
- Object model (for B): `claude-memory/project_object_item_model.md`,
`src/AcDream.Core/Items/ClientObjectTable.cs`.

View file

@ -0,0 +1,265 @@
# D.2b Sub-phase B-Controller — inventory population design
**Date:** 2026-06-21
**Phase:** D.2b retail-UI engine → core panels → Sub-phase B (inventory) → **B-Controller**
**Branch:** `claude/hopeful-maxwell-214a12`
**Predecessor:** B-Grid (sub-window mount + `UiItemList` grid mode) + #145 (ZLevel z-order) — shipped.
Handoff: `docs/research/2026-06-21-d2b-bgrid-shipped-bcontroller-next-handoff.md`.
## 0. Goal
Make the F12 inventory window show the player's live contents. Bind the imported
`gmInventoryUI` (`LayoutDesc 0x21000023`) tree by element id and populate it from
`ClientObjectTable`:
1. the **"Contents of Backpack" grid** with the player's pack items,
2. the **right-strip pack-selector** (main-pack cell + side-bag container icons),
3. the **vertical burden meter** + the `%` text,
4. the **Type-0 captions** ("Burden", "Contents of Backpack").
This is the `gmInventoryUI::PostInit` / `gmBackpackUI::PostInit` / `gm3DItemsUI::PostInit`
analogue. **Read-only**: no container switching, no drag-as-source, no wield/drop wire
(those are B-Wire / B-Drag / Sub-phase C).
Both scope calls were taken by the user during brainstorming: **faithful pack-selector**
(populate the right strip too) and **full faithful burden meter** (vertical fill + ported
`InqLoad` formula).
## 1. The inventory tree (confirmed by dat dump + decomp)
`gmInventoryUI` `0x21000023` (300×362) is a frame nesting three sub-windows (mounted by
B-Grid's sub-window mount; all reachable via `ImportedLayout.FindElement`):
| Sub-window | LayoutDesc | Mounted at | Size | This phase |
|---|---|---|---|---|
| paperdoll | `0x21000024` | `0x100001CD` | 224×214 | **Sub-phase C** (not here) |
| backpack strip | `0x21000022` | `0x100001CE` | 61×339 | **here** |
| 3D-items ("Contents of Backpack") | `0x21000021` | `0x100001CF` | 234×120 | **here** |
### Elements this controller binds
| Id | Role | Resolved widget | Source |
|---|---|---|---|
| `0x100001C6` | 3D-items **contents grid** (192×96) | `UiItemList` (grid) | `gm3DItemsUI::PostInit` m_itemList `DynamicCast(0x10000031)` (decomp 176734-176742) |
| `0x100001C5` | "Contents of Backpack" caption (192×15) | Type-0 → caption pass | `gm3DItemsUI::PostInit` m_contentsText, `SetText` 176745 |
| `0x100001CA` | backpack **m_containerList** (36×252, 1 col) | `UiItemList` (grid) | `gmBackpackUI::PostInit` `DynamicCast(0x10000031)` (176621-176629) |
| `0x100001C9` | backpack **m_topContainer** (36×36, 1 cell) | `UiItemList` (single) | `gmBackpackUI::PostInit` (176612-176620) |
| `0x100001D9` | **burden meter** (vertical 11×58, Type 7) | `UiMeter` | `gmBackpackUI::PostInit` `DynamicCast(7)` (176601-176610) |
| `0x100001D8` | burden **`%` text** (36×15) | Type-0 → caption pass | `gmBackpackUI` m_burdenText, `SetText "%d%%"` (176583) |
| `0x100001D7` | "Burden" caption (36×15) | Type-0 → caption pass | dat |
The three list elements (`0x100001C6/C9/CA`) are Type-0 in the dat, inheriting
`BaseElement 0x10000339 / BaseLayoutId 0x2100003D` (the `UIElement_ItemList` prototype) →
`ElementReader.Merge` resolves them to Type `0x10000031``DatWidgetFactory` builds them as
`UiItemList`. Already verified reachable by id through the mount.
## 2. Architecture
New `src/AcDream.App/UI/Layout/InventoryController.cs`, mirroring `ToolbarController` /
`VitalsController` (Code-Structure Rule 1 — no new feature body in `GameWindow`):
```csharp
public sealed class InventoryController
{
public static InventoryController Bind(
ImportedLayout invLayout,
ClientObjectTable objects,
Func<uint> playerGuid, // _playerServerGuid
Func<ItemType,uint,uint,uint,uint,uint> iconIds, // IconComposer.GetIcon
Func<int?> strength, // LocalPlayerState Strength.Current
UiDatFont? datFont);
public void Dispose(); // unsubscribe (controllers currently don't, but
// the inventory rebuild is event-driven — be tidy)
}
```
Bound **inline in GameWindow's existing inventory-init block** (the `invLayout` local from
B-Grid, `GameWindow.cs` ~2141), right after `RegisterWindow(WindowNames.Inventory, …)`
exactly where `ToolbarController.Bind(toolbarLayout, …)` sits for the toolbar.
Lifecycle: `Bind` find-by-id binds all elements, subscribes to
`objects.ObjectAdded/ObjectMoved/ObjectRemoved` and `LocalPlayerState` attribute/`Changed`
events, then calls `Populate()` once. Each subscription calls `Populate()` (filtered: only
when the changed object is in the player's possession subtree, mirroring
`ToolbarController.Populate`'s `IsShortcutGuid` gate). Missing elements are skipped silently
(partial-layout tolerance, like `SelectedObjectController`).
## 3. Population (`Populate()`)
Reads `var contents = objects.GetContents(playerGuid())` (→ `IReadOnlyList<uint>`, slot-ordered).
Partition each guid's `ClientObject`:
- **side bag** = `Type.HasFlag(ItemType.Container)` OR `ItemsCapacity > 0`.
- **loose item** = everything else.
Then:
- **3D-items grid `0x100001C6`**`Columns=6`, `CellWidth=CellHeight=32` (192÷32=6 cols ×
96÷32=3 rows; cell template `UIItem 0x21000037` = 32×32). `Flush()`, then `AddItem(new
UiItemSlot{…})` + `cell.SetItem(guid, iconIds(item.Type, item.IconId, item.IconUnderlayId,
item.IconOverlayId, item.Effects))` per **loose item**. (Pitch 32 vs 36 → **visual-confirm**;
192/32=6 is the clean fit and matches the handoff "6 cols" note.) Overflow past 3 rows is
clipped by the panel for now; scroll = follow-up (the `0x100001C7` gutter is the scrollbar).
- **m_containerList `0x100001CA`**`Columns=1`, 32px cells. One cell per **side bag**, same
`SetItem`. Empty on a char with no side bags (correct).
- **m_topContainer `0x100001C9`** — single cell = the **main pack**. Icon: a fixed backpack
RenderSurface DID (pin from the decomp / a known UI icon; if none found, leave the
empty-slot art and file a follow-up — do NOT invent a guid). Bound object id = `playerGuid()`
(the main pack ≡ the player container).
Reuse the shipped `UiItemList` grid mode (`Columns`/`CellWidth`/`CellHeight`/`Flush`/`AddItem`)
and `UiItemSlot.SetItem(guid, tex)` + `IconComposer.GetIcon`. **Do not rebuild the spine.**
## 4. Burden meter (faithful vertical port)
### 4.1 Retail formula (ported, with anchors)
`gmBackpackUI::SetLoadLevel(double load)` (decomp 176536, `0x004a6ea0`):
- `fill = clamp(load × 0.3333…, 0, 1)` → pushed to the meter as float attribute `0x69`
(`UIElement::SetAttribute_Float`, 176565-176573).
- `%` text = `floor((load×⅓) × 300) = floor(load × 100)` formatted `"%d%%"`
`UIElement_Text::SetText(m_burdenText)` (176576-176583).
`load = CACQualities::InqLoad()` (decomp 409756, `0x0058f130`):
```
strength = InqAttribute(1) // primary attr 1 = Strength, default 10
aug = InqInt(0xE6) // capacity augmentation, default 0
capacity = EncumbranceSystem::EncumbranceCapacity(strength, aug)
burden = InqInt(5) // PropertyInt 5 = EncumbranceVal (total carried)
load = EncumbranceSystem::Load(capacity, burden)
```
`EncumbranceSystem::EncumbranceCapacity(str, aug)` (decomp 256393, `0x004fcc00`):
```
if str <= 0: return 0
bonus = clamp(aug × 30, 0, 150) // 0x1e=30, cap 0x96=150
return str × 150 + bonus × str // = str × (150 + bonus)
```
`EncumbranceSystem::Load(capacity, burden)` (decomp 256413, `0x004fcc40`): the decompiler
mangled the FP body to `if cap<=0 return cap; return burden`; the structure (a divide-by-zero
guard + a single divide) is unambiguously `load = burden / capacity` — the encumbrance ratio
(1.0 = at capacity, up to ~3.0). **Cross-ref:** matches acdream's existing
`BurdenMath.ComputeMax = 150×str + str×bonus` (`ClientObject.cs:215`) and the r06 research doc
("maxBurden = 150 × Strength + Strength × bonusBurden; carry limit 3 × maxBurden"). ACE was
not checked out in this worktree; the decomp + existing port + r06 doc are three corroborating
sources.
**acdream port:** `maxBurden = BurdenMath.ComputeMax(strength, bonus)` (reuse; `bonus = clamp(aug×30,0,150)`,
`aug` from player PropertyInt `0xE6`, default 0 → `str×150`). `load = burden / maxBurden`.
`fill = clamp(load/3, 0, 1)`. `%text = floor(load×100)`.
### 4.2 Burden data source
acdream does **not** track the player's wire `EncumbranceVal` today (WorldSession parses only
per-item Burden). Priority:
1. player `ClientObject`'s PropertyInt 5: `objects.Get(playerGuid())?.Properties.GetInt(5)`
populated iff PD/`0x02CE` carried it (B-Wire guarantees this later).
2. fallback: client-side sum of carried `Burden` over the player's possession subtree
(`GetContents(player)` + recurse side bags + items with `WielderId == player`).
3. neither → `currentBurden = 0` → bar empty / "0%" (correct "no data" state).
`strength` from `LocalPlayerState.GetAttribute(Strength)?.Current` (default 10 if absent,
matching `InqAttribute`'s `0xa` default). **Divergence row** if (2) is used (client recompute
vs server EncumbranceVal — same quantity).
Drive the meter via `UiMeter.Fill = () => fill` and a `Label`/caption provider for the `%`
text, recomputed on `ObjectAdded/Moved/Removed` + `LocalPlayerState.Changed/AttributeChanged`.
### 4.3 Vertical fill (the one `UiMeter` change)
Per `UIElement_Meter::DrawChildren` (decomp 123574, `0x0046fbd0`): the meter draws its **own
DirectState media as the track (full)** and the **`m_pcChildImage` child clipped to attribute
`0x69` (the fill fraction)**. So `BuildMeter`'s existing single-image assignment is **already
correct** (meter-own `0x0600121D``BackTile`/track; child `0x0600121C``FrontTile`/fill).
**No sprite-role change.**
The clip axis/direction is `m_eDirection` (read from LayoutDesc property `0x6f` at
`UIElement_Meter::Initialize`, decomp 123334-123336): `1`=L→R, `2`=T→B, `3`=R→L, `4`=B→T.
`UiMeter` is hardcoded `1`. Add:
- a `UiMeter.Vertical` (or `Direction`) flag,
- a `DrawVBar` that draws `BackTile` over the full rect then `FrontTile` clipped to
`Height × fraction` along the fill direction. The burden child sprite (11×61) ≈ the bar
(11×58) — effectively single-sprite, no 3-slice tiling needed.
Direction source: read property `0x6f` if `ElementReader` surfaces it; **else** infer vertical
from `Width < Height` and default **B→T (dir 4)** — the AC convention for a vertical resource
bar — with a **visual-confirm** and an AP register row for the geometry-inference. (Reading
`0x6f` is preferred; decide in the plan based on how cheaply `ElementReader` can expose it.)
## 5. Captions (Type-0 text)
Controller **caption pass** (not a factory Type-0→`UiText` promotion — that would risk other
panels' inert Type-0 elements). For each caption id, attach a centered `UiText` child carrying
the known string, mirroring `SelectedObjectController`'s name-overlay attach and
`VitalsController`'s number overlay:
- `0x100001D7` → "Burden"
- `0x100001C5` → "Contents of Backpack" (procedural in retail via `SetText`, 176743-176745)
- `0x100001D8` → the live `%` text (`floor(load×100)` + "%"), updated with the meter.
Caption strings are hardcoded with their decomp citation (retail sets `0x100001C5`/`0x100001D8`
procedurally; `0x100001D7` "Burden" is the dat element's label). Font = the retail dat font
(`datFont`), color/justification per the Type-0 base (left/normal). If the dat string for
`0x100001D7` is trivially readable from the layout, prefer it over the hardcode.
## 6. Divergence register rows (add in the impl commit)
- **AP** — burden `currentBurden` computed client-side (Σ carried Burden) when the wire
`EncumbranceVal` (PropertyInt 5) is absent; retail reads the server-maintained value.
Retire when B-Wire parses it.
- **AP** — capacity augmentation (`aug`, PropertyInt `0xE6`) not tracked → `bonus = 0`
un-augmented `str×150`. Buffed/augmented chars read slightly high.
- **AP** (only if dir not read from `0x6f`) — burden meter orientation inferred from `W<H`
geometry + default B→T, vs retail's `m_eDirection` from property `0x6f`.
- **AP** — main-pack `m_topContainer` icon is a fixed/placeholder backpack art, not a
weenie-driven icon (the main pack ≡ the player, which has no item IconId).
## 7. Testing (dat-free conformance)
`tests/AcDream.App.Tests/UI/Layout/InventoryControllerTests.cs` (+ a burden-math test):
- **Bind** — given a synthetic `ImportedLayout` (built via `BuildFromInfos` from ElementInfo
fixtures, or a hand-built tree) with the 7 ids, `Bind` finds the lists/meter and does not
throw on missing ids.
- **Grid population** — N loose items in `GetContents(player)` → the 3D grid has N cells with
the right guids; M side bags → `m_containerList` has M cells; partition correctness
(container vs loose).
- **Burden golden values** (Str=100 → max=15000): burden 0 → load 0 → fill 0 / "0%"; 7500 →
0.5 → 0.1667 / "50%"; 15000 → 1.0 → 0.3333 / "100%"; 45000 → 3.0 → 1.0 (clamped) / "300%";
60000 → 4.0 → 1.0 (clamped) / "400%". Assert `fill` and `%text` separately.
- **Capacity**`EncumbranceCapacity(100, 0)=15000`; `(100, 3)=15000+min(90,150)·100=24000`;
`(100, 10)=15000+min(300→150)·100=30000`; `(0, x)=0`.
- **Vertical fill rect**`UiMeter` vertical mode: fraction 0.5 of an 11×58 bar fills the
correct half along the fill direction.
- **Caption** — the caption `UiText` children carry the expected strings.
- **Rebuild** — firing `ObjectAdded` for a player-pack item re-runs `Populate` and the new cell
appears; an unrelated guid does not.
Reuse the existing Layout test fixtures (`vitals_2100006C.json` pattern). A real-dat smoke
check of `Import(0x21000023)` + `Bind` is a nice-to-have before wiring (not gating).
## 8. Visual verification (final launch — folded with the z-order eyeball)
Launch with `ACDREAM_RETAIL_UI=1`, F12: confirm (a) pack items show in the "Contents of
Backpack" grid, (b) side bags (if any) + main-pack cell in the right strip, (c) the burden bar
fills vertically the right direction with the right sprites and the `%` reads sanely, (d) the
"Burden"/"Contents of Backpack" captions render, AND (e) chat + toolbar show no #145 z-order
regression. Items (c) direction and (a) cell pitch are the two visual-confirm unknowns.
## 9. Acceptance criteria
- [ ] `InventoryController` binds `0x21000023`'s tree by id; build + tests green.
- [ ] 3D-items grid populates from `GetContents(player)` loose items; right strip from side
bags + main pack.
- [ ] Burden meter renders vertically with the ported formula; `%` text correct; golden tests
pass.
- [ ] Captions render.
- [ ] Every ported AC algorithm cites its decomp anchor in comments; divergence rows added.
- [ ] Visual verification (with the z-order eyeball) by the user.
## 10. Out of scope (explicit non-goals → later sub-phases)
- Container **switching** (click a pack-selector cell → re-point the 3D grid). → interactivity.
- Inventory cell as a **drag source** (`SourceKind==Inventory`). → B-Drag.
- Wire gaps (`DropItem`/`GetAndWieldItem`/`ViewContents`/`SetStackSize`/player `EncumbranceVal`
parse). → B-Wire.
- Paperdoll doll + per-slot equip art (`0x10000032` UiItemSlot registration). → Sub-phase C.
- Scrolling the contents grid past 3 rows.

View file

@ -0,0 +1,172 @@
# D.2b inventory window finish (Stage 1) — design / spec
**Date:** 2026-06-21
**Phase:** D.2b inventory, **Stage 1 of the "full retail inventory" arc** (Stage 2 = paperdoll, separate spec).
**Branch:** `claude/hopeful-maxwell-214a12` (follows B-Wire, tip `7c006d1`).
**Predecessor:** B-Wire (inventory wire layer) — shipped + burden gate passed.
**Status:** approved design → write plan next.
This was triggered by a side-by-side: acdream's inventory vs. a retail client (`+Je`). The gaps the
user flagged — "scroll bar, background all the way, slots for backpacks" — are the 2D window finish.
The 3D paperdoll doll is the heavier, separable Stage 2.
---
## 1. Goal / non-goals
**Goal.** Make the inventory window match retail's 2D presentation:
1. The "Contents of Backpack" grid **clips to its panel and scrolls** (today it renders every row
unclipped, so a full pack overflows off-window — which is *also* why picked-up items "don't appear"
and why the backdrop looks torn at the bottom).
2. The dark **backdrop covers the whole window** (no grass showing through).
3. The **side-bag selector** renders as a proper slot column (bags + empty frames), like retail.
**Non-goals (deferred):**
- **Paperdoll** (3D character doll + per-slot equip silhouettes) → **Stage 2** (needs a Core→App
`UiViewport` render seam + `0x10000032` `UiItemSlot` registration). Research:
`docs/research/2026-06-16-equipment-paperdoll-deep-dive.md`.
- **Drag-to-drop / drag-from-inventory** → B-Drag.
- The **side-bag column's own scrollbar** (`0x100001CB`): 7 slots fit the 252px column exactly, so
no scroll is needed there; leave it inert.
---
## 2. Grounding (from the dat layout dumps — authoritative geometry)
Read from `.layout-dumps/inventory-0x21000023.txt`, `items3d-0x21000021.txt`, `backpack-0x21000022.txt`:
- **Outer frame `0x21000023`** (300×362): the **backdrop `0x100001D0`** is **X=0,Y=0, 300×362**
(full window), Type 3, Alphablend sprite `0x06004D0A`, ZLevel 100. Three sub-windows mount inside:
paperdoll `0x100001CD` (0,23 224×214), backpack `0x100001CE` (239,23 61×339), 3D-items
`0x100001CF` (0,237 234×120).
- **3D-items `0x21000021`** (234×120): **contents grid `0x100001C6` = X=15,Y=20, 192×96** (exactly
6 cols × 3 rows of 32px); **scrollbar `0x100001C7` = X=207,Y=20, 16×96**, Type 0 inheriting base
layout `0x2100003E` (the scrollbar base → resolves to a Type-11 `UiScrollbar` via the inheritance
chain), ZLevel 9 (just behind the grid's 10).
- **Backpack `0x21000022`** (61×339): main-pack cell `0x100001C9` (6,32 36×36); **side-bag column
`0x100001CA` = X=6,Y=73, 36×252** (= 7 slots of 36px), inherits the ItemList base `0x2100003D`;
side-bag scrollbar gutter `0x100001CB` (41,73 16×252) — unused (7 slots fit). Burden meter
`0x100001D9` + captions `0x100001D7/D8` (already wired by B-Controller).
**Key deduction:** the backdrop is geometrically full-window, so the "torn" look is the **unclipped
grid overflowing below Y=362** (41 items / 6 cols = 7 rows × 32 = 224px, drawn from abs Y≈257 down to
≈481, far past the frame's 362 bottom). Clipping the grid (component A) removes that overflow and the
torn-bottom backdrop with it.
---
## 3. Components
### A — Contents-grid clip + scroll (the core)
**A1. `UiItemList` gains a clip+scroll capability**, backed by the existing **`UiScrollable`** model
(the same one `UiText`/the chat transcript + `UiScrollbar` use — do not invent a second scroll model).
- The list clips cell drawing to its own rect (`0x100001C6` = 192×96 = 3 visible rows). Content
height = `ceil(count / Columns) × CellHeight`; view height = the list's `Height`.
- A scroll offset (whole rows, or pixels) shifts the cells up; cells fully outside the view are not
drawn (scissor-clip via `UiRenderContext`, mirroring the transcript's clip).
- Mouse-wheel over the list scrolls it (reuse the wheel→`UiScrollable` path the transcript uses).
- `UiScrollable` exposes the thumb-size + position ratios the bound `UiScrollbar` reads (already its
contract).
**A2. `InventoryController` binds the gutter scrollbar.** Resolve `0x100001C7` from the layout; it
is built by `DatWidgetFactory` as a Type-11 `UiScrollbar` (via its `0x2100003E` base). Bind it to
`_contentsGrid`'s `UiScrollable` — exactly as `ChatWindowController` binds its transcript scrollbar
(`ChatWindowController.cs:237-254`). If `0x100001C7` does not resolve to a `UiScrollbar` (inheritance
surprise), the plan's first step pins why before wiring.
**Acceptance:** with > 18 loose items the grid shows 3 rows + a working scrollbar; picked-up items are
reachable by scrolling; the grid no longer draws past the frame; the bottom backdrop is intact.
### B — Backdrop full coverage
The backdrop `0x100001D0` is already full-window (300×362). **Primary fix is A** (clipping the grid
removes the overflow that tears the bottom). After A lands, the implementer verifies at the visual
gate that the dark backing covers the whole window. **If a residual gap remains** (e.g. the Alphablend
backdrop sprite drawn at native size instead of filled to the element's 300×362 bounds, or a sub-window
region uncovered), root-cause it then — the candidate is `UiDatElement`'s Alphablend draw not
stretching/tiling to bounds. This is a **root-cause-then-fix** task gated on the post-A screenshot, not
a pre-committed code change (a render-coverage bug must be confirmed visually before fixing).
**Acceptance:** no world/grass shows through the inventory window anywhere (matches retail's solid
backing).
### C — Side-bag slot column
The side-bag column `0x100001CA` (36×252 = 7 slots) currently renders **only the actual side bags**
(via `InventoryController.Populate`'s `isBag → _containerList` branch), so a character with one bag
shows one lone cell. Retail shows the bag(s) **plus empty slot frames** filling the column.
- `InventoryController.Populate`: after adding the player's side bags to `_containerList`, **pad with
empty `UiItemSlot`s** up to the slot count. Slot count = the column capacity (252 / cellPitch = 7),
optionally clamped to the player's `ContainersCapacity` when known (> 0); default to the 7-slot
column height otherwise. Empty cells draw the empty-slot art already (the toolbar empty-slot path).
- **Cell pitch = 36** for `_containerList` + `_topContainer` (match `0x100001C9`/`0x100001CA`'s 36px
geometry), not the 32px used for the contents grid. (Today the controller sets `CellPx = 32` for all
three — split the contents-grid pitch (32) from the backpack-column pitch (36).)
**Acceptance:** the side-bag column shows the character's bag(s) in the top slot(s) + empty frames
below, a clean column like retail's.
---
## 4. Design decisions
- **Reuse `UiScrollable`, don't fork it.** It already models content/view/offset + thumb ratios and is
battle-tested by the chat transcript. `UiItemList` becomes a second consumer. (DRY; matches the
widget-generalization ethos.)
- **Scroll by whole rows vs. pixels:** scroll by **pixels** (smooth), clamped so the last row aligns to
the view bottom — matches the transcript's pixel scroll and avoids a janky row-snap. (Plan may choose
row-snap if the dat/retail behavior says so; default pixels.)
- **Backdrop fix is gated on the post-A screenshot** (decision §B) — don't pre-change the Alphablend
draw before confirming A didn't already fix it; avoids touching shared `UiDatElement` render for
every window without cause.
- **Side-bag slot count from `ContainersCapacity` when known, else 7** — the column is physically 7
slots; clamping to capacity matches retail (you see only as many slots as you can use) without a wire
dependency (capacity ships in the weenie header, already parsed).
---
## 5. Testing
- **`UiItemList` clip/scroll (unit, App.Tests):** content-height for N cells; the visible-cell index
range at scroll offset 0 and at max offset; wheel delta updates the offset (clamped); `UiScrollable`
thumb/position ratios for a known content/view. (No GL — assert the model + which cells are in-view.)
- **`InventoryController` (unit, App.Tests, `BuildLayout` harness):** the side-bag column renders
`bags + empties = slotCount` cells (filled first, then empty); the contents grid is bound to the
scrollbar (the `0x100001C7` widget's scroll model is the grid's). A >18-item grid reports content
height > view height (scrollable).
- **Backdrop:** visual gate only (no unit test).
- Full `dotnet build` + `dotnet test` green; existing inventory tests (incl. the B-Wire burden tests)
unbroken.
---
## 6. Divergence register
- If the side-bag slot count uses a fallback (7) when `ContainersCapacity` is absent, add a row noting
the approximation. If B's residual fix stretches the Alphablend backdrop in a way that differs from
retail's exact draw, add a row. Otherwise no new deviations (faithful 2D layout). Any row lands in the
same commit as its deviation (register rule).
---
## 7. Out of scope (explicit)
Paperdoll doll + equip-slot silhouettes (Stage 2); drag-to-drop / drag-from-inventory (B-Drag); the
side-bag column scrollbar `0x100001CB` (inert — 7 slots fit); contents-grid drag-reorder; stack-quantity
overlays.
---
## 8. Acceptance + visual gate
- [ ] Contents grid clips to 3 rows + scrolls (wheel + scrollbar); picked-up items reachable; no
overflow past the frame.
- [ ] Backdrop covers the whole window (no grass through), confirmed at the gate.
- [ ] Side-bag column shows bag(s) + empty slot frames (36px pitch).
- [ ] `dotnet build` + `dotnet test` green.
- [ ] **Visual gate:** open F12 with a full pack → matches retail screenshot 2 (minus the doll):
scrollable grid, solid backdrop, bag-slot column.
Then Stage 2 (paperdoll) gets its own brainstorm → spec → plan.

View file

@ -0,0 +1,272 @@
# 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:
1. Deliver the player's **server-authoritative properties** to the player's `ClientObject`,
so the burden bar reads the wire `EncumbranceVal` instead of a client-side estimate.
(Retires divergence **AP-48** and **AP-49**.)
2. Fix two **latent parse bugs** in existing inbound handlers (dropped fields).
3. 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](../../../src/AcDream.App/UI/Layout/InventoryController.cs)) 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:
1. **Login path.** `GameEventWiring`'s PlayerDescription (`0x0013`) handler
([GameEventWiring.cs:285](../../../src/AcDream.Core.Net/GameEventWiring.cs)) parses the player's
full PropertyInt table into a `PropertyBundle` (`PlayerDescriptionParser``bundle.Ints[5]`)
but **never applies that bundle to the player's `ClientObject`** — 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.)
2. **Live path.** Burden changes (pick up / drop) ride `PrivateUpdatePropertyInt (0x02CD)`
the player's own object, **no guid on the wire**. acdream does not parse `0x02CD` at all (the
`PublicUpdatePropertyInt` 0x02CE doc-comment says exactly this).
3. **Apply gate.** Even parsed `0x02CE` updates only reach the table when
`Property == UiEffects` — [ObjectTableWiring.cs:28](../../../src/AcDream.Core.Net/ObjectTableWiring.cs)
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](../../../src/AcDream.Core.Net/GameEventWiring.cs)),
after the existing attribute/skill/membership extraction, apply the parsed player `PropertyBundle`
to the player's `ClientObject`. Requires:
- A new `Func<uint> playerGuid` threaded 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 own `CreateObject`; the existing `UpdateProperties`
no-ops on an unknown object, [ClientObjectTable.cs:152](../../../src/AcDream.Core/Items/ClientObjectTable.cs)).
The later `CreateObject` `Ingest` is 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 (same `ByteSequence` as 0x02CE; not honored, latest-wins, DR-4).
- `WorldSession` dispatches it (new `else if (op == PrivateUpdatePropertyInt.Opcode)` in the
GameMessage switch, [WorldSession.cs:954](../../../src/AcDream.Core.Net/WorldSession.cs)) →
new event `PlayerIntPropertyUpdated(uint Property, int Value)` (no guid — implicitly the player).
- `ObjectTableWiring.Wire` gains the `Func<uint> playerGuid` and subscribes
`PlayerIntPropertyUpdated``table.UpdateIntProperty(playerGuid(), property, value)`.
- Anchor: named-retail `ACCWeenieObject` int-property apply (the client-side `0x02CD` consumer);
ACE writer `GameMessagePrivateUpdatePropertyInt`.
**C1c — Apply gate.** Loosen [ObjectTableWiring.cs:26-30](../../../src/AcDream.Core.Net/ObjectTableWiring.cs)
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](../../../src/AcDream.App/UI/Layout/InventoryController.cs)) 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 = 0x0196` entry. Body: `u32 containerGuid, u32 count,
[u32 guid, u32 containerType]×count` (a leading guid then a PackableList<ContentProfile>).
Oracle: ACE `GameEventViewContents.cs` + named-retail `ClientUISystem::OnViewContents`
(`?OnViewContents@ClientUISystem@@…PackableList<ContentProfile>`) + holtburger `events.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 second `UiItemList`.
**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 0x0024` are **GameMessage
> opcodes** (top-level `WorldSession` switch, like `0x02CE`/`0xF745`). `ViewContents 0x0196` /
> `InventoryPutObjInContainer 0x0022` are **GameEvent eventTypes** (inside the `0xF7B0` envelope,
> dispatched by `GameEvents.Dispatch`). Adjacent numbers, different dispatchers — do not cross them.
### C5 — Registration
- **GameEventWiring.WireAll** ([GameEventWiring.cs](../../../src/AcDream.Core.Net/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 `_playerServerGuid` into `ObjectTableWiring.Wire` and the GameEvent
wiring; inject the new `WorldSession.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 (covers
`UpsertProperties` create-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.
- `SetStackSize` parse — op + byte-seq + guid + stackSize + value (size 17).
- `InventoryRemoveObject` parse — op + guid.
- **Burden end-to-end** (App.Tests) — with the wire `EncumbranceVal` present, `RefreshBurden` uses
it (not `SumCarriedBurden`); 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 `SumCarriedBurden` fallback) — burden now reads the wire value.
Keep `SumCarriedBurden` in 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).
- `CreateObject` field extraction — **already done** (`ObjectTableWiring.ToWeenieData` captures
IconId/StackSize/Value/capacities/Burden); the deep-dive's "discards" note is stale. Verify no
field is still `_`-discarded in `CreateObject.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), via `UpsertProperties` (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 `EncumbranceVal` and refreshes live on a player-int update (C1d).
- [ ] `0x0022` reads containerType; `0x00A0` reads weenieError.
- [ ] Builders `0x001B` / `0x001A` / `0x0195` added with `WorldSession.Send*` wrappers + byte tests.
- [ ] Parsers `ViewContents 0x0196` / `SetStackSize 0x0197` / `InventoryRemoveObject 0x0024` added,
dispatched in the correct dispatcher, applied-or-logged.
- [ ] `WireAll` registers ViewContents / InventoryPutObjectIn3D / CloseGroundContainer /
InventoryServerSaveFailed.
- [ ] Every AC-specific format cites its named-retail symbol/address + ACE `file:line` in a comment.
- [ ] AP-48 + AP-49 retired in the register; any new approximation has a row.
- [ ] `dotnet build` + `dotnet test` green; existing tests unbroken.

View file

@ -0,0 +1,151 @@
# D.2b inventory container-switching — design
**Date:** 2026-06-22
**Branch:** `claude/hopeful-maxwell-214a12`
**Phase:** D.2b retail-UI inventory arc — container-switching (the first inventory feature with a live two-way wire round-trip).
**Brainstorm:** this doc. Handoff that scoped it: `docs/research/2026-06-22-container-switching-handoff.md`. SSOT: `claude-memory/project_d2b_retail_ui.md`.
---
## Goal
Click a side bag in the inventory's pack-selector column → the contents grid shows **that bag's** contents (opened server-side via a `Use → ViewContents` round-trip), with the open bag marked by the retail **open-container triangle** and the selected cell marked by the retail **selected-item square**. Click the main-pack cell → back to the main pack.
This is the first inventory feature that **talks to the server and consumes the reply** (a `Use 0x0036``ViewContents 0x0196` round-trip), a step up from the read-only display work so far.
## Scope
**In:**
1. `_openContainer` state in `InventoryController`; the contents grid shows `GetContents(_openContainer)`.
2. Click a side-bag cell → open it: `SendUse(bagGuid)`; on switching away from a previously-open side bag, `SendNoLongerViewingContents(prevGuid)`.
3. `ViewContents 0x0196` consumed as a **full REPLACE** of the container's membership (consumes the `GameEventWiring.cs:256` TODO), not the current additive merge.
4. Click the main-pack cell (`0x100001C9`) → `_openContainer = player`; the grid shows the main pack again.
5. **Open-container triangle** `0x06005D9C` on the cell whose `ItemId == _openContainer`.
6. **Selected-item square** `0x06004D21` on the cell whose `ItemId == _selectedItem` — panel-wide (one selection across grid + column), applied **uniformly** to grid items AND bag cells. **Visual-only** this phase.
7. Caption `0x100001C5` follows the open container ("Contents of Backpack" → "Contents of <BagName>").
**Out (deferred, with reasons):**
- **Wiring the green square to the selected-object bar** (toolbar name/info strip) or to global 3D-world selection — the square is *visual-only* this phase. The selection→selected-object-bar integration is its own follow-up (it's a selection-system concern, not container-switching).
- **Preferred-pack auto-fit** (opening a pack makes it the auto-add target). Noted in `reference_retail_inventory_paperdoll`; out of the display-only first cut.
- **Drag INTO a side bag** (that's B-Drag) and **double-click-to-use** an inventory item (interaction work).
- The side-bag column scrollbar `0x100001CB` (inert — 7 fit).
## Retail anchors (decomp + wire, all verified this session)
| Concern | Retail anchor | Fact |
|---|---|---|
| Open a container | `Use 0x0036` (`InteractRequests.BuildUse`) | Use-on-container opens it server-side; ACE replies `ViewContents`. |
| Contents reply | `GameEventViewContents` (ACE) | `containerGuid, count, [guid, containerType]×count`. Entries written `OrderBy(PlacementPosition)`**no explicit slot field; list order encodes the slot.** Our `ParseViewContents` (8 bytes/entry) is correct. |
| Close a view | `NoLongerViewingContents 0x0195` (`WorldSession.SendNoLongerViewingContents`) | Sent when switching away from an open side bag. |
| Open-container indicator | `UIElement_ItemList::UpdateOpenContainerIndicator` `0x004e3070``SetOpenContainerState` `0x004e1200` | shows element `0x10000450` (sprite `0x06005D9C`) on the cell where `item.itemID == openContainerId`. |
| Selected-item indicator | `UIElement_ItemList::ItemList_SetSelectedItem` `0x004e2fe0``SetSelectedState` `0x004e1240` | shows element `0x10000342` (sprite `0x06004D21`, pixel-confirmed green/yellow frame) on the cell where `item.itemID == selectedItemId`. Uniform across item + container cells. |
**Indicator reconciliation (user-confirmed retail model — axiom):** the two indicators are orthogonal and can co-occur on one cell. Triangle = "this is the *open* backpack (its contents fill the grid)". Square = "this item is *selected*" — a backpack is just an item, so a clicked bag gets both. Side-bag cells use the 36×36 container prototype `0x1000033F` (triangle child only, no square child) — so the square is drawn as a procedural overlay (see Components / divergence).
## Components
### 1. `ClientObjectTable.ReplaceContents` (Core — new)
```
public void ReplaceContents(uint containerId, IReadOnlyList<uint> guids)
```
Full-replace the container's membership so `GetContents(containerId)` returns exactly `guids` in order:
- **Detach** each current member NOT in the new set: `ContainerId = 0`, `Reindex`, fire `ObjectMoved(o, containerId, 0)`. (It left the container server-side.)
- **Record** each new guid in order: create-if-absent, set `ContainerId = containerId` and `ContainerSlot = index` (reconstructs ACE's `PlacementPosition`), `Reindex`, fire `ObjectAdded` (new) or `ObjectMoved` (existing). containerType is not needed by the grid (dropped).
Mirrors the existing `RecordMembership`/`Reindex` machinery; the only new behavior is the authoritative-snapshot semantics (flush-then-record).
### 2. `GameEventWiring` ViewContents handler (Core.Net)
Replace the additive `foreach … RecordMembership` (`:260``:266`) with one `items.ReplaceContents(p.Value.ContainerGuid, [entry.Guid …])`. Delete the `:256` TODO. This is the authoritative full snapshot per ACE.
### 3. `WorldSession.SendUse` (Core.Net — new)
```
public void SendUse(uint targetGuid) // NextGameActionSequence + BuildUse + SendGameAction
```
A thin wire wrapper (mirrors `SendAddShortcut` etc.). Distinct from `GameWindow.SendUse` (the world-interaction path with autowalk/close-range hold) — opening a pack in your own inventory needs no movement, just the wire.
### 4. `UiItemSlot` overlays (App)
Two new procedural overlays in `OnDraw`, modeled exactly on the existing `DragAccept` overlay (resolve via `SpriteResolve`, **guard `id != 0` before resolving** per `feedback_ui_resolve_zero_magenta`):
- `bool IsOpenContainer` + `uint OpenContainerSprite = 0x06005D9C` → triangle.
- `bool Selected` + `uint SelectedSprite = 0x06004D21` → square.
Draw order in `OnDraw`: icon/empty → digits → open-container triangle → selected square → drag-accept (transient, stays on top). `UiItemSlot` is already a behavioral leaf that paints DragAccept/digits procedurally, so drawing these procedurally (rather than via prototype state elements) is consistent — and it lets the square render on a 36×36 bag cell whose container prototype lacks the `m_elem_Icon_Selected` child.
### 5. `InventoryController` (App)
- **State:** `uint _openContainer` (default `= _playerGuid()`), `uint _selectedItem` (default 0).
- **`Populate` split:**
- Side-bag column (`_containerList`) + main-pack cell (`_topContainer`): always the **player's** bags (constant across switches; only indicators move). Unchanged logic + padding (AP-52).
- Contents grid (`_contentsGrid`): the **open container's** loose items — `GetContents(_openContainer)`, `if (isBag) continue` (bags live in the column; a side bag has no sub-bags so this is a no-op when a bag is open), equipped excluded. Pad to the open container's `ItemsCapacity` (player 102 / side bag 24).
- Caption `0x100001C5`: `"Contents of " + (_openContainer == player ? "Backpack" : Get(_openContainer)?.Name ?? "Backpack")`.
- Ends with `ApplyIndicators()`.
- **Click wiring** (set per-cell in `Populate`, the loop knows the cell's role):
- Grid item cell → `SelectItem(guid)`: `_selectedItem = guid`; `ApplyIndicators()` (square moves; no wire, no repopulate).
- Container cell (side bag or main-pack) → `OpenContainer(guid)`:
- `_selectedItem = guid` (the bag is also selected).
- If `guid != _openContainer`: if the *previous* `_openContainer` was a side bag (`!= player && != 0`), `sendNoLongerViewing(prev)`; set `_openContainer = guid`; if `guid != player`, `sendUse(guid)`; `Populate()` (repaint grid for the new container + indicators). Else (same container) `ApplyIndicators()`.
- Empty cell (`ItemId == 0`) → no-op.
- **`ApplyIndicators()`:** for every cell in the three lists, `cell.Selected = ItemId != 0 && ItemId == _selectedItem`; `cell.IsOpenContainer = ItemId != 0 && ItemId == _openContainer`. (Light: toggles bools on existing cells; no Flush.)
- **`Concerns`** += `|| o.ContainerId == _openContainer` — so the `ViewContents`-driven membership changes (which fire `ObjectAdded`/`Moved` for the open container's items) trigger a `Populate()` that fills the grid when the reply lands.
### 6. `GameWindow` wiring (App)
`InventoryController.Bind` gains two optional callbacks (default null, so existing tests/callers compile):
- `sendUse: g => _liveSession?.SendUse(g)`
- `sendNoLongerViewing: g => _liveSession?.SendNoLongerViewingContents(g)`
Wired at the existing bind site (`GameWindow.cs:2231`), mirroring the toolbar's `sendAddShortcut`/`sendRemoveShortcut`.
## Data flow
```
click side-bag cell
→ _selectedItem = bag (square), _openContainer = bag (triangle)
→ switching from another side bag? SendNoLongerViewingContents(prev) [0x0195]
→ SendUse(bag) [0x0036]
→ Populate(): grid = GetContents(bag) (EMPTY initially) + indicators painted
───────────── server ─────────────
→ ViewContents(bag, [items]) [0x0196]
→ ReplaceContents(bag, guids) → ObjectAdded/Moved
→ Concerns(o.ContainerId == _openContainer) → Populate() → grid fills
```
Robust to lazy-load either way: if the bag's contents were already known, the grid fills on the first `Populate()`; if they arrive only on open (the expected retail behavior), the `Concerns` extension fills it when `ViewContents` lands.
## Divergence register
- **Retire AP-56** (both indicators now drawn) and the container-switch clause of **AP-53** (side-pack contents now shown; keep/reword the capacity-default part).
- **Add** (same commit):
- The open-container triangle + selected-item square are drawn as **procedural `UiItemSlot` overlays** keyed by controller state (`_openContainer`/`_selectedItem`), reproducing the retail *keying* (`item.itemID == openContainerId / selectedItemId`) but not via the dat prototype's `m_elem_Icon_OpenContainer`/`m_elem_Icon_Selected` state elements + `SetOpenContainerState`/`SetSelectedState` calls. Lets the square render on the 36×36 container cell (whose prototype lacks the square child). Outcome matches retail.
- Inventory item **selection is panel-local and visual-only** — not wired to the selected-object bar (`SelectedObjectController`) or to global 3D-world selection. (Deferred to the selection follow-up.)
## Test plan
- **Core (`ClientObjectTable.Tests`):** `ReplaceContents` — records a fresh list in order; a second replace with a *smaller* list detaches the dropped guids (`GetContents` shrinks, dropped items `ContainerId == 0`); ordering follows list index.
- **Core.Net (`GameEventWiring`/parse tests):** `ViewContents` → full replace (a second smaller `ViewContents` shrinks membership — proves it's not the old additive merge).
- **App (`InventoryControllerTests`):** extend the existing fake-layout harness (`BuildLayout`/`Bind`) — add optional `sendUse`/`sendNoLongerViewing` capture lambdas. Cases:
- default `_openContainer` = player; grid shows player contents (existing tests still pass).
- click a side-bag cell → `sendUse(bag)` fired; grid shows `GetContents(bag)`; caption follows the bag name.
- switch bag A → bag B → `sendNoLongerViewing(A)` then `sendUse(B)`.
- click main-pack cell after a bag → `sendNoLongerViewing(bag)`, **no** `sendUse`; grid back to player.
- indicators: `IsOpenContainer` on the open cell, `Selected` on the selected cell; select-only grid click moves the square, leaves `_openContainer` + grid unchanged, sends no wire.
- **App (`UiItemSlot`):** overlay state — `Selected`/`IsOpenContainer` gate the draw; `id != 0` guard.
Run the **full** suite at the phase boundary (not just filtered batches — the B-Wire process lesson).
## Acceptance criteria
- [ ] Decomp anchors cited in code comments (the `0x004exxxx` functions + sprite ids + the ACE wire ref).
- [ ] Divergence register updated in the same commit (AP-56 retired, AP-53 reworded, new rows added).
- [ ] `dotnet build` + full `dotnet test` green.
- [ ] **Visual gate (the oracle):** F12 → click a side bag → grid swaps to its contents; the open bag shows the triangle; the clicked cell shows the green/yellow square; click another bag → grid + indicators follow; click the main-pack cell → back to the main pack. WireMCP capture of `127.0.0.1:9000` confirms the `Use 0x0036 → ViewContents 0x0196` round-trip (and settles lazy-load-on-open).
- [ ] SSOT (`project_d2b_retail_ui.md`) + roadmap updated.
## Open verification (at the live gate, not a blocker)
Whether a side bag's contents are pre-known or arrive only on open — confirmed by the WireMCP/log capture at the gate. The design works either way; this just documents the observed behavior + retires the handoff's open question.

View file

@ -0,0 +1,145 @@
# D.2b — Inventory empty-slot art: faithful cell-template resolution (design)
**Date:** 2026-06-22
**Status:** DESIGN — approach + scope approved in the 2026-06-22 brainstorm. No code yet.
**Branch:** `claude/hopeful-maxwell-214a12` (tip after the inventory window-finish Stage 1).
**Closes (inventory portion):** the OPEN issue *"Inventory + equipment slots show the wrong empty-slot background art"* (`docs/ISSUES.md`). The paperdoll equip silhouettes stay Sub-phase C.
**Line numbers drift — grep the symbol.**
---
## 1. Problem
At the inventory window's visual gate the empty cells in the **contents grid** (`0x100001C6`) and the **side-bag column** (`0x100001CA`) render the wrong background — the generic toolbar empty square — instead of the retail pack-slot art.
The handoff framed the fix as "swap the toolbar's `0x060074CF` for template `0x21000037`'s empty state." Investigation showed that framing is malformed:
- **`0x060074CF` is not "the toolbar's sprite"** — it is the *generic* `ItemSlot_Empty` shared by many `0x21000037` catalog elements (the toolbar shortcut prototypes derive from base `0x1000045C`/`0x10000445` and use it).
- **`0x21000037` is a catalog of ~50 per-slot-kind empty backgrounds**, not "a template with an empty state": the generic square, ~25 equip-slot silhouettes (`0x06006Dxx`/`0x06000Fxx`/…), a numbered group, and standalone `UIElement_UIItem` (class `0x10000032`) cell **prototypes**.
- **The dat doesn't say which prototype a list uses.** The itemlist base `0x2100003D` (element `0x10000339`) and the contents grid `0x100001C6` are bare 32×32 containers; the cell art is bound at *runtime* by `UIElement_ItemList`.
- **acdream synthesizes bare cells** (`new UiItemSlot` with the hardcoded `EmptySprite = 0x060074CF`), bypassing retail's per-list cell-template inheritance entirely. *That* is the mechanism gap.
## 2. The retail mechanism (verified against the named decomp)
Every cell — empty or filled — is created by `UIElement_ItemList::InternalCreateItem` (decomp `004e3570`, named-retail line 231486; verified, not agent-reported). The cache-miss path:
```c
eax_1 = UIElement::GetAttribute_Enum(this, 0x1000000e, &this_1); // 231517: cell-template ELEMENT ID, read off THIS list's ElementDesc
... var_18=0x10000038; var_14=5; var_10_3=0x23 ... // 231518-231520
eax_2 = DBObj::GetByEnum(0x10000038, 5, 0x23); // the shared UIItem-catalog LayoutDesc (= 0x21000037, strong inference)
eax_3 = UIElementManager::CreateChildElement(s_pInstance, this, eax_2 /*catalog*/, this_1 /*element id*/); // 231526: clone that prototype
return eax_3->DynamicCast(0x10000032); // 231534: a UIElement_UIItem cell
```
`ItemList_AddEmptySlot` (`004e36b0`) → `InternalCreateItem``UIItem_SetState(0x1000001c)` (= the `ItemSlot_Empty` state). The cloned prototype's baked `ItemSlot_Empty` media **is** the empty-slot background. `gm3DItemsUI::PostInit` (`004a7190`) and `gmBackpackUI::PostInit` (`004a6f70`) only bind the lists (`GetChildRecursive` + `DynamicCast(0x10000031)`); they set **no** cell art.
**Conclusion:** the empty-slot sprite is *data-driven per list* via attribute **`0x1000000e`** on the list's own ElementDesc → an element in the catalog `0x21000037` → that prototype's `ItemSlot_Empty` (`0x1000001c`) media. acdream's hardcoded `0x060074CF` is wrong for the pack/backpack lists.
## 3. Goal / non-goals
**Goal:** the contents grid + side-bag + main-pack empty cells render the correct per-list empty-slot art, **resolved from the dat exactly as retail does** (attribute `0x1000000e` → catalog `0x21000037` prototype's `ItemSlot_Empty`), retiring the hardcoded default for those lists.
**Non-goals (explicitly out):**
- **Paperdoll equip silhouettes** — Sub-phase C (needs the `0x10000032` `UiItemSlot` registration + the `UiViewport`). This design *lays the groundwork* (the same resolver serves them) but does not wire them.
- **Main-pack backpack *icon*** — AP-51 (the *filled* state of `0x100001C9`), separate.
- **Container switching** — deferred (AP-53).
- **The toolbar** — its hardcoded `0x060074CF` is the *correct* outcome (its `0x1000000e` resolves to a generic prototype); left untouched to avoid regression. The new resolver is reusable for it later.
## 4. Design
### 4.1 Components (smallest faithful change)
| Unit | Change | Why |
|---|---|---|
| `src/AcDream.App/UI/Layout/ItemListCellTemplate.cs` (**new**, static helper) | `ResolveEmptySprite(DatCollection dats, uint listLayoutId, uint listElementId) → uint` | The pure dat→sprite resolver. Mirrors the **existing** `0x21000037` read in `GameWindow` (the digit-array block, `GameWindow.cs:1928-1994`) but extracted into a testable helper instead of another inline block (CLAUDE.md rule #1: no new feature bodies in `GameWindow`). |
| `src/AcDream.App/UI/UiItemList.cs` | add `uint CellEmptySprite` property | The list's per-cell empty sprite; applied to every cell. |
| `src/AcDream.App/UI/Layout/InventoryController.cs` | `Bind`/ctor gain the three resolved sprites; set `CellEmptySprite` on `_contentsGrid` / `_containerList` / `_topContainer` | Where the lists are owned. |
| `src/AcDream.App/Rendering/GameWindow.cs` | call the helper for the 3 lists, pass results into `InventoryController.Bind` | Thin wiring only (≤ a dozen lines), mirroring the `ToolbarController.Bind(..., peaceDigits, …)` seam at `GameWindow.cs:2015`. |
`UiItemSlot.EmptySprite` and its `0x060074CF` default are **unchanged** (still correct for the toolbar). The inventory cells get their value from `UiItemList.CellEmptySprite`.
### 4.2 `UiItemList.CellEmptySprite`
```csharp
private uint _cellEmptySprite;
/// <summary>Empty-slot sprite for THIS list's cells, resolved from the dat cell template
/// (retail attribute 0x1000000e → catalog 0x21000037 prototype's ItemSlot_Empty). 0 = leave the
/// UiItemSlot default (0x060074CF). Applied to every existing + future cell.</summary>
public uint CellEmptySprite
{
get => _cellEmptySprite;
set { _cellEmptySprite = value; if (value != 0) foreach (var c in _cells) c.EmptySprite = value; }
}
```
`AddItem` applies it to each new cell: `if (CellEmptySprite != 0) cell.EmptySprite = CellEmptySprite;` (placed with the existing `cell.SpriteResolve ??=` line). Order-independent: the ctor's default cell is re-stamped by the setter when the controller assigns `CellEmptySprite`.
### 4.3 The resolver (explicit, single-sprite contract)
`ResolveEmptySprite(dats, listLayoutId, listElementId)`:
1. `listLd = dats.Get<LayoutDesc>(listLayoutId)`; find `listElem` (id `listElementId`) in `listLd.Elements` (recursively into `Children`). Not found → return `0`.
2. **Read the cell-template id:** `cellProtoId = listElem.StateDesc.Properties[0x1000000e]` as a single id value (`DataIdBaseProperty`/`IntBaseProperty` — exact type confirmed in §6 step 1). Absent on the element → walk the `BaseElement`/`BaseLayoutId` chain once (the itemlist base `0x10000339` is bare, so this just confirms absence). Still absent → return `0` (caller keeps the `UiItemSlot` default; logged).
3. `catalogLd = dats.Get<LayoutDesc>(0x21000037)`; `proto = catalogLd.Elements[cellProtoId]`. Not found → return `0`.
4. **Pick the prototype's single empty-background sprite, in this priority** (explicit — handles both observed prototype shapes):
a. **Frame child** — if `proto` has a child whose `StateDesc` (DirectState) carries a background image (the recessed pack-slot frame; e.g. the 36×36 prototype `0x1000033F`'s child `0x10000450``0x06005D9C`), return that `File`.
b. **m_elem_Icon empty** — else find the icon sub-element (id `0x1000033B`, recursively through any inner wrapper such as `0x10000340`) and return its `ItemSlot_Empty` (state `0x1000001c`) `MediaDescImage.File` (e.g. the 32×32 prototype `0x1000033A``0x06004D20`).
c. else → return `0`.
The catalog id `0x21000037` is hardcoded — a **strong inference** (it's the only LayoutDesc that is a catalog of standalone `0x10000032` prototypes; `GetByEnum(0x10000038,5,0x23)` resolves through a master enum-map *dat* object with no code literal). The `0x10000038` enum domain is not wired in acdream. §6 step 1 validates the hardcode (the resolved id must name a real `0x21000037` prototype with a sane sprite).
### 4.4 Data flow
```
GameWindow (composition root, ≤12 lines, under _datLock):
contentsEmpty = ItemListCellTemplate.ResolveEmptySprite(dats, 0x21000021, 0x100001C6) // gm3DItemsUI contents grid
sideBagEmpty = ItemListCellTemplate.ResolveEmptySprite(dats, 0x21000022, 0x100001CA) // gmBackpackUI side-bag column
mainPackEmpty = ItemListCellTemplate.ResolveEmptySprite(dats, 0x21000022, 0x100001C9) // gmBackpackUI main-pack cell
↓ (named params, mirroring ToolbarController.Bind(..., peaceDigits, …))
InventoryController.Bind(..., contentsEmpty, sideBagEmpty, mainPackEmpty):
_contentsGrid.CellEmptySprite = contentsEmpty
_containerList.CellEmptySprite = sideBagEmpty
_topContainer.CellEmptySprite = mainPackEmpty
↓ (UiItemList.AddItem / setter)
each cell.EmptySprite = (per-list resolved sprite) → UiItemSlot.OnDraw draws it when ItemId == 0
```
Each list reads its **own** `0x1000000e` (the contents grid and the backpack lists live in different layouts), so the resolution is per-list-faithful, not a size guess.
### 4.5 Why GameWindow-resolves rather than the importer
The fully-automatic alternative — resolve inside `LayoutImporter`/`DatWidgetFactory` so *every* `UiItemList` gets `CellEmptySprite` for free — would touch the `ElementInfo` POCO (which carries an explicit "don't add members without updating consumers" warning), `ElementReader`, and the factory, and would route the **toolbar** through the new path (regression risk on frozen, working art). The chosen design keeps the blast radius to the inventory, puts the logic in one testable helper, and reuses the proven `GameWindow`-reads-`0x21000037` + passes-to-`Bind` pattern. The importer route remains available for Sub-phase C if we later want all lists automatic.
## 5. Divergence register impact
- **No new deviation is introduced** — this *ports* the retail resolver for the inventory lists (a deviation retired, not added).
- The **hardcoded empty-slot art was never registered** (a pre-existing gap — a deviation without a row). This change makes it honest by adding **one narrow row**: *"The toolbar's item slots use the hardcoded `UiItemSlot.EmptySprite = 0x060074CF` rather than resolving their cell template via `0x1000000e`; correct in outcome (the toolbar's `0x1000000e` resolves to a generic prototype) but not dat-resolved. Retire when the toolbar is routed through `ItemListCellTemplate`."* (AP-55 or next free id, added in the implementation commit.)
- A second row for the **flat-cell approximation**: *"acdream's `UiItemSlot` is a flat single-sprite cell; for a prototype with both a frame child and an inner icon (the 36×36 backpack `0x1000033F`: frame `0x06005D9C` + inner `0x06000F6E`), only the dominant background (the frame) is drawn — retail clones the full prototype subtree. Revisit at the visual gate / a layered cell if the backpack slots read wrong."* (next free id.)
## 6. Implementation outline + step-1 validation (this is how we "pin the exact asset")
1. **Pin the values (first task).** Extend the layout dumper (`AcDream.Cli dump-vitals-layout`, the reflective ElementDesc walker) to surface each element's scalar **attribute/property table** (it currently prints geometry + states + media, omitting `Properties` keys like `0x1000000e`). Dump `0x1000000e` for `0x100001C6` (layout `0x21000021`), `0x100001C9` and `0x100001CA` (layout `0x21000022`). Record: the property type, the resolved prototype id, and that prototype's chosen sprite per §4.3. This **confirms the catalog is `0x21000037`** and **locks the golden test values**. (If `0x1000000e` is absent on a list element, escalate to the cdb fallback: break at `InternalCreateItem` `004e3616`, read arg4 + the catalog DID — per the research report's §"How to verify next".)
2. `ItemListCellTemplate.ResolveEmptySprite` + unit/real-dat tests.
3. `UiItemList.CellEmptySprite` + unit test.
4. `InventoryController.Bind` params + apply; update affected InventoryController tests.
5. `GameWindow` wiring.
6. Divergence rows; ISSUES update; visual gate.
## 7. Testing
- **Resolver, real-dat smoke** (`tests/AcDream.App.Tests`, dat-gated like the existing Layout real-dat smokes): `ResolveEmptySprite(dats, 0x21000021, 0x100001C6)` returns **non-zero**, **`0x060074CF`**, and **== the `ItemSlot_Empty` of the prototype named by that element's `0x1000000e`** (structural assertion — no magic literal until step 1 locks it). Same for `0x100001C9`/`0x100001CA` (layout `0x21000022`).
- **`UiItemList.CellEmptySprite`** unit test: assigning it stamps existing **and** subsequently-added cells; `0` leaves the `UiItemSlot` default.
- **`InventoryController`**: after `Bind` with the three sprites, the contents-grid / side-bag / main-pack cells carry the expected `EmptySprite`; the padded empty cells too.
- **Unchanged:** `UiItemSlotTests.DefaultEmptySprite_isToolbarBorder` stays green (the default is still `0x060074CF`).
- `dotnet build` + full suite green (run the **full** suite at the phase boundary — a filtered batch hid a cross-file regression in B-Wire).
## 8. Acceptance criteria
- Build + full test suite green.
- The resolver's golden values are pinned from the dat (step 1) and locked by a real-dat test.
- Divergence rows added in the same commit; ISSUES entry moved to *Recently closed* (inventory portion) with the commit SHA.
- **Visual gate (user, F12, `ACDREAM_RETAIL_UI=1`):** the contents-grid and side-bag empty cells show the retail pack-slot background, not the generic toolbar square. The user is the arbiter on whether the 36×36 backpack frame approximation reads correctly; if not, escalate to a layered cell (noted, not in scope now).
## 9. Open questions (resolved during step 1, not blocking the design)
- Exact `0x1000000e` property **type** + **value** per list (→ the exact prototypes/sprites). *Resolved by the step-1 dump.*
- Whether the 36×36 backpack cells need both the frame **and** the inner layer. *Resolved at the visual gate; approximation + escalation path recorded as a divergence row.*

View file

@ -0,0 +1,121 @@
# D.2b inventory drag-drop (item moving) — design
**Date:** 2026-06-22
**Branch:** `claude/hopeful-maxwell-214a12`
**Phase:** D.2b retail-UI inventory arc — B-Drag (inventory drag SOURCE + drop placement).
**Brainstorm:** this doc. Builds on the shipped drag-drop spine (B.1) + container-switching.
---
## Goal
Drag an inventory item and drop it to move it, **instantly**:
1. **on an empty grid slot** → the item goes to the **first empty slot** of the open pack (pack-to-front),
2. **on an occupied item****insert before** it (the rest shift down),
3. **on a side-bag cell** → the item goes **into that container**,
with a **green insert-arrow** overlay while a valid drop is hovered and a **red circle** when the target container is **full**.
The move **feels instant**: the grid updates locally the moment you drop, with the server reconciling in the background (and snapping the item back only if it bounces the move).
## Scope
**In:**
- Inventory cells as drag **sources** (already: `UiItemSlot.IsDragSource` when occupied, `SourceKind = Inventory`).
- `InventoryController` as the drop **handler** (`IItemListDragHandler`) on the contents grid + the side-bag list.
- The three drop placements (goal 13) → `PutItemInContainer 0x0019`.
- **Optimistic local move** on drop (instant repaint) + **rollback** on `InventoryServerSaveFailed`.
- **Accept/reject overlays**: green insert-arrow (valid) / red circle (target bag full).
**Out (separate work, with reasons):**
- **Equipping via the paperdoll** (`GetAndWieldItem 0x001A`) — Sub-phase C / its own drop path.
- **Drop-to-ground** (`DropItem 0x001B`) — separate interaction.
- **Dragging the side-bag cells themselves** (reordering bags) — bags are drop *targets* here, not sources.
- **Stack split/merge** on drop — deferred (the Selected-Target-Bar split is its own feature).
## Retail anchors (the spec is a faithful port)
| Concern | Retail | Note |
|---|---|---|
| Drop placement + accept/reject flags | `UIElement_ItemList::InqDropIconInfo 0x004e26f0` | reads the cell's drop-info properties → placement + `DropItemFlags` |
| Insert-before | `UIElement_ItemList::ItemList_InsertItem` | the dragged item takes the target slot; rest shift |
| Commit the drop | `UIElement_ItemList::HandleDropRelease` | issues the move |
| Local move + server reconcile | `ItemList_InsertItem` (local) + `RecvNotice_ServerSaysMoveItem`/`ServerSaysMoveItem` | retail moves locally then reconciles — this is the "instant + rollback" model |
| Wire | `PutItemInContainer 0x0019` (`item, container, placement`) → ACE `Player.HandleActionPutItemInContainer` | placement = the target slot index (server packs/shifts) |
## Components
### 1. `WorldSession.SendPutItemInContainer` (Core.Net — new wrapper)
Thin wrapper over the existing `InteractRequests.BuildPickUp(seq, itemGuid, containerGuid, placement)` (opcode `0x0019`), mirroring `SendUse`/`SendNoLongerViewingContents`:
```
public void SendPutItemInContainer(uint itemGuid, uint containerGuid, int placement)
```
### 2. `InventoryController : IItemListDragHandler` (App)
Registered on the contents grid + the side-bag list in `Populate` (`list.RegisterDragHandler(this)`), like `ToolbarController`. Cells already default `SourceKind = Inventory`; set each cell's `SlotIndex` so the drop target knows its position.
- **`OnDragLift` → no-op.** The item **stays in its slot** during the drag (unlike the toolbar's remove-on-lift); it moves only on drop. (Retail dims the source; we leave it in place + the floating ghost — a minor approximation.)
- **`OnDragOver(targetList, targetCell, payload)` → accept/reject overlay (advisory):**
- target is a **grid slot** (the open pack) → **accept** (reorder/insert is always valid in the open container).
- target is a **side-bag cell****accept** unless that bag is **known-full** (`GetContents(bag).Count >= ItemsCapacity` when we know the count) → **reject**. A *closed* bag whose count we don't know → **accept** (advisory; the server is authoritative).
- Sets `targetCell.DragAcceptSprite` = the green insert-arrow / `DragRejectSprite` = the red circle (ids §Overlays).
- **`HandleDropRelease(targetList, targetCell, payload)` → optimistic move + wire:**
1. Resolve `(targetContainer, placement)`:
- grid empty slot → `targetContainer = EffectiveOpen()`, `placement = append` (first empty = end of the packed list).
- grid occupied slot N → `targetContainer = EffectiveOpen()`, `placement = targetCell.SlotIndex` (insert-before).
- side-bag cell → `targetContainer = targetCell.ItemId` (the bag guid), `placement = append`.
2. If the target is **known-full** (the reject case) → no-op (the red overlay already showed; nothing moves).
3. Else: record the item's **pre-move** `(ContainerId, ContainerSlot)` in a pending-move map; **`_objects.MoveItem(item, targetContainer, placement)` locally** (instant repaint via `ObjectMoved``Concerns``Populate`); **`SendPutItemInContainer(item, targetContainer, placement)`**.
### 3. Optimistic reconcile + rollback — `ClientObjectTable` (Core)
The pending-move tracking lives in **`ClientObjectTable`** (Core), so BOTH the App drop handler and the Core.Net wire handlers reach it (Core.Net can't depend on App):
- `MoveItemOptimistic(itemId, newContainer, newSlot)` — snapshot the item's current `(ContainerId, ContainerSlot)` into a small pending map, then `MoveItem` (fires `ObjectMoved` → instant repaint). Called by `HandleDropRelease`.
- `ConfirmMove(itemId)` — clear the pending entry (the move stuck).
- `RollbackMove(itemId)``MoveItem` back to the snapshotted `(container, slot)`, then clear. Returns false if no pending entry.
Wiring (`GameEventWiring`):
- **Reconcile (success):** the `InventoryPutObjInContainer 0x0022` echo already routes to `MoveItem` (no-op/correction for a move we initiated) — add `items.ConfirmMove(itemGuid)` after it.
- **Rollback (failure):** `InventoryServerSaveFailed 0x00A0` (today it only logs) → `items.RollbackMove(itemGuid)` (snap back). B-Drag is what the B-Wire note reserved this handler for.
### 4. Overlays (sprites)
Green insert-arrow + red circle, **dat-exported + visual-gate-confirmed** before pinning (per the empty-slot-art / backpack-icon lesson). Candidates from the shipped spine: green move-arrow `0x060011F7`, red ∅ `0x060011F8`, green ring `0x060011F9` (inventory accept). `UiItemSlot` already draws `DragAcceptSprite`/`DragRejectSprite` overlays on DragEnter/Reject — the controller sets the inventory ids.
## Data flow
```
drop item on a target cell
→ OnDragOver already showed green (valid) / red (full)
→ HandleDropRelease: resolve (container, placement)
known-full? → no-op (red, nothing moves)
else → record pre-move pos
→ MoveItem(item, container, placement) LOCALLY → instant repaint
→ SendPutItemInContainer(item, container, placement)
─────────── server ───────────
→ InventoryPutObjInContainer 0x0022 (confirm) → MoveItem (no-op/correct) → clear pending
OR
→ InventoryServerSaveFailed 0x00A0 (reject) → MoveItem back to pre-move → clear pending
```
## Divergence register
- **`OnDragLift` no-op / source not dimmed** — retail dims the lifted item's source cell; we leave it in place + the floating ghost (minor visual).
- **Closed-bag drop is advisory-accept** — the client can't know a closed bag's fullness (contents aren't indexed until opened), so it shows green + relies on the server's reject + rollback. Retail knows the count if loaded.
- Accept/reject overlay ids set by the controller (procedural), pinned by dat-export (cf. AP-55/57).
- Retire/relax the B-Wire note on `InventoryServerSaveFailed` ("B-Drag wires the rollback") — now wired.
## Test plan
- **Core.Net:** `SendPutItemInContainer` wire (opcode `0x0019`, item/container/placement) — extend `InteractRequestsTests` (the builder is already covered; assert the wrapper path if a seam exists, else rely on the builder test).
- **App (`InventoryControllerTests`):** the handler — extend the fake-layout harness with a `SendPutItemInContainer` capture.
- drop on empty grid slot → `MoveItem(item, openContainer, append)` locally + wire fired with the open container.
- drop on occupied slot N → wire `placement == N` (insert-before); local grid shows the item at N.
- drop on a side-bag cell → wire `container == bagGuid`.
- `OnDragOver`: grid → accept; full side bag → reject; closed bag → accept.
- rollback: simulate `InventoryServerSaveFailed` → item returns to its pre-move container/slot.
- **Core:** a pending-move rollback unit (record pre-move, restore on failure) if the mover lives in Core.
## Acceptance
- [ ] Decomp anchors cited; divergence rows added same-commit.
- [ ] `dotnet build` + full `dotnet test` green.
- [ ] **Visual gate (instant):** drag an item onto an empty slot → it lands (first empty) the instant you release; onto an item → inserts before it; onto a side bag → goes in; a full bag shows the red circle and bounces; a valid target shows the green insert-arrow. WireMCP confirms `PutItemInContainer 0x0019` + the `0x0022` echo (and a `0x00A0` bounce on a full bag).
- [ ] SSOT + roadmap updated.
## Open verification (at the gate)
- Pin the green-arrow / red-circle sprite ids by dat-export + the visual gate.
- Confirm ACE's `placement` interpretation (insert-before-N vs append) against a live capture; adjust the placement mapping if ACE packs differently.

View file

@ -0,0 +1,341 @@
# 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.