acdream/docs/superpowers/plans/2026-06-20-d2b-drag-drop-spine.md
Erik 24ce8a0b5e docs(D.5.3/B.1): drag-drop spine implementation plan
Bite-sized TDD plan for Stream B.1, four tasks matching the spec slices:
 1. ItemDragPayload + ItemDragSource + IItemListDragHandler + UiItemList
    handler registration.
 2. UiElement GetDragPayload/GetDragGhost virtuals + UiItemSlot drag source
    + drop target + accept/reject overlay; use moves MouseDown->Click (fixes
    the existing ToolbarControllerTests.Click test in-task to stay green).
 3. UiRoot payload pull + cancel-on-null + cursor ghost (AP-47).
 4. ToolbarController stub handler + spine wiring (TS-33) + registration tests.

Exact code, commands, and the AP-47/TS-33 register rows inline. Plan + spec
preapproved by the user; executing subagent-driven next.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-20 12:31:37 +02:00

35 KiB
Raw Blame History

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.csItemDragSource 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.csDragHandler property + RegisterDragHandler.
  • src/AcDream.App/UI/UiElement.csGetDragPayload() + GetDragGhost() virtuals.
  • src/AcDream.App/UI/UiItemSlot.csSlotIndex, SourceKind, accept/reject overlay, the two overrides, drag/drop OnEvent cases, FindList, MouseDown→Click use move.
  • src/AcDream.App/UI/UiRoot.csBeginDrag 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) <noreply@anthropic.com> (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:

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:

namespace AcDream.App.UI;

/// <summary>
/// Where a dragged item came from — the retail <c>InqDropIconInfo</c> flag
/// distinction (<c>flags &amp; 0xE == 0</c> fresh-from-inventory vs
/// <c>flags &amp; 4</c> 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.
/// </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 <c>m_dragElement</c> +
/// <c>InqDropIconInfo</c> out-params (objId/container/flags, decomp 230533).
/// <para><c>SourceContainer</c> is intentionally NOT stored: the handler resolves the
/// LIVE container via <c>ClientObjectTable.Get(ObjId).ContainerId</c> at drop — the
/// same container id retail reads off the dragged element, single source of truth.</para>
/// </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
  • Step 4: Create the handler interface

Create src/AcDream.App/UI/IItemListDragHandler.cs:

namespace AcDream.App.UI;

/// <summary>
/// A panel controller implements this and registers itself on each of its
/// <see cref="UiItemList"/>s. Port of retail's <c>m_dragHandler</c> vtable
/// (<c>RegisterItemListDragHandler</c>, decomp 230461; confirmed acclient
/// 0x004a539e + the gmToolbarUI block 0x004bdd89).
/// <para><see cref="OnDragOver"/> decides the accept/reject OVERLAY only (advisory).
/// <see cref="HandleDropRelease"/> is authoritative — it performs the action, or
/// no-ops to reject.</para>
/// </summary>
public interface IItemListDragHandler
{
    /// <summary>True ⇒ the target cell shows the accept (green) frame; false ⇒ reject (red).</summary>
    bool OnDragOver(UiItemList targetList, UiItemSlot targetCell, ItemDragPayload payload);

    /// <summary>Perform the drop (issue the per-panel wire action), or no-op to reject.</summary>
    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):

    /// <summary>The drag handler this list routes drops to (a panel controller).
    /// Null = the list accepts no drops. Retail: UIElement_ItemList::m_dragHandler.</summary>
    public IItemListDragHandler? DragHandler { get; private set; }

    /// <summary>Register the panel's drag handler on this list. Retail:
    /// UIElement_ItemList::RegisterItemListDragHandler (decomp 230461).</summary>
    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
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) <noreply@anthropic.com>"

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)

    // ── 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<ItemDragPayload>(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):

    /// <summary>The data this element carries when a drag begins. <see cref="UiRoot"/>
    /// pulls this on drag-promote; 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 <see cref="UiRoot"/> paints at the cursor while this element
    /// is the drag source: (GL handle, width, height). Null = no ghost. Keeps
    /// <see cref="UiRoot"/> item-agnostic. Retail analog: m_dragIcon (decomp 229738).</summary>
    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):

    /// <summary>This cell's own index within its panel (0..17 toolbar; container slot
    /// for inventory). Distinct from <see cref="ShortcutNum"/> (the 19 label, -1 on the
    /// bottom row). Set by the controller; used as the drag payload's SourceSlot and to
    /// identify the drop TARGET slot.</summary>
    public int SlotIndex { get; set; } = -1;

    /// <summary>What kind of slot this is, for the drag payload (retail InqDropIconInfo
    /// flags). Controller overrides; default Inventory.</summary>
    public ItemDragSource SourceKind { get; set; } = ItemDragSource.Inventory;

    /// <summary>Drag-rollover accept frame (retail ItemSlot_DragOver_Accept 0x060011F9,
    /// state id 0x10000041). Configurable; guard id != 0 before resolving.</summary>
    public uint DragAcceptSprite { get; set; } = 0x060011F9u;
    /// <summary>Drag-rollover reject frame (retail ItemSlot_DragOver_Reject 0x060011F8,
    /// state id 0x10000040).</summary>
    public uint DragRejectSprite { get; set; } = 0x060011F8u;

    /// <summary>Accept/reject overlay state while a drag hovers this cell.</summary>
    public enum DragAcceptState { None, Accept, Reject }
    private DragAcceptState _dragAccept = DragAcceptState.None;
    /// <summary>Current overlay state — internal so unit tests can assert it (InternalsVisibleTo).</summary>
    internal DragAcceptState DragAcceptVisual => _dragAccept;

