acdream/docs/superpowers/specs/2026-06-20-d2b-toolbar-shortcut-drag-design.md
Erik 19f1b8b614 docs(D.5.3/B.2): toolbar shortcut drag interactivity design spec
From the user's visual-gate feedback + a retail decomp trace this session.
Retail's model is remove-on-lift / place-on-drop / no-restore (confirmed:
RecvNotice_ItemListBeginDrag 0x004bd930 → RemoveShortcut 0x004bd450 at lift;
HandleDropRelease 0x004be7c0 places + bumps displaced → source; off-bar drop
leaves it removed). This unifies the user's points 3/4/5 into one mechanism.

Verified the drop sprite against client_portal.dat: 0x060011F9 = green RING
(inventory), 0x060011FA = green CROSS (toolbar) — user was right.

Scope (toolbar-internal; drag-from-inventory is Stream C): spine extensions
(lift hook on IItemListDragHandler; ghost snapshotted at BeginDrag at full
opacity so it survives the source emptying; FinishDrag delivers a drop only on
a real hit, off-bar = nothing); a mutable 18-slot ShortcutStore; ToolbarController
as the live handler (OnDragLift removes; HandleDropRelease places + bumps); the
AddShortcut 0x019C / RemoveShortcut 0x019D wire (fix the BuildAddShortcut param
names; SendAddShortcut/SendRemoveShortcut on WorldSession); green-cross overlay.
Amends AP-47 (ghost now full opacity), retires TS-33 (real wire replaces the stub).

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

21 KiB
Raw Blame History

D.2b toolbar shortcut drag interactivity (Stream B.2) — design

Date: 2026-06-20 Phase: D.2b retail-UI → D.5 core panels. Stream B.2 of the 2026-06-18 handoff — make the toolbar shortcut drag functional (reorder / remove) and retail-faithful, on top of the B.1 spine (9d48346..acdefc2, shipped + visually confirmed this session). Branch: claude/hopeful-maxwell-214a12. Driver: user visual-gate feedback 2026-06-20 (6 points) + the retail research below.

Read alongside:

  • docs/research/2026-06-16-action-bar-toolbar-deep-dive.md §5 (HandleDropRelease/RemoveShortcut/wire).
  • docs/research/2026-06-16-ui-item-slot-icon-dragdrop-spine-deep-dive.md §5 (the cell drag chain).
  • docs/superpowers/specs/2026-06-20-d2b-drag-drop-spine-design.md (B.1 — what this extends).
  • claude-memory/project_d2b_retail_ui.md.

1. Goal & non-goals

Goal. A player drags a hotbar shortcut, sees the item icon lift off the slot (slot empties) and follow the cursor at full opacity with a green-cross drop indicator; dropping on another slot moves it there (reordering, bumping any occupant back to the source slot); dropping anywhere else removes it from the bar. All changes are sent to the server (AddShortcut 0x019C / RemoveShortcut 0x019D) so they persist. This realizes the user's 6 visual-gate points as retail's single remove-on-lift / place-on-drop / no-restore mechanism.

In scope:

  1. Spine extensions (small, to the B.1 toolkit): a drag-LIFT hook to the source's handler; full-opacity ghost snapshotted at drag-begin (survives the source emptying); FinishDrag delivers a drop only when released on a real element (off-bar = no drop fires).
  2. ShortcutStore — a mutable 18-slot model (port of retail ShortCutManager::shortCuts_[18]).
  3. ToolbarController as the live drag handlerOnDragLift removes; HandleDropRelease places + bumps displaced → source; both drive the store, the wire, and a re-Populate().
  4. The wire — fix BuildAddShortcut param names; add WorldSession.SendAddShortcut / SendRemoveShortcut.
  5. Green-cross accept overlay (0x060011FA) on toolbar cells (verified against the dat).

