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

20 KiB
Raw Blame History

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 constructionPostInit 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_DragOverm_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)

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)

/// <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:
    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):
    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 MouseDownreturn 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):
    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)

/// <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).
  • OnDragOverpayload.ObjId != 0 (accept any real item; retail's IsShortcutEligible gate is B.2).
  • HandleDropReleaseConsole.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 pxUiRoot.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 draggingUpdateDragHover(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. ReleaseUiRoot.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 coreItemDragPayload/ItemDragSource, the two UiElement virtuals, UiRoot.BeginDrag payload pull + cancel, UiRoot.Draw ghost.
  2. Cell hooksUiItemSlot: SlotIndex/SourceKind, GetDragPayload/ GetDragGhost, MouseDown→Click move, the DragEnter/Over/DropReleased cases + accept/reject overlay; IItemListDragHandler; UiItemList.DragHandler.
  3. Toolbar stub + wiringToolbarController : 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.)