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>
This commit is contained in:
Erik 2026-06-20 12:23:44 +02:00
parent 31d7ffd253
commit 2de9cc1c19

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