Out of scope (later streams):

  • Drag FROM inventory onto the bar (the flags & 0xE == 0 "fresh-from-inventory" branch) — needs the inventory window as a drag source (Stream C). This spec is reorder-within-bar + remove only (the flags & 4 branch).
  • Spell shortcuts (the SpellId|Layer payload) — items only (spellId=layer=0); the store carries a spell field for forward-compat but the toolbar binds item guids.
  • Server-pushed shortcut mutations after login (the store is client-authoritative post-Load).
  • The selected-object mana meter / stack-split UI (issue #141 deferred bits).

2. Retail grounding (the model — CONFIRMED by research 2026-06-20)

Retail's toolbar drag is remove-on-lift, place-on-drop, no-restore (decomp trace, this session):

  • On drag-begin (UIElement_ItemList::ItemList_BeginDrag 0x004e32d0 → broadcasts msg 0x21gmToolbarUI::RecvNotice_ItemListBeginDrag 0x004bd930): PrepareDragIcon makes the full icon the cursor ghost (m_dragIcon), then gmToolbarUI::RemoveShortcut (0x004bd450) removes the shortcut: ItemList_Flush → cell → empty state 0x1000001c, Event_RemoveShortCut sends 0x019D, and m_lastShortcutNumDragged = the source slot. The slot is now empty; the item is "in hand."
  • On mouse-up over a slot (UIElementManager::StopDragandDrop 0x00459810 → success → gmToolbarUI::HandleDropRelease 0x004be7c0): within-bar reorder branch (flags & 4): RemoveShortcutInSlotNum(target) (evict the occupant, get its objId) → AddShortcut(draggedObjId, target) (0x019C); if an item was displaced and the source slot is free, AddShortcut(displaced, m_lastShortcutNumDragged) — the bumped item lands in the vacated source slot. (deep-dive §5.)
  • On mouse-up over nothing (m_pElementLastDragCursorOver == null → success=0): no HandleDropRelease, no re-add — the shortcut stays gone (already removed + 0x019D sent at lift).
  • No cancel/restore path. gmToolbarUI has no end-drag/restore vtable entry. Lifted-then-not- landed = permanently removed (server already told).
  • Drop indicator is a per-slot OVERLAY sprite on m_elem_Icon_DragAccept (0x1000045A), NOT a cursor change (UpdateCursorState is combat/target-only). The toolbar accept sprite is the green cross 0x060011FA (verified by exporting the 0x060011F7..FA family from client_portal.dat: F7=green move-arrow, F8=red ∅ reject, F9=green ring [inventory], FA=green cross [toolbar]); reject = 0x060011F8.

Wire (ShortCutData, CONFIRMED 3 refs — deep-dive §131-145):

  • AddShortCut 0x019C body after the 12-byte GameAction envelope: Index(u32), ObjectId(u32), SpellId(u16), Layer(u16) (item → ObjectId=guid, SpellId=Layer=0).
  • RemoveShortCut 0x019D body: Index(u32).
  • ACE handles the remove-then-add reorder pattern (Player_Character.cs:254).

3. Architecture — components & boundaries

 press+move on OCCUPIED slot ─► UiRoot.BeginDrag
   │  snapshot ghost (full icon) BEFORE the lift clears the source cell
   │  fire DragBegin on the source cell
   ▼
 UiItemSlot.OnEvent(DragBegin) ─► FindList().DragHandler.OnDragLift(list, cell, payload)
   ▼
 ToolbarController.OnDragLift ─► ShortcutStore.Remove(srcSlot); SendRemoveShortcut(srcSlot); Populate()
                                  (source slot now empty; ghost still shows the snapshot)
 drag-over target slot ─► UiItemSlot DragEnter ─► handler.OnDragOver → green-cross accept overlay (FA)
 release ─► UiRoot.FinishDrag:
   ├─ over a slot  ─► that cell.OnEvent(DropReleased) ─► handler.HandleDropRelease(target, payload)
   │                    ShortcutStore place + bump displaced→srcSlot; SendAddShortcut×N; Populate()
   └─ over nothing ─► no DropReleased delivered → lift's removal stands (item gone)

Boundaries: UiRoot/UiElement/UiItemSlot/UiItemList stay item-agnostic — they gain a lift hook and a ghost snapshot, nothing item-specific. ShortcutStore is pure logic in AcDream.Core (testable, no GL). The wire lives in AcDream.Core.Net. ToolbarController (App) orchestrates. Honors structure Rules 1/2/3/6.


4. New & changed types (precise)

4.1 Spine — IItemListDragHandler gains the lift hook (UI/IItemListDragHandler.cs)

public interface IItemListDragHandler
{
    /// <summary>The drag STARTED from a cell in this list — retail's
    /// RecvNotice_ItemListBeginDrag → RemoveShortcut (decomp 0x004bd930/0x004bd450): the handler
    /// removes the lifted item from its model + wire, so the source slot empties immediately. The
    /// item is "in hand" until HandleDropRelease (place) or the drag ends off-target (stays removed).</summary>
    void OnDragLift(UiItemList sourceList, UiItemSlot sourceCell, ItemDragPayload payload);

    bool OnDragOver(UiItemList targetList, UiItemSlot targetCell, ItemDragPayload payload);
    void HandleDropRelease(UiItemList targetList, UiItemSlot targetCell, ItemDragPayload payload);
}

4.2 Spine — UiItemSlot (UI/UiItemSlot.cs)

  • OnEvent DragBegin case changes from return true (no-op) to: notify the list's handler so it can lift:
    case UiEventType.DragBegin:
        if (FindList() is { DragHandler: { } h } list && e.Payload is ItemDragPayload p)
            h.OnDragLift(list, this, p);
        return true;
    
  • DropReleased case: drop the e.Data0 == 1 gate — reaching the cell means a real slot was hit (FinishDrag only delivers on a hit). The handler is authoritative (place; drop-on-self re-adds):
    case UiEventType.DropReleased:
        _dragAccept = DragAcceptState.None;
        if (FindList() is { DragHandler: { } dh } dl && e.Payload is ItemDragPayload dp)
            dh.HandleDropRelease(dl, this, dp);
        return true;
    
    (DragEnter/DragOver overlay handling unchanged from B.1.)

4.3 Spine — UiRoot (UI/UiRoot.cs)

  • Add private (uint tex, int w, int h)? _dragGhost;. In BeginDrag, snapshot the ghost before firing DragBegin (the lift will empty the source cell, so a live re-read would vanish):
    private void BeginDrag(UiElement source)
    {
        var payload = source.GetDragPayload();
        if (payload is null) { _dragCandidate = false; return; }
        DragSource  = source;
        DragPayload = payload;
        _dragGhost  = source.GetDragGhost();      // snapshot NOW — survives the source emptying
        var e = new UiEvent(source.EventId, source, UiEventType.DragBegin, Payload: payload);
        source.OnEvent(in e);                      // → cell → handler.OnDragLift (clears the source slot)
    }
    
  • DrawDragGhost uses the snapshot, not a live read; GhostAlpha1.0 (full opacity, retail m_dragIcon):
    private const float GhostAlpha = 1.0f;
    private void DrawDragGhost(UiRenderContext ctx)
    {
        if (_dragGhost 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));
    }
    
  • FinishDrag delivers a drop ONLY to a real hit element; off-bar (null hit) fires nothing; clear the ghost:
    private void FinishDrag(int x, int y)
    {
        var (t, lx, ly) = HitTestTopDown(x, y);
        if (t is not null)
        {
            var e = new UiEvent(DragSource!.EventId, t, UiEventType.DropReleased,
                                Data1: (int)lx, Data2: (int)ly, Payload: DragPayload);
            t.OnEvent(in e);   // the hit cell's handler places; a non-item target ignores it → item stays removed
        }
        // else: dropped off any element — no drop fires; the lift's removal stands (retail).
        DragSource = null; DragPayload = null; _dragGhost = null; _lastDragHoverTarget = null;
    }
    
    (GetDragGhost() on UiElement/UiItemSlot stays as-is — BeginDrag calls it once.)