(b) Add the two overrides (anywhere in the class, e.g. after Clear()):

    /// <inheritdoc/>
    public override object? GetDragPayload()
        => ItemId != 0 ? new ItemDragPayload(ItemId, SourceKind, SlotIndex, this) : null;

    /// <inheritdoc/>
    public override (uint tex, int w, int h)? GetDragGhost()
        => ItemId != 0 && IconTexture != 0 ? (IconTexture, (int)Width, (int)Height) : null;

    /// <summary>Walk up to the containing <see cref="UiItemList"/> (the drop handler owner).</summary>
    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:

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

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

        // 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
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) <noreply@anthropic.com>"

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)

    // ── 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<ItemDragPayload>(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):

    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:

                    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:

    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();
    }

    /// <summary>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.</summary>
    private const float GhostAlpha = 0.6f;

    /// <summary>Paint the drag ghost at the cursor. The texture comes from the source
    /// element's <see cref="UiElement.GetDragGhost"/> so UiRoot stays item-agnostic; the
    /// ghost is NOT a tree element, so it never intercepts hit-tests.</summary>
    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 ---):

| 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
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) <noreply@anthropic.com>"

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)

    // ── B.1: drag-drop spine wiring ──────────────────────────────────────────

    /// <summary>Bind registers the controller as each slot list's drag handler and
    /// stamps every cell's SlotIndex + SourceKind=ShortcutBar.</summary>
    [Fact]
    public void Bind_registersDragHandler_andStampsSlots()
    {
        var (layout, slots, _) = FakeToolbar();
        var repo = new ClientObjectTable();

        var ctrl = ToolbarController.Bind(layout, repo,
            () => Array.Empty<PlayerDescriptionParser.ShortcutEntry>(),
            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);
    }

    /// <summary>OnDragOver accepts a real item (ObjId != 0). Eligibility (IsShortcutEligible)
    /// is Stream B.2; the stub accepts any non-empty payload.</summary>
    [Fact]
    public void OnDragOver_acceptsRealItem()
    {
        var (layout, slots, _) = FakeToolbar();
        var ctrl = ToolbarController.Bind(layout, new ClientObjectTable(),
            () => Array.Empty<PlayerDescriptionParser.ShortcutEntry>(),
            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));
    }

    /// <summary>HandleDropRelease is a logging stub (TS-33): it must not throw and must not
    /// mutate the target slot (no wire / no store yet).</summary>
    [Fact]
    public void HandleDropRelease_isInertStub()
    {
        var (layout, slots, _) = FakeToolbar();
        var ctrl = ToolbarController.Bind(layout, new ClientObjectTable(),
            () => Array.Empty<PlayerDescriptionParser.ShortcutEntry>(),
            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:

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:

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

    // ── 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).

    /// <inheritdoc/>
    public bool OnDragOver(UiItemList targetList, UiItemSlot targetCell, ItemDragPayload payload)
        => payload.ObjId != 0;

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

| 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
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) <noreply@anthropic.com>"

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.