diff --git a/docs/superpowers/plans/2026-06-20-d2b-drag-drop-spine.md b/docs/superpowers/plans/2026-06-20-d2b-drag-drop-spine.md new file mode 100644 index 00000000..71a6ac50 --- /dev/null +++ b/docs/superpowers/plans/2026-06-20-d2b-drag-drop-spine.md @@ -0,0 +1,752 @@ +# Drag-drop spine (Stream B.1) Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Complete the widget-level drag-drop machine so an item icon can be picked up, dragged with a cursor-following ghost, hovered slots flip accept/reject, and the drop is dispatched to the owning panel's handler — with a visible toolbar stub proving the chain this session. + +**Architecture:** `UiRoot` already holds the device-level drag state machine. We add (1) a typed `ItemDragPayload` + `IItemListDragHandler`, (2) two `UiElement` virtuals (`GetDragPayload`/`GetDragGhost`) so `UiRoot` stays item-agnostic, (3) drag/drop event handling + an accept/reject overlay on the cell (`UiItemSlot`), which delegates the accept decision + dispatch UP to its parent `UiItemList`'s registered handler, and (4) a logging toolbar stub handler (wire deferred to B.2). Spec: `docs/superpowers/specs/2026-06-20-d2b-drag-drop-spine-design.md`. + +**Tech Stack:** C# / .NET 10, xUnit (`[Fact]`, `Assert.*`), the `src/AcDream.App/UI/` retained-mode toolkit. `InternalsVisibleTo("AcDream.App.Tests")` is set — internal members are test-visible. Tests are pure-logic (no GL); the cursor ghost render is visual-only (no unit test), confirmed by the user at the end. + +--- + +## File Structure + +**Create:** +- `src/AcDream.App/UI/ItemDragPayload.cs` — `ItemDragSource` enum + `ItemDragPayload` record (the drag snapshot). +- `src/AcDream.App/UI/IItemListDragHandler.cs` — the panel-handler interface. +- `tests/AcDream.App.Tests/UI/DragDropSpineTests.cs` — toolkit-level conformance. + +**Modify:** +- `src/AcDream.App/UI/UiItemList.cs` — `DragHandler` property + `RegisterDragHandler`. +- `src/AcDream.App/UI/UiElement.cs` — `GetDragPayload()` + `GetDragGhost()` virtuals. +- `src/AcDream.App/UI/UiItemSlot.cs` — `SlotIndex`, `SourceKind`, accept/reject overlay, the two overrides, drag/drop `OnEvent` cases, `FindList`, MouseDown→Click use move. +- `src/AcDream.App/UI/UiRoot.cs` — `BeginDrag` payload pull + cancel; cursor ghost in `Draw`. +- `src/AcDream.App/UI/Layout/ToolbarController.cs` — implement `IItemListDragHandler`, register on each slot list, set `SlotIndex`/`SourceKind`, stub `HandleDropRelease`. +- `tests/AcDream.App.Tests/UI/Layout/ToolbarControllerTests.cs` — fix `Click_emitsUseForBoundItem` (send Click not MouseDown), add registration tests. +- `docs/architecture/retail-divergence-register.md` — add **AP-47** (Task 3) + **TS-33** (Task 4). + +**Build/test commands** (run from repo root): +- Build: `dotnet build src/AcDream.App/AcDream.App.csproj -c Debug` +- Test a class: `dotnet test tests/AcDream.App.Tests/AcDream.App.Tests.csproj --filter "FullyQualifiedName~DragDropSpine"` +- Full app tests: `dotnet test tests/AcDream.App.Tests/AcDream.App.Tests.csproj` + +**Every commit ends with the trailer** `Co-Authored-By: Claude Opus 4.8 (1M context) ` (per CLAUDE.md). + +--- + +## Task 1: Payload types + handler interface + UiItemList registration + +**Files:** +- Create: `src/AcDream.App/UI/ItemDragPayload.cs` +- Create: `src/AcDream.App/UI/IItemListDragHandler.cs` +- Modify: `src/AcDream.App/UI/UiItemList.cs` +- Test: `tests/AcDream.App.Tests/UI/DragDropSpineTests.cs` + +- [ ] **Step 1: Write the failing test** + +Create `tests/AcDream.App.Tests/UI/DragDropSpineTests.cs`: + +```csharp +using AcDream.App.UI; +using Xunit; + +namespace AcDream.App.Tests.UI; + +public class DragDropSpineTests +{ + // A spy handler used across the spine tests. + private sealed class SpyHandler : IItemListDragHandler + { + public bool AcceptResult = true; + public (UiItemList list, UiItemSlot cell, ItemDragPayload payload)? LastOver; + public (UiItemList list, UiItemSlot cell, ItemDragPayload payload)? LastDrop; + + public bool OnDragOver(UiItemList list, UiItemSlot cell, ItemDragPayload p) + { LastOver = (list, cell, p); return AcceptResult; } + + public void HandleDropRelease(UiItemList list, UiItemSlot cell, ItemDragPayload p) + { LastDrop = (list, cell, p); } + } + + [Fact] + public void Payload_holdsAllFields() + { + var src = new UiItemSlot(); + var p = new ItemDragPayload(0x5001u, ItemDragSource.ShortcutBar, 3, src); + Assert.Equal(0x5001u, p.ObjId); + Assert.Equal(ItemDragSource.ShortcutBar, p.SourceKind); + Assert.Equal(3, p.SourceSlot); + Assert.Same(src, p.SourceCell); + } + + [Fact] + public void UiItemList_registerDragHandler_roundtrips() + { + var list = new UiItemList(_ => (0u, 0, 0)); + Assert.Null(list.DragHandler); + var h = new SpyHandler(); + list.RegisterDragHandler(h); + Assert.Same(h, list.DragHandler); + } +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `dotnet test tests/AcDream.App.Tests/AcDream.App.Tests.csproj --filter "FullyQualifiedName~DragDropSpine"` +Expected: FAIL to compile — `ItemDragPayload`, `ItemDragSource`, `IItemListDragHandler`, `UiItemList.DragHandler`, `RegisterDragHandler` not defined. + +- [ ] **Step 3: Create the payload + enum** + +Create `src/AcDream.App/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 anchors: gmToolbarUI 0x004bd162 / 0x004bd1af; InqDropIconInfo 230533. +/// +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 intentionally NOT stored: the handler resolves the +/// LIVE container via ClientObjectTable.Get(ObjId).ContainerId at drop — the +/// same container id retail reads off the dragged element, single source of truth. +/// +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 +``` + +- [ ] **Step 4: Create the handler interface** + +Create `src/AcDream.App/UI/IItemListDragHandler.cs`: + +```csharp +namespace AcDream.App.UI; + +/// +/// A panel controller implements this and registers itself on each of its +/// s. Port of retail's m_dragHandler vtable +/// (RegisterItemListDragHandler, decomp 230461; confirmed acclient +/// 0x004a539e + the gmToolbarUI block 0x004bdd89). +/// decides the accept/reject OVERLAY only (advisory). +/// is authoritative — it performs the action, or +/// no-ops to reject. +/// +public interface IItemListDragHandler +{ + /// True ⇒ the target cell shows the accept (green) frame; false ⇒ reject (red). + bool OnDragOver(UiItemList targetList, UiItemSlot targetCell, ItemDragPayload payload); + + /// Perform the drop (issue the per-panel wire action), or no-op to reject. + void HandleDropRelease(UiItemList targetList, UiItemSlot targetCell, ItemDragPayload payload); +} +``` + +- [ ] **Step 5: Add registration to UiItemList** + +In `src/AcDream.App/UI/UiItemList.cs`, add inside the class (e.g. just after the `SpriteResolve` property at ~line 25): + +```csharp + /// The drag handler this list routes drops to (a panel controller). + /// Null = the list accepts no drops. Retail: UIElement_ItemList::m_dragHandler. + public IItemListDragHandler? DragHandler { get; private set; } + + /// Register the panel's drag handler on this list. Retail: + /// UIElement_ItemList::RegisterItemListDragHandler (decomp 230461). + public void RegisterDragHandler(IItemListDragHandler handler) => DragHandler = handler; +``` + +- [ ] **Step 6: Run test to verify it passes** + +Run: `dotnet test tests/AcDream.App.Tests/AcDream.App.Tests.csproj --filter "FullyQualifiedName~DragDropSpine"` +Expected: PASS (2 tests). + +- [ ] **Step 7: Commit** + +```bash +git add src/AcDream.App/UI/ItemDragPayload.cs src/AcDream.App/UI/IItemListDragHandler.cs src/AcDream.App/UI/UiItemList.cs tests/AcDream.App.Tests/UI/DragDropSpineTests.cs +git commit -m "feat(ui): D.5.3/B.1 — drag payload + handler interface + UiItemList registration" -m "Co-Authored-By: Claude Opus 4.8 (1M context) " +``` + +--- + +## Task 2: UiElement drag hooks + UiItemSlot drag/drop + overlay + +**Files:** +- Modify: `src/AcDream.App/UI/UiElement.cs` +- Modify: `src/AcDream.App/UI/UiItemSlot.cs` +- Modify: `tests/AcDream.App.Tests/UI/Layout/ToolbarControllerTests.cs` (fix the one MouseDown→Click test) +- Test: `tests/AcDream.App.Tests/UI/DragDropSpineTests.cs` (add cases) + +- [ ] **Step 1: Write the failing tests** (append to `DragDropSpineTests.cs`, inside the class) + +```csharp + // ── UiItemSlot drag-source payload/ghost ──────────────────────────────── + [Fact] + public void GetDragPayload_emptyCell_isNull() + => Assert.Null(new UiItemSlot().GetDragPayload()); + + [Fact] + public void GetDragPayload_boundCell_snapshotsFields() + { + var cell = new UiItemSlot { SlotIndex = 4, SourceKind = ItemDragSource.ShortcutBar }; + cell.SetItem(0x5001u, 0x99u); + var p = Assert.IsType(cell.GetDragPayload()); + Assert.Equal(0x5001u, p.ObjId); + Assert.Equal(ItemDragSource.ShortcutBar, p.SourceKind); + Assert.Equal(4, p.SourceSlot); + Assert.Same(cell, p.SourceCell); + } + + [Fact] + public void GetDragGhost_emptyCell_isNull() + => Assert.Null(new UiItemSlot().GetDragGhost()); + + [Fact] + public void GetDragGhost_boundCell_returnsIconTuple() + { + var cell = new UiItemSlot { Width = 32, Height = 32 }; + cell.SetItem(0x5001u, 0x99u); + var g = cell.GetDragGhost(); + Assert.NotNull(g); + Assert.Equal(0x99u, g!.Value.tex); + Assert.Equal(32, g.Value.w); + Assert.Equal(32, g.Value.h); + } + + // ── cell drop-target: DragEnter overlay + DropReleased dispatch ────────── + private static (UiItemList list, UiItemSlot cell, SpyHandler h) ListWithHandler() + { + var list = new UiItemList(_ => (1u, 1, 1)); // non-zero resolve so overlay draw is harmless + var h = new SpyHandler(); + list.RegisterDragHandler(h); + return (list, list.Cell, h); + } + + private static ItemDragPayload SomePayload() + => new(0x5001u, ItemDragSource.ShortcutBar, 0, new UiItemSlot()); + + [Fact] + public void DragEnter_setsAcceptOverlay_whenHandlerAccepts() + { + var (_, cell, h) = ListWithHandler(); + h.AcceptResult = true; + cell.OnEvent(new UiEvent(0u, cell, UiEventType.DragEnter, Payload: SomePayload())); + Assert.Equal(UiItemSlot.DragAcceptState.Accept, cell.DragAcceptVisual); + } + + [Fact] + public void DragEnter_setsRejectOverlay_whenHandlerRejects() + { + var (_, cell, h) = ListWithHandler(); + h.AcceptResult = false; + cell.OnEvent(new UiEvent(0u, cell, UiEventType.DragEnter, Payload: SomePayload())); + Assert.Equal(UiItemSlot.DragAcceptState.Reject, cell.DragAcceptVisual); + } + + [Fact] + public void DragOver_resetsOverlayToNeutral() + { + var (_, cell, h) = ListWithHandler(); + cell.OnEvent(new UiEvent(0u, cell, UiEventType.DragEnter, Payload: SomePayload())); + cell.OnEvent(new UiEvent(0u, cell, UiEventType.DragOver, Payload: SomePayload())); + Assert.Equal(UiItemSlot.DragAcceptState.None, cell.DragAcceptVisual); + } + + [Fact] + public void DropReleased_accepted_dispatchesToHandler() + { + var (list, cell, h) = ListWithHandler(); + var p = SomePayload(); + cell.OnEvent(new UiEvent(0u, cell, UiEventType.DropReleased, Data0: 1, Payload: p)); + Assert.NotNull(h.LastDrop); + Assert.Same(list, h.LastDrop!.Value.list); + Assert.Same(cell, h.LastDrop.Value.cell); + Assert.Same(p, h.LastDrop.Value.payload); + } + + [Fact] + public void DropReleased_notAccepted_skipsDispatch() + { + var (_, cell, h) = ListWithHandler(); + cell.OnEvent(new UiEvent(0u, cell, UiEventType.DropReleased, Data0: 0, Payload: SomePayload())); + Assert.Null(h.LastDrop); + } +``` + +- [ ] **Step 2: Run to verify it fails** + +Run: `dotnet test tests/AcDream.App.Tests/AcDream.App.Tests.csproj --filter "FullyQualifiedName~DragDropSpine"` +Expected: FAIL to compile — `GetDragPayload`/`GetDragGhost` not on `UiItemSlot`, `SlotIndex`/`SourceKind`/`DragAcceptVisual`/`DragAcceptState` not defined. + +- [ ] **Step 3: Add the two virtuals to UiElement** + +In `src/AcDream.App/UI/UiElement.cs`, add to the "Virtual overrides" region (e.g. after `OnEvent` at ~line 204): + +```csharp + /// The data this element carries when a drag begins. + /// pulls this on drag-promote; 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 paints at the cursor while this element + /// is the drag source: (GL handle, width, height). Null = no ghost. Keeps + /// item-agnostic. Retail analog: m_dragIcon (decomp 229738). + public virtual (uint tex, int w, int h)? GetDragGhost() => null; +``` + +- [ ] **Step 4: Add drag state + overrides + event handling to UiItemSlot** + +In `src/AcDream.App/UI/UiItemSlot.cs`: + +(a) Add fields/props after `SourceKind` area — insert after the `IconTexture` property (~line 22): + +```csharp + /// This cell's own index within its panel (0..17 toolbar; container slot + /// for inventory). Distinct from (the 1–9 label, -1 on the + /// bottom row). Set by the controller; used as the drag payload's SourceSlot and to + /// identify the drop TARGET slot. + public int SlotIndex { get; set; } = -1; + + /// What kind of slot this is, for the drag payload (retail InqDropIconInfo + /// flags). Controller overrides; default Inventory. + public ItemDragSource SourceKind { get; set; } = ItemDragSource.Inventory; + + /// Drag-rollover accept frame (retail ItemSlot_DragOver_Accept 0x060011F9, + /// state id 0x10000041). Configurable; guard id != 0 before resolving. + public uint DragAcceptSprite { get; set; } = 0x060011F9u; + /// Drag-rollover reject frame (retail ItemSlot_DragOver_Reject 0x060011F8, + /// state id 0x10000040). + public uint DragRejectSprite { get; set; } = 0x060011F8u; + + /// Accept/reject overlay state while a drag hovers this cell. + public enum DragAcceptState { None, Accept, Reject } + private DragAcceptState _dragAccept = DragAcceptState.None; + /// Current overlay state — internal so unit tests can assert it (InternalsVisibleTo). + internal DragAcceptState DragAcceptVisual => _dragAccept; +``` + +(b) Add the two overrides (anywhere in the class, e.g. after `Clear()`): + +```csharp + /// + public override object? GetDragPayload() + => ItemId != 0 ? new ItemDragPayload(ItemId, SourceKind, SlotIndex, this) : null; + + /// + public override (uint tex, int w, int h)? GetDragGhost() + => ItemId != 0 && IconTexture != 0 ? (IconTexture, (int)Width, (int)Height) : null; + + /// Walk up to the containing (the drop handler owner). + private UiItemList? FindList() + { + UiElement? e = Parent; + while (e is not null) { if (e is UiItemList l) return l; e = e.Parent; } + return null; + } +``` + +(c) Replace the `OnEvent` method (currently MouseDown→Clicked) with: + +```csharp + /// + public override bool OnEvent(in UiEvent e) + { + switch (e.Type) + { + // Use fires on CLICK (mouse-up over the same cell), not MouseDown — so a + // drag (press + >3px move) does NOT also use the item. UiRoot suppresses the + // post-drag Click (UiRoot.cs FinishDrag returns before the Click emit). + case UiEventType.MouseDown: + return true; // consume the press; no use here + case UiEventType.Click: + Clicked?.Invoke(); + return true; + + case UiEventType.DragBegin: + return true; // we're the source; payload already pulled by UiRoot + + case UiEventType.DragEnter: // pointer entered me mid-drag → ask the list's handler + _dragAccept = (FindList() is { DragHandler: { } h } list + && e.Payload is ItemDragPayload p && h.OnDragOver(list, this, p)) + ? DragAcceptState.Accept : DragAcceptState.Reject; + return true; + + case UiEventType.DragOver: // UiRoot fires this on LEAVE → neutral + _dragAccept = DragAcceptState.None; + return true; + + case UiEventType.DropReleased: + _dragAccept = DragAcceptState.None; + if (e.Data0 == 1 // accepted = dropped on a DIFFERENT element (skips self/empty) + && FindList() is { DragHandler: { } dh } dl + && e.Payload is ItemDragPayload dp) + dh.HandleDropRelease(dl, this, dp); + return true; + } + return false; + } +``` + +(d) In `OnDraw`, add the accept/reject overlay AFTER the digit-overlay block (at the very end of `OnDraw`): + +```csharp + // Drag-rollover accept/reject frame (retail SetDragAcceptState 0x10000041/40). + // Guard id != 0 BEFORE resolving — resolve(0) returns the 1×1 magenta placeholder + // with a non-zero GL handle (feedback_ui_resolve_zero_magenta). + if (_dragAccept != DragAcceptState.None && SpriteResolve is not null) + { + uint id = _dragAccept == DragAcceptState.Accept ? DragAcceptSprite : DragRejectSprite; + if (id != 0) + { + var (tex, _, _) = SpriteResolve(id); + if (tex != 0) + ctx.DrawSprite(tex, 0f, 0f, Width, Height, 0f, 0f, 1f, 1f, Vector4.One); + } + } +``` + +- [ ] **Step 5: Fix the existing controller test that sent MouseDown** + +In `tests/AcDream.App.Tests/UI/Layout/ToolbarControllerTests.cs`, in `Click_emitsUseForBoundItem`, change the event from `MouseDown` to `Click` (the test name already says "Click"): + +```csharp + // Use now fires on Click (mouse-up), not MouseDown — drag/click disambiguation. + slots[Row1[0]].Cell.OnEvent(new UiEvent(0u, null, UiEventType.Click)); +``` + +- [ ] **Step 6: Run to verify it passes** + +Run: `dotnet test tests/AcDream.App.Tests/AcDream.App.Tests.csproj --filter "FullyQualifiedName~DragDropSpine|FullyQualifiedName~ToolbarController|FullyQualifiedName~UiItemSlot"` +Expected: PASS (the new DragDropSpine cases, the fixed ToolbarController test, and all UiItemSlot tests). + +- [ ] **Step 7: Commit** + +```bash +git add src/AcDream.App/UI/UiElement.cs src/AcDream.App/UI/UiItemSlot.cs tests/AcDream.App.Tests/UI/DragDropSpineTests.cs tests/AcDream.App.Tests/UI/Layout/ToolbarControllerTests.cs +git commit -m "feat(ui): D.5.3/B.1 — UiItemSlot drag source + drop target + accept/reject overlay" -m "Co-Authored-By: Claude Opus 4.8 (1M context) " +``` + +--- + +## Task 3: UiRoot payload injection + cursor ghost (+ AP-47) + +**Files:** +- Modify: `src/AcDream.App/UI/UiRoot.cs` +- Modify: `docs/architecture/retail-divergence-register.md` +- Test: `tests/AcDream.App.Tests/UI/DragDropSpineTests.cs` (add full-chain cases) + +- [ ] **Step 1: Write the failing tests** (append to `DragDropSpineTests.cs`, inside the class) + +```csharp + // ── Full UiRoot chain: arming + use-vs-drag ───────────────────────────── + // A bound, hit-testable slot inside a list, sized for the hit-test. + private static (UiRoot root, UiItemList list, UiItemSlot cell) RootWithBoundSlot(uint itemId) + { + var root = new UiRoot { Width = 800, Height = 600 }; + var list = new UiItemList(_ => (1u, 1, 1)) { Left = 0, Top = 0, Width = 32, Height = 32 }; + // Tests don't run OnDraw (which sizes the cell), so size the cell explicitly. + list.Cell.Width = 32; list.Cell.Height = 32; + if (itemId != 0) list.Cell.SetItem(itemId, 0x99u); + root.AddChild(list); + return (root, list, list.Cell); + } + + [Fact] + public void BeginDrag_arms_whenPayloadNonNull() + { + var (root, _, cell) = RootWithBoundSlot(0x5001u); + root.OnMouseDown(UiMouseButton.Left, 10, 10); + root.OnMouseMove(20, 10); // >3px → promote to drag + Assert.Same(cell, root.DragSource); + Assert.IsType(root.DragPayload); + } + + [Fact] + public void BeginDrag_doesNotArm_whenPayloadNull_emptySlot() + { + var (root, _, _) = RootWithBoundSlot(0u); // empty cell → GetDragPayload null + root.OnMouseDown(UiMouseButton.Left, 10, 10); + root.OnMouseMove(20, 10); + Assert.Null(root.DragSource); // never armed + } + + [Fact] + public void Click_withoutDrag_firesUse() + { + var (root, _, cell) = RootWithBoundSlot(0x5001u); + bool used = false; + cell.Clicked = () => used = true; + root.OnMouseDown(UiMouseButton.Left, 10, 10); + root.OnMouseUp(UiMouseButton.Left, 10, 10); // no move → Click emitted + Assert.True(used); + } + + [Fact] + public void CompletedDrag_doesNotFireUse() + { + var (root, _, cell) = RootWithBoundSlot(0x5001u); + bool used = false; + cell.Clicked = () => used = true; + root.OnMouseDown(UiMouseButton.Left, 10, 10); + root.OnMouseMove(20, 10); // promote to drag + root.OnMouseUp(UiMouseButton.Left, 20, 10); // FinishDrag, NOT Click + Assert.False(used); + } +``` + +- [ ] **Step 2: Run to verify it fails** + +Run: `dotnet test tests/AcDream.App.Tests/AcDream.App.Tests.csproj --filter "FullyQualifiedName~DragDropSpine"` +Expected: FAIL — `BeginDrag_doesNotArm_whenPayloadNull_emptySlot` fails (current `BeginDrag` arms unconditionally with `payload: null`, so `DragSource` is non-null), and `CompletedDrag_doesNotFireUse` may behave wrong because a null-payload drag would still arm. + +- [ ] **Step 3: Change BeginDrag to pull the payload + cancel when null** + +In `src/AcDream.App/UI/UiRoot.cs`, replace the `BeginDrag` method (~line 450): + +```csharp + private void BeginDrag(UiElement source) + { + // Pull the payload from the source; a null payload (e.g. an empty item cell) + // CANCELS the drag — retail's ItemList_BeginDrag only arms an occupied cell. + var payload = source.GetDragPayload(); + if (payload is null) { _dragCandidate = false; return; } + + DragSource = source; + DragPayload = payload; + var e = new UiEvent(source.EventId, source, UiEventType.DragBegin, Payload: payload); + source.OnEvent(in e); + } +``` + +Update the single call site (~line 188 in `OnMouseMove`) from `BeginDrag(Captured, payload: null);` to: + +```csharp + BeginDrag(Captured); +``` + +- [ ] **Step 4: Add the cursor ghost to Draw** + +In `src/AcDream.App/UI/UiRoot.cs`, replace the `Draw` method (~line 133) so it paints the ghost in the overlay layer, and add the helper + constant: + +```csharp + public void Draw(UiRenderContext ctx) + { + DrawSelfAndChildren(ctx); + // Open popups/menus + the drag ghost draw ON TOP of the whole tree (overlay layer). + ctx.BeginOverlayLayer(); + DrawOverlays(ctx); + DrawDragGhost(ctx); + ctx.EndOverlayLayer(); + } + + /// Translucency of the cursor-following drag ghost. AP-47: we reuse the + /// item's full composited icon at this alpha rather than retail's dedicated + /// underlay-less m_pDragIcon. + private const float GhostAlpha = 0.6f; + + /// Paint the drag ghost at the cursor. The texture comes from the source + /// element's so UiRoot stays item-agnostic; the + /// ghost is NOT a tree element, so it never intercepts hit-tests. + private void DrawDragGhost(UiRenderContext ctx) + { + if (DragSource?.GetDragGhost() is not { } g || g.tex == 0) return; + ctx.DrawSprite(g.tex, MouseX - g.w / 2f, MouseY - g.h / 2f, g.w, g.h, + 0f, 0f, 1f, 1f, new Vector4(1f, 1f, 1f, GhostAlpha)); + } +``` + +- [ ] **Step 5: Run to verify it passes** + +Run: `dotnet test tests/AcDream.App.Tests/AcDream.App.Tests.csproj --filter "FullyQualifiedName~DragDropSpine"` +Expected: PASS (all DragDropSpine tests). + +- [ ] **Step 6: Add the AP-47 divergence row** + +In `docs/architecture/retail-divergence-register.md`: bump the AP section header count `## 3. Documented approximation (AP) — 42 rows` → `— 43 rows`, and append this row at the END of the AP table (after the `AP-46` row, before the section's `---`): + +```markdown +| AP-47 | Cursor 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 type-default underlay) | `src/AcDream.App/UI/UiRoot.cs` (`DrawDragGhost`/`GhostAlpha`) → `src/AcDream.App/UI/UiItemSlot.cs` (`GetDragGhost`) | Cosmetic only — the dragged item is still unambiguously identifiable; building the second underlay-less composite is deferred polish; the ghost is item-agnostic in UiRoot via `GetDragGhost()` | The ghost shows the opaque type-default underlay backing rather than retail's underlay-less translucent copy — a subtle look difference while dragging, no functional effect | `IconData::RenderIcons` acclient_2013_pseudo_c.txt:407594-407625 (m_pDragIcon path); deep-dive §3.2/§5.5 | +``` + +- [ ] **Step 7: Commit** + +```bash +git add src/AcDream.App/UI/UiRoot.cs docs/architecture/retail-divergence-register.md tests/AcDream.App.Tests/UI/DragDropSpineTests.cs +git commit -m "feat(ui): D.5.3/B.1 — UiRoot payload injection + cursor drag ghost (AP-47)" -m "Co-Authored-By: Claude Opus 4.8 (1M context) " +``` + +--- + +## Task 4: Toolbar stub handler + wiring (+ TS-33 + registration tests) + +**Files:** +- Modify: `src/AcDream.App/UI/Layout/ToolbarController.cs` +- Modify: `tests/AcDream.App.Tests/UI/Layout/ToolbarControllerTests.cs` +- Modify: `docs/architecture/retail-divergence-register.md` + +- [ ] **Step 1: Write the failing tests** (append to `ToolbarControllerTests.cs`, inside the class) + +```csharp + // ── B.1: drag-drop spine wiring ────────────────────────────────────────── + + /// Bind registers the controller as each slot list's drag handler and + /// stamps every cell's SlotIndex + SourceKind=ShortcutBar. + [Fact] + public void Bind_registersDragHandler_andStampsSlots() + { + var (layout, slots, _) = FakeToolbar(); + var repo = new ClientObjectTable(); + + var ctrl = ToolbarController.Bind(layout, repo, + () => Array.Empty(), + iconIds: (_,_,_,_,_) => 0u, useItem: _ => { }); + + for (int i = 0; i < Row1.Length; i++) + { + Assert.Same(ctrl, slots[Row1[i]].DragHandler); + Assert.Equal(i, slots[Row1[i]].Cell.SlotIndex); + Assert.Equal(ItemDragSource.ShortcutBar, slots[Row1[i]].Cell.SourceKind); + } + // Bottom row slots are indices 9..17. + for (int j = 0; j < Row2.Length; j++) + Assert.Equal(9 + j, slots[Row2[j]].Cell.SlotIndex); + } + + /// OnDragOver accepts a real item (ObjId != 0). Eligibility (IsShortcutEligible) + /// is Stream B.2; the stub accepts any non-empty payload. + [Fact] + public void OnDragOver_acceptsRealItem() + { + var (layout, slots, _) = FakeToolbar(); + var ctrl = ToolbarController.Bind(layout, new ClientObjectTable(), + () => Array.Empty(), + iconIds: (_,_,_,_,_) => 0u, useItem: _ => { }); + + var list = slots[Row1[0]]; + var payload = new ItemDragPayload(0x5001u, ItemDragSource.Inventory, 0, new UiItemSlot()); + Assert.True(ctrl.OnDragOver(list, list.Cell, payload)); + } + + /// HandleDropRelease is a logging stub (TS-33): it must not throw and must not + /// mutate the target slot (no wire / no store yet). + [Fact] + public void HandleDropRelease_isInertStub() + { + var (layout, slots, _) = FakeToolbar(); + var ctrl = ToolbarController.Bind(layout, new ClientObjectTable(), + () => Array.Empty(), + iconIds: (_,_,_,_,_) => 0u, useItem: _ => { }); + + var list = slots[Row1[2]]; + var payload = new ItemDragPayload(0x5001u, ItemDragSource.ShortcutBar, 0, new UiItemSlot()); + var ex = Record.Exception(() => ctrl.HandleDropRelease(list, list.Cell, payload)); + Assert.Null(ex); + Assert.Equal(0u, list.Cell.ItemId); // unchanged — inert stub + } +``` + +- [ ] **Step 2: Run to verify it fails** + +Run: `dotnet test tests/AcDream.App.Tests/AcDream.App.Tests.csproj --filter "FullyQualifiedName~ToolbarController"` +Expected: FAIL to compile — `ToolbarController` doesn't implement `IItemListDragHandler` (`OnDragOver`/`HandleDropRelease`), `DragHandler` not registered. + +- [ ] **Step 3: Make ToolbarController implement the handler + wire registration** + +In `src/AcDream.App/UI/Layout/ToolbarController.cs`: + +(a) Change the class declaration: + +```csharp +public sealed class ToolbarController : IItemListDragHandler +``` + +(b) In the constructor, extend the slot loop (currently `_slots[i] = layout.FindElement(SlotIds[i]) as UiItemList; if (_slots[i] is { } list) WireClick(list);`) to also register the handler + stamp the cell: + +```csharp + for (int i = 0; i < SlotIds.Length; i++) + { + _slots[i] = layout.FindElement(SlotIds[i]) as UiItemList; + if (_slots[i] is { } list) + { + WireClick(list); + // B.1 drag-drop spine: this controller is the drop handler for every + // toolbar slot list; each cell knows its slot index + that it's a + // shortcut-bar source (retail UIElement_ItemList::RegisterItemListDragHandler). + list.RegisterDragHandler(this); + list.Cell.SlotIndex = i; + list.Cell.SourceKind = AcDream.App.UI.ItemDragSource.ShortcutBar; + } + } +``` + +(c) Add the interface implementation (e.g. at the end of the class): + +```csharp + // ── IItemListDragHandler (B.1 spine) ───────────────────────────────────── + // Retail: gmToolbarUI is the m_dragHandler for every shortcut slot list. + // The accept/reject gate (IsShortcutEligible) and the AddShortcut/RemoveShortcut + // wire are Stream B.2; this stub accepts any real item and LOGS the drop (TS-33). + + /// + public bool OnDragOver(UiItemList targetList, UiItemSlot targetCell, ItemDragPayload payload) + => payload.ObjId != 0; + + /// + public void HandleDropRelease(UiItemList targetList, UiItemSlot targetCell, ItemDragPayload payload) + { + // TS-33: no wire yet. Stream B.2 replaces this with the ShortcutStore mutation + + // AddShortcut 0x019C / RemoveShortcut 0x019D sends (gmToolbarUI::HandleDropRelease :197971). + Console.WriteLine($"[B.2 TODO] drop obj 0x{payload.ObjId:X8} from {payload.SourceKind} " + + $"slot {payload.SourceSlot} → toolbar slot {targetCell.SlotIndex}"); + } +``` + +(d) Confirm `using AcDream.App.UI;` is present at the top (it is). The `ItemDragSource`/`ItemDragPayload`/`UiItemSlot`/`UiItemList`/`IItemListDragHandler` types are all in `AcDream.App.UI`. + +- [ ] **Step 4: Run to verify it passes** + +Run: `dotnet test tests/AcDream.App.Tests/AcDream.App.Tests.csproj --filter "FullyQualifiedName~ToolbarController"` +Expected: PASS (the 3 new B.1 tests + all existing ToolbarController tests, including the fixed Click test). + +- [ ] **Step 5: Add the TS-33 divergence row** + +In `docs/architecture/retail-divergence-register.md`: bump the TS section header count `## 4. Temporary stopgap (TS) — 31 rows` → `— 32 rows`, and append this row at the END of the TS table (after the `TS-32` row, before the section's `---`): + +```markdown +| TS-33 | Toolbar `IItemListDragHandler.HandleDropRelease` is a logging stub — no `AddShortcut 0x019C` / `RemoveShortcut 0x019D` wire, no mutable `ShortcutStore`; a drop onto the bar logs and does nothing | `src/AcDream.App/UI/Layout/ToolbarController.cs` (`HandleDropRelease`) | The B.1 spine ships the drag machine (payload/ghost/overlay/dispatch) independent of the wire; the shortcut store + add/remove/reorder sends are Stream B.2, which needs the inventory window as a drag source too | A player dragging onto/within the hotbar sees the ghost + accept overlay but the drop is inert until B.2 — add/remove/reorder don't persist | `gmToolbarUI::HandleDropRelease` acclient_2013_pseudo_c.txt:197971; `AddShortcut`/`RemoveShortcut` 0x019C/0x019D | +``` + +- [ ] **Step 6: Run the FULL app test suite (no regressions)** + +Run: `dotnet build src/AcDream.App/AcDream.App.csproj -c Debug` then `dotnet test tests/AcDream.App.Tests/AcDream.App.Tests.csproj` +Expected: build green; all tests pass. + +- [ ] **Step 7: Commit** + +```bash +git add src/AcDream.App/UI/Layout/ToolbarController.cs tests/AcDream.App.Tests/UI/Layout/ToolbarControllerTests.cs docs/architecture/retail-divergence-register.md +git commit -m "feat(ui): D.5.3/B.1 — toolbar drag handler stub + spine wiring (TS-33)" -m "Co-Authored-By: Claude Opus 4.8 (1M context) " +``` + +--- + +## Final verification + +- [ ] **Full solution build + test:** `dotnet build AcDream.slnx -c Debug` and `dotnet test tests/AcDream.App.Tests/AcDream.App.Tests.csproj` — both green. +- [ ] **Self-check the acceptance criteria** in the spec §9 (UiRoot item-agnostic; AP-47 + TS-33 added; counts bumped). +- [ ] **Hand back for visual verification** (the user runs the client): with `ACDREAM_RETAIL_UI=1`, in-world — grab a hotbar item and drag: a translucent ghost follows the cursor; the hovered slot shows the accept (green) frame; on release over another slot the log prints `[B.2 TODO] drop obj … → toolbar slot N`; the source item stays put; a plain click still USES the item. (Launch per CLAUDE.md "Running the client" — build green first.) +- [ ] **Update memory** if a durable lesson emerged; note in `docs/ISSUES.md` / roadmap only if scope shifted. +```