From 2de9cc1c1916af83e4be14833013297649ca0e99 Mon Sep 17 00:00:00 2001 From: Erik Date: Sat, 20 Jun 2026 12:23:44 +0200 Subject: [PATCH] docs(D.5.3/B.1): drag-drop spine design spec MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .../2026-06-20-d2b-drag-drop-spine-design.md | 367 ++++++++++++++++++ 1 file changed, 367 insertions(+) create mode 100644 docs/superpowers/specs/2026-06-20-d2b-drag-drop-spine-design.md diff --git a/docs/superpowers/specs/2026-06-20-d2b-drag-drop-spine-design.md b/docs/superpowers/specs/2026-06-20-d2b-drag-drop-spine-design.md new file mode 100644 index 00000000..56cb25d6 --- /dev/null +++ b/docs/superpowers/specs/2026-06-20-d2b-drag-drop-spine-design.md @@ -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.1–5.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; + +/// 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. +public enum ItemDragSource { Inventory, ShortcutBar, Equipment, Ground } + +/// 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). +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 +/// 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. +public virtual object? GetDragPayload() => null; + +/// 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. +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 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 != 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 +/// 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). +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.)