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>
20 KiB
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):
- Payload injection into
UiRoot.BeginDrag(today passespayload: null). - A cursor-following drag ghost, painted by
UiRoot, item-agnostic. - Drop-target hooks on
UiItemSlot(DragEnter/Over/DropReleased → accept/reject overlay + dispatch). - The
IItemListDragHandlerinterface + registration onUiItemList. - A typed
ItemDragPayloaddescribing what is dragged and where from. - Moving the item-cell's use-trigger from MouseDown → Click (drag/click disambiguation; also more retail-faithful).
- A visible toolbar stub handler so the chain is verifiable this session.
Out of scope (explicitly deferred — later streams):
- The
AddShortcut 0x019C/RemoveShortcut 0x019Dwire and the mutableShortcutStore(Stream B.2). - The inventory window,
UiItemListN-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.1–5.5)
- The cell
UIElement_UIItemis BOTH drag source and drop target. On left-press-and-move it walks to its parentUIElement_ItemListand callsItemList_BeginDrag(ListenToElementMessagedecomp 229344, msg0x21). - Every cell is a drop target by construction —
PostInitsets theCatchDroppedItemattribute0x36true (decomp 229744). - On drag-over the cell forwards to
ItemList_DragOver; the LIST routes to its registeredm_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 atgmToolbarUI0x004bd162 / 0x004bd1af):flags & 0xE == 0⇒ fresh-from- inventory;flags & 4⇒ within-list reorder. Accept/reject overlay state ids: neutral0x1000003f, reject0x10000040, accept0x10000041(SetDragAcceptState, confirmed 0x004bd16d).- The drag ghost
m_dragIcon(id0x10000345) 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)
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 thepayloadparam): pullvar payload = source.GetDragPayload();→ ifnull, set_dragCandidate = falseand return without arming (noDragSource, no event). Else setDragSource/DragPayload, fireDragBeginwith the payload. Call site (line 188) becomesBeginDrag(Captured);.Draw(ctx): afterDrawOverlays, still inside the overlay layer, draw the ghost:
Root transform is at origin duringif (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.6fDraw, 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 fromShortcutNum, the 1–9 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 != 0before 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):
OnEventkeepsMouseDown→return true(still consume the press, but DON'T fire use), and addsClick(0x01) →Clicked?.Invoke(); return true. (UiRootalready 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.)WireClickinToolbarControlleris unchanged — it still assignsClicked. OnEventnew 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()walksParentuntil aUiItemList. The accept/reject overlay draws inOnDrawon top of the icon+digit,id != 0guarded.
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'sSlotIndex = iandSourceKind = ItemDragSource.ShortcutBar, andlist.RegisterDragHandler(this).OnDragOver→payload.ObjId != 0(accept any real item; retail'sIsShortcutEligiblegate 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
- Press on an occupied toolbar slot →
UiRoot.OnMouseDownsetsCaptured = cell,_dragCandidate = true(cell isn'tCapturesPointerDrag, isn't a window-drag). - Move > 3 px →
UiRoot.OnMouseMovecallsBeginDrag(cell)→ pullscell.GetDragPayload()=ItemDragPayload{ObjId, ShortcutBar, srcSlot, cell}(non-null since occupied) → armsDragSource/DragPayload, firesDragBegin. - Each move while dragging →
UpdateDragHover(x,y)hit-tests; on target CHANGE it firesDragOver(LEAVE) on the old cell → its overlay resets to neutral, andDragEnteron the new cell → it asks its list's handlerOnDragOver→ overlay flips accept/reject. MeanwhileUiRoot.Drawpaints the ghost at the cursor. - Release →
UiRoot.OnMouseUp:DragSourceset →FinishDrag(x,y)deliversDropReleasedto the cell under the cursor (Data0 = 1if a different element). The cell → its list'sHandleDropRelease(list, cell, payload)→ (stub) logs.DragSource/DragPayloadcleared → ghost stops. Capture released. - Empty-slot drag attempt → step 2
GetDragPayload()returns null →_dragCandidate=false, nothing armed; the eventual mouse-up emits Click (no-op,ClickedguardsItemId != 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 areUiRoot's constants and differ from retail's raw per-cellListenToElementMessagecodes (0x21 begin / 0x3e over / 0x15 drop). Do NOT try to match the retail per-cell codes; the SEMANTICS match, the numbers areUiRoot's. (IA-12 already covers "UI toolkit mirrors behavior, not byte-layout.") - Hover re-eval cadence.
UiRoot.UpdateDragHoverearly-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-moveMouseOverTop— no register row. - Self-drop / drop-on-nothing.
FinishDragsetsData0=0when the target is the source or null; the cell gatesHandleDropReleaseonData0==1, so both are clean no-ops (= "put it back"). - Magenta footgun.
DragAcceptSprite/DragRejectSpritedefault non-zero; theOnDrawoverlay still guardsif (id != 0)beforeSpriteResolve, never on the returned GL handle (feedback_ui_resolve_zero_magenta). - No heavy diagnostics in the render loop. The stub's
Console.WriteLinefires 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_pIconat reduced alpha (GhostAlpha=0.6) instead of retail's dedicatedm_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::RenderIcons407594-407625 (m_pDragIcon path); deep-dive §3.2/§5.5. - TS-33 (Temporary stopgap): Toolbar
IItemListDragHandler.HandleDropReleaseis a logging stub — noAddShortcut 0x019C/RemoveShortcut 0x019Dwire, noShortcutStoremutation. 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::HandleDropReleaseacclient_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):
GetDragPayloadreturns null for an empty cell, a correctItemDragPayloadfor a bound cell (ObjId/SourceKind/SourceSlot/SourceCell).BeginDragdoes NOT arm (DragSource == null) when the source's payload is null; arms + firesDragBeginwhen non-null.- Drop dispatch: a fake
IItemListDragHandlerregistered on aUiItemListreceivesOnDragOveron DragEnter andHandleDropReleaseon DropReleased with the right(list, cell, payload); NOT called on aData0==0self/empty drop. - Accept/reject overlay state flips to Accept when the handler returns true, Reject when false, None on leave.
- Use-vs-drag: a Click (no intervening drag) fires
Clicked; a completed drag does not (driveUiRootMouseDown→move>3px→MouseUp and assertClickeddidn't fire). FindList()resolves the parentUiItemListthrough the cell'sParentchain.
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 buildgreen;dotnet testgreen (existing + the new §8 tests).UiRoothas zero compile-time dependency onUiItemSlot/ItemDragPayload(item-agnostic; only the twoUiElementvirtuals 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)
- Toolkit core —
ItemDragPayload/ItemDragSource, the twoUiElementvirtuals,UiRoot.BeginDragpayload pull + cancel,UiRoot.Drawghost. - Cell hooks —
UiItemSlot:SlotIndex/SourceKind,GetDragPayload/GetDragGhost, MouseDown→Click move, the DragEnter/Over/DropReleased cases + accept/reject overlay;IItemListDragHandler;UiItemList.DragHandler. - Toolbar stub + wiring —
ToolbarController : IItemListDragHandler, register + set SlotIndex/SourceKind, the loggingHandleDropRelease; register rows. - Tests — the §8 suite.
(Slices 1→2 are sequential; 3 depends on 2; 4 can follow each.)