acdream/docs/superpowers/specs/2026-06-20-d2b-drag-drop-spine-design.md
Erik 2de9cc1c19 docs(D.5.3/B.1): drag-drop spine design spec
Stream B.1 of the 2026-06-18 handoff — the shared widget-level drag-drop
infra that both shortcut-drag (B) and the inventory window (C) sit on.

Four design decisions confirmed with the user this session:
1. Payload = typed ItemDragPayload record snapshotted at drag-begin
   (ObjId/SourceKind/SourceSlot/SourceCell); SourceContainer derived at
   drop from ClientObjectTable (single source of truth).
2. Cursor ghost painted by UiRoot via a generic UiElement.GetDragGhost()
   hook — keeps UiRoot item-agnostic; floats above all windows.
3. Cell (UiItemSlot) is the drop-target hit unit + accept/reject overlay
   owner, delegating the decision + dispatch UP to its parent UiItemList's
   registered IItemListDragHandler (faithful to retail cell->ItemList_DragOver
   ->m_dragHandler; scales to the inventory N-cell grid).
4. PR ships infra + a visible toolbar STUB handler (logs, no wire) so the
   ghost/overlay/dispatch are confirmable this session; AddShortcut/Remove
   wire is Stream B.2.

Retail-grounded: InqDropIconInfo flags (&0xE==0 fresh / &4 reorder, reject
state 0x10000040) confirmed live at gmToolbarUI 0x004bd162; the cell
begin-drag/CatchDroppedItem/RegisterItemListDragHandler chain at decomp
229344/229744/230461. Planned register rows: AP-47 (ghost reuses full icon
at reduced alpha vs retail m_pDragIcon) + TS-33 (toolbar drop stub pending
B.2). No new wire format in the spine itself.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-20 12:23:44 +02:00

367 lines
20 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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.)