4.4 NEW — ShortcutStore (src/AcDream.Core/Items/ShortcutStore.cs)

Port of retail ShortCutManager::shortCuts_[18] (acclient.h:36492). Pure logic.

public sealed class ShortcutStore
{
    public const int SlotCount = 18;
    private readonly uint[] _objIds = new uint[SlotCount];   // 0 = empty

    /// <summary>Replace all slots from the login PlayerDescription shortcut list.</summary>
    public void Load(IReadOnlyList<PlayerDescriptionParser.ShortcutEntry> entries)
    {
        System.Array.Clear(_objIds);
        foreach (var e in entries)
            if (e.Index < SlotCount && e.ObjectGuid != 0) _objIds[e.Index] = e.ObjectGuid;
    }

    public uint Get(int slot) => (uint)slot < SlotCount ? _objIds[slot] : 0u;
    public bool IsEmpty(int slot) => Get(slot) == 0u;
    public void Set(int slot, uint objId) { if ((uint)slot < SlotCount) _objIds[slot] = objId; }
    public void Remove(int slot) { if ((uint)slot < SlotCount) _objIds[slot] = 0u; }
}

4.5 Wire — InventoryActions.BuildAddShortcut rename (Core.Net/Messages/InventoryActions.cs)

Byte layout is already correct; FIX the misleading param names (handoff §B.2; deep-dive §131-145):

