# 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. ```