/// <summary>Pin an item/spell to a quickbar slot. ShortCutData = Index(u32),ObjectId(u32),
/// SpellId(u16),Layer(u16). For an item: objectGuid + spellId=layer=0.</summary>
public static byte[] BuildAddShortcut(uint seq, uint index, uint objectGuid, ushort spellId, ushort layer)
{
    byte[] body = new byte[24];
    BinaryPrimitives.WriteUInt32LittleEndian(body,            GameActionEnvelope);
    BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(4),  seq);
    BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(8),  AddShortcutOpcode);   // 0x019C
    BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(12), index);
    BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(16), objectGuid);
    BinaryPrimitives.WriteUInt16LittleEndian(body.AsSpan(20), spellId);
    BinaryPrimitives.WriteUInt16LittleEndian(body.AsSpan(22), layer);
    return body;
}

(BuildRemoveShortcut(seq, slotIndex) is correct — unchanged.) Any existing caller of the old 4-arg BuildAddShortcut must be updated; grep first (likely none wired today).

4.6 Wire — WorldSession sends (Core.Net/WorldSession.cs, mirror SendChangeCombatMode :1134)

public void SendAddShortcut(uint index, uint objectGuid, ushort spellId = 0, ushort layer = 0)
{
    uint seq = NextGameActionSequence();
    SendGameAction(InventoryActions.BuildAddShortcut(seq, index, objectGuid, spellId, layer));
}
public void SendRemoveShortcut(uint index)
{
    uint seq = NextGameActionSequence();
    SendGameAction(InventoryActions.BuildRemoveShortcut(seq, index));
}

4.7 ToolbarController — the live handler (UI/Layout/ToolbarController.cs)

  • Holds a ShortcutStore _store. Populate() reads _store (18 slots → repo.Get(objId) → icon) INSTEAD of the read-only _shortcuts() provider. Lazy-load-once: the PlayerDescription shortcut list arrives AFTER Bind (at login), so load the store the first time _shortcuts() is non-empty, then treat it as authoritative:
    // top of Populate(), before rendering:
    if (!_storeLoaded && _shortcuts().Count > 0) { _store.Load(_shortcuts()); _storeLoaded = true; }
    
    Bind's initial Populate (pre-login) sees an empty list and skips the load; the existing repo.ObjectAdded → Populate path (shortcut items arriving via CreateObject, by which time the PD shortcut list is set) triggers the one-time load. After load, drag ops mutate _store directly and it is never reloaded within the session (a relog = a fresh process → _storeLoaded resets). No GameWindow change is needed for loading.
  • IsShortcutGuid(guid) (the repo-event gate) checks _store (any slot holds guid) rather than _shortcuts(), so an item dragged ONTO the bar (a new guid) still gets its later updates rendered.
  • Each toolbar cell: DragAcceptSprite = 0x060011FAu (green cross). (DragRejectSprite stays 0x060011F8.)
  • Inject two Actions for the wire (so the controller stays testable without a live session): Action<uint,uint> sendAdd = (index, objId) => session.SendAddShortcut(index, objId), Action<uint> sendRemove = index => session.SendRemoveShortcut(index). Null in tests.
  • OnDragLift(sourceList, sourceCell, payload) — retail RemoveShortcut:
    _store.Remove(payload.SourceSlot);
    _sendRemove?.Invoke((uint)payload.SourceSlot);
    Populate();
    
  • HandleDropRelease(targetList, targetCell, payload) — retail reorder branch (deep-dive §5):
    int target = targetCell.SlotIndex;
    uint evicted = _store.Get(target);                       // RemoveShortcutInSlotNum
    if (evicted != 0) { _store.Remove(target); _sendRemove?.Invoke((uint)target); }
    _store.Set(target, payload.ObjId); _sendAdd?.Invoke((uint)target, payload.ObjId);   // AddShortcut
    if (evicted != 0 && evicted != payload.ObjId && _store.IsEmpty(payload.SourceSlot))
    {                                                        // bump displaced → vacated source slot
        _store.Set(payload.SourceSlot, evicted);
        _sendAdd?.Invoke((uint)payload.SourceSlot, evicted);
    }
    Populate();
    
  • OnDragOver unchanged from B.1 (accept any real item → green-cross overlay).

5. Data flow — frame by frame (reorder slot 3 → occupied slot 5)

  1. Press+move on slot 3 (objId A). BeginDrag: snapshot ghost = A's icon; fire DragBegin.
  2. Cell 3 → OnDragLift: _store.Remove(3) + SendRemoveShortcut(3) (0x019D) + Populate() → slot 3 shows empty. Ghost (A's full icon) follows the cursor.
  3. Drag over slot 5 (objId B) → green-cross accept overlay on slot 5.
  4. Release on slot 5 → FinishDrag delivers DropReleased to cell 5 → HandleDropRelease: evicted=B; Remove(5)+SendRemoveShortcut(5); Set(5,A)+SendAddShortcut(5,A); source slot 3 is empty → Set(3,B)+SendAddShortcut(3,B); Populate() → slot 5 = A, slot 3 = B (swapped).
  5. (Off-bar variant) release over the 3D world → no DropReleased → A stays removed → slot 3 empty, A gone from the bar (server already told at step 2).

6. Edge cases

  • Drop on self (slot 3 → slot 3): at lift slot 3 emptied; at drop evicted=0, Set(3,A) + SendAddShortcut(3,A) → A back at 3. Net no-op (2 wire msgs — retail does the same).
  • Drop on a non-item element (chrome / another window): FinishDrag delivers DropReleased to it; its OnEvent doesn't handle it → no place → A stays removed (off-bar). Correct.
  • Ghost lifetime: snapshotted at BeginDrag, cleared in FinishDrag — independent of the source cell emptying. A cancelled drag (mouse-up off-bar) still clears _dragGhost.
  • Empty-slot lift: an empty slot is not a drag source (IsDragSource => ItemId != 0 from B.1) → no drag arms → no lift. (And empty slots still move the window — IA-12, from B.1.)
  • Spell shortcuts in the loaded list (ObjectGuid == 0, a spell): ShortcutStore.Load skips them (item-only this stream); they neither render nor drag. Forward-compat: the store could hold spell entries later.

7. Divergence register (update in the implementation commits)

  • AP-47 (amend): the ghost is now full opacity (retail m_dragIcon) — retire the GhostAlpha=0.6 reduced-alpha approximation. The row's REMAINING approximation: the ghost reuses the full composited m_pIcon (which includes the type-default underlay) rather than retail's dedicated underlay-less m_pDragIcon. Reword AP-47 to drop the alpha claim, keep the underlay note.
  • TS-33 (retire): the toolbar HandleDropRelease logging stub is replaced by the real store-mutation + AddShortcut/RemoveShortcut wire. Delete the row.
  • No new row for remove-on-lift / off-bar-remove — it is the faithful retail mechanism (it reduces divergence). The store being client-authoritative post-login matches retail's ShortCutManager (client owns the array; server is notified via the wire).

8. Testing (conformance)

Core tests (tests/AcDream.Core.Tests/):

  1. ShortcutStore: Load maps Index→ObjId (skips ObjectGuid==0 + out-of-range); Set/Remove/ Get/IsEmpty; bounds-safe.
  2. BuildAddShortcut: 24-byte body, envelope+seq+0x019C+index+objectGuid+spellId(u16)+layer(u16) at the right offsets (golden bytes). BuildRemoveShortcut: 16 bytes, index at +12.

App tests (tests/AcDream.App.Tests/): 3. Spine: BeginDrag snapshots the ghost so it survives the source cell being cleared mid-drag (clear the source cell after begin; assert the drawn ghost source is still the snapshot — assert via a testable UiRoot accessor for _dragGhost). 4. Spine: FinishDrag over null delivers NO DropReleased (a spy cell/handler isn't called); over a real cell, delivers it (drop the Data0 gate — drop-on-self now dispatches). 5. ToolbarController.OnDragLift: removes the source slot from the store, invokes sendRemove(src), and the source cell empties after Populate. 6. ToolbarController.HandleDropRelease reorder onto an OCCUPIED target: store ends with dragged@target

  • displaced@source; sendRemove+sendAdd called with the retail sequence/args. Onto an EMPTY target: dragged@target, source stays empty, displaced-branch not taken. Drop-on-self: re-adds to source.
  1. Update the B.1 tests changed by §4.2/§4.3: DropReleased_notAccepted_skipsDispatchDropReleased_alwaysDispatchesToHandler (the cell dispatches whenever it receives a DropReleased); the full-chain drag tests still pass (a completed reorder fires HandleDropRelease, not Clicked).

9. Acceptance criteria

  • dotnet build + dotnet test green (Core + App).
  • AP-47 reworded (no alpha); TS-33 deleted; wire builder renamed; no stray old-signature callers.
  • UiRoot/UiElement/UiItemSlot remain item-agnostic (the lift hook is generic; only ToolbarController knows shortcuts).
  • Visual (user), ACDREAM_RETAIL_UI=1, in-world: lift a hotbar item → slot empties immediately, full-opacity icon follows the cursor with a green cross over hovered slots; drop on another slot → it moves there (occupant bumps to the source slot); drop off-bar → it's removed; a single click still uses the item. Changes persist after relog (the wire reached ACE).
  • Memory crib + roadmap/ISSUES updated; #141's note refreshed if relevant.

10. Subagent task slices

  1. Core: ShortcutStore + wireShortcutStore.cs + BuildAddShortcut rename + WorldSession.SendAddShortcut/SendRemoveShortcut + Core tests (§8.1-2).
  2. Spine extensionsIItemListDragHandler.OnDragLift; UiItemSlot DragBegin→lift + DropReleased ungate; UiRoot ghost snapshot + full opacity + FinishDrag deliver-on-hit-only; update the B.1 tests (§8.3-4, §8.7).
  3. ToolbarController handler_store + Load + Populate-from-store; green-cross sprite; OnDragLift/HandleDropRelease; wire Actions; GameWindow injects the session sends; controller tests (§8.5-6). Update AP-47, delete TS-33.

(1 and 2 are independent; 3 depends on both.)