# Toolbar shortcut drag interactivity (Stream B.2) 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:** Make toolbar shortcut drag functional + retail-faithful — lift empties the source slot, the full-opacity icon follows the cursor with a green-cross indicator, dropping on another slot reorders (bumping the occupant to the source slot), dropping off-bar removes it, and all changes go to ACE via `AddShortcut 0x019C`/`RemoveShortcut 0x019D`. **Architecture:** Retail's **remove-on-lift / place-on-drop / no-restore** model. Three slices: (1) Core `ShortcutStore` + the wire builders/senders; (2) small spine extensions (a lift hook, a ghost snapshot, drop-on-hit-only); (3) `ToolbarController` as the live drag handler driving the store + wire. Spec: `docs/superpowers/specs/2026-06-20-d2b-toolbar-shortcut-drag-design.md`. **Tech Stack:** C# / .NET 10, xUnit. Core logic in `AcDream.Core`, wire in `AcDream.Core.Net`, UI toolkit in `AcDream.App/UI`. `InternalsVisibleTo("AcDream.App.Tests")` + `("AcDream.Core.Tests")` are set. --- ## File Structure **Create:** - `src/AcDream.Core/Items/ShortcutStore.cs` — mutable 18-slot shortcut model. - `tests/AcDream.Core.Tests/Items/ShortcutStoreTests.cs` **Modify:** - `src/AcDream.Core.Net/Messages/InventoryActions.cs` — `BuildAddShortcut` signature/field fix. - `src/AcDream.Core.Net/WorldSession.cs` — `SendAddShortcut`/`SendRemoveShortcut`. - `tests/AcDream.Core.Net.Tests/Messages/InventoryActionsTests.cs` — update `BuildAddShortcut_ThreeFields`. - `src/AcDream.App/UI/IItemListDragHandler.cs` — add `OnDragLift`. - `src/AcDream.App/UI/UiItemSlot.cs` — `DragBegin`→lift; `DropReleased` ungate. - `src/AcDream.App/UI/UiRoot.cs` — `_dragGhost` snapshot; `GhostAlpha=1`; `FinishDrag` deliver-on-hit-only; test accessor. - `tests/AcDream.App.Tests/UI/DragDropSpineTests.cs` — update B.1 tests + add lift/ghost/off-bar. - `src/AcDream.App/UI/Layout/ToolbarController.cs` — store + handler + green cross + wire actions. - `src/AcDream.App/Rendering/GameWindow.cs` — inject the session sends at the toolbar Bind. - `tests/AcDream.App.Tests/UI/Layout/ToolbarControllerTests.cs` — lift/drop/store tests. - `docs/architecture/retail-divergence-register.md` — reword AP-47, delete TS-33. **Commands** (from repo root): - Build all: `dotnet build AcDream.slnx -c Debug` - Core tests: `dotnet test tests/AcDream.Core.Tests/AcDream.Core.Tests.csproj` - Net tests: `dotnet test tests/AcDream.Core.Net.Tests/AcDream.Core.Net.Tests.csproj` - App tests: `dotnet test tests/AcDream.App.Tests/AcDream.App.Tests.csproj` **Every commit ends with** `Co-Authored-By: Claude Opus 4.8 (1M context) `. --- ## Task 1: Core — ShortcutStore + the AddShortcut/RemoveShortcut wire **Files:** - Create: `src/AcDream.Core/Items/ShortcutStore.cs`, `tests/AcDream.Core.Tests/Items/ShortcutStoreTests.cs` - Modify: `src/AcDream.Core.Net/Messages/InventoryActions.cs`, `src/AcDream.Core.Net/WorldSession.cs`, `tests/AcDream.Core.Net.Tests/Messages/InventoryActionsTests.cs` - [ ] **Step 1: Write the failing ShortcutStore test** Create `tests/AcDream.Core.Tests/Items/ShortcutStoreTests.cs`: ```csharp using System.Collections.Generic; using AcDream.Core.Items; using AcDream.Core.Net.Messages; using Xunit; namespace AcDream.Core.Tests.Items; public class ShortcutStoreTests { [Fact] public void Load_mapsIndexToObjId_skipsEmptyAndOutOfRange() { var store = new ShortcutStore(); var entries = new List { new(Index: 0, ObjectGuid: 0x5001u, SpellId: 0, Layer: 0), new(Index: 3, ObjectGuid: 0x5002u, SpellId: 0, Layer: 0), new(Index: 5, ObjectGuid: 0u, SpellId: 7, Layer: 1), // spell-only → skipped (item store) new(Index: 99, ObjectGuid: 0x5003u, SpellId: 0, Layer: 0), // out of range → skipped }; store.Load(entries); Assert.Equal(0x5001u, store.Get(0)); Assert.Equal(0x5002u, store.Get(3)); Assert.Equal(0u, store.Get(5)); Assert.True(store.IsEmpty(1)); } [Fact] public void SetRemoveGet_roundtrip_andBoundsSafe() { var store = new ShortcutStore(); store.Set(4, 0xABCDu); Assert.Equal(0xABCDu, store.Get(4)); Assert.False(store.IsEmpty(4)); store.Remove(4); Assert.True(store.IsEmpty(4)); // bounds-safe: out-of-range Get/Set/Remove never throw Assert.Equal(0u, store.Get(-1)); Assert.Equal(0u, store.Get(18)); store.Set(18, 1u); // no-op, no throw store.Remove(-1); // no-op, no throw } } ``` - [ ] **Step 2: Run to verify it fails** Run: `dotnet test tests/AcDream.Core.Tests/AcDream.Core.Tests.csproj --filter "FullyQualifiedName~ShortcutStore"` Expected: FAIL to compile — `ShortcutStore` not defined. - [ ] **Step 3: Create ShortcutStore** Create `src/AcDream.Core/Items/ShortcutStore.cs`: ```csharp using System.Collections.Generic; using AcDream.Core.Net.Messages; namespace AcDream.Core.Items; /// /// Mutable client-side model of the 18 toolbar shortcut slots — port of retail /// ShortCutManager::shortCuts_[18] (acclient.h:36492). Holds the bound object guid per /// slot (0 = empty). Loaded from the login PlayerDescription, then mutated by drag-drop /// (lift removes, drop places); the server is notified via AddShortcut/RemoveShortcut so the /// store stays the client's source of truth within a session (retail: client owns the array). /// Item shortcuts only — spell shortcuts (ObjectGuid 0) are skipped on Load. /// public sealed class ShortcutStore { public const int SlotCount = 18; private readonly uint[] _objIds = new uint[SlotCount]; /// Replace all slots from the login PlayerDescription shortcut list (item entries only). public void Load(IReadOnlyList entries) { System.Array.Clear(_objIds); foreach (var e in entries) if (e.Index < SlotCount && e.ObjectGuid != 0) _objIds[(int)e.Index] = e.ObjectGuid; } /// Bound object guid at , or 0 (empty / out of range). 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; } } ``` (`PlayerDescriptionParser.ShortcutEntry` has `uint Index, uint ObjectGuid, ushort SpellId, ushort Layer`. Verify the field types when you open the file; if `Index` is `uint`, the `e.Index < SlotCount` compare and the `(int)e.Index` cast as written are correct.) - [ ] **Step 4: Run to verify it passes** Run: `dotnet test tests/AcDream.Core.Tests/AcDream.Core.Tests.csproj --filter "FullyQualifiedName~ShortcutStore"` Expected: PASS (2 tests). - [ ] **Step 5: Write the failing wire test (new BuildAddShortcut signature)** Replace the existing `BuildAddShortcut_ThreeFields` test in `tests/AcDream.Core.Net.Tests/Messages/InventoryActionsTests.cs` with: ```csharp [Fact] public void BuildAddShortcut_ItemShortcut_FieldLayout() { // ShortCutData = Index(u32), ObjectId(u32), SpellId(u16), Layer(u16). Item → spell/layer 0. byte[] body = InventoryActions.BuildAddShortcut(seq: 1, index: 0, objectGuid: 0x3E1, spellId: 0, layer: 0); Assert.Equal(24, body.Length); Assert.Equal(InventoryActions.AddShortcutOpcode, System.Buffers.Binary.BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(8))); Assert.Equal(0u, System.Buffers.Binary.BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(12))); // index Assert.Equal(0x3E1u, System.Buffers.Binary.BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(16))); // objectGuid Assert.Equal((ushort)0, System.Buffers.Binary.BinaryPrimitives.ReadUInt16LittleEndian(body.AsSpan(20))); // spellId Assert.Equal((ushort)0, System.Buffers.Binary.BinaryPrimitives.ReadUInt16LittleEndian(body.AsSpan(22))); // layer } [Fact] public void BuildAddShortcut_SpellShortcut_PacksSpellAndLayerAsU16s() { byte[] body = InventoryActions.BuildAddShortcut(seq: 1, index: 2, objectGuid: 0, spellId: 0x1234, layer: 3); Assert.Equal(0x1234, System.Buffers.Binary.BinaryPrimitives.ReadUInt16LittleEndian(body.AsSpan(20))); Assert.Equal(3, System.Buffers.Binary.BinaryPrimitives.ReadUInt16LittleEndian(body.AsSpan(22))); } ``` (The file already has `using System.Buffers.Binary;` per the other tests — if so, use the short `BinaryPrimitives.` form to match; the fully-qualified form above is safe regardless.) - [ ] **Step 6: Run to verify it fails** Run: `dotnet test tests/AcDream.Core.Net.Tests/AcDream.Core.Net.Tests.csproj --filter "FullyQualifiedName~BuildAddShortcut"` Expected: FAIL to compile — `BuildAddShortcut` has no `(seq, index, objectGuid, spellId, layer)` overload (old signature is `(seq, slotIndex, objectType, targetId)`). - [ ] **Step 7: Fix BuildAddShortcut signature + field packing** In `src/AcDream.Core.Net/Messages/InventoryActions.cs`, replace the `BuildAddShortcut` method (the `(uint seq, uint slotIndex, uint objectType, uint targetId)` one) with: ```csharp /// Pin an item/spell to a quickbar slot. ShortCutData = Index(u32), ObjectId(u32), /// SpellId(u16), Layer(u16) — CONFIRMED across ACE/Chorizite/holtburger (action-bar deep-dive /// §131-145). For an ITEM: objectGuid = item guid, spellId = layer = 0. 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); 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; } ``` - [ ] **Step 8: Add the WorldSession send wrappers** In `src/AcDream.Core.Net/WorldSession.cs`, after `SendChangeCombatMode` (~line 1141), add: ```csharp /// Send AddShortcut (0x019C) — pin an item to toolbar slot . /// Retail: CM_Character::Event_AddShortCut. Mirrors the SendChangeCombatMode pattern. public void SendAddShortcut(uint index, uint objectGuid, ushort spellId = 0, ushort layer = 0) { uint seq = NextGameActionSequence(); SendGameAction(InventoryActions.BuildAddShortcut(seq, index, objectGuid, spellId, layer)); } /// Send RemoveShortcut (0x019D) — clear toolbar slot . /// Retail: CM_Character::Event_RemoveShortCut. public void SendRemoveShortcut(uint index) { uint seq = NextGameActionSequence(); SendGameAction(InventoryActions.BuildRemoveShortcut(seq, index)); } ``` (Confirm `InventoryActions` is in scope — `WorldSession` already calls other `*Actions.Build*` builders; add a `using AcDream.Core.Net.Messages;` only if not already present.) - [ ] **Step 9: Run to verify all Core/Net tests pass** Run: `dotnet test tests/AcDream.Core.Net.Tests/AcDream.Core.Net.Tests.csproj --filter "FullyQualifiedName~InventoryActions"` then `dotnet build src/AcDream.Core.Net/AcDream.Core.Net.csproj -c Debug` Expected: PASS; build green (no stale callers of the old 4-arg `BuildAddShortcut` — there were none besides the test). - [ ] **Step 10: Commit** ```bash git add src/AcDream.Core/Items/ShortcutStore.cs tests/AcDream.Core.Tests/Items/ShortcutStoreTests.cs src/AcDream.Core.Net/Messages/InventoryActions.cs src/AcDream.Core.Net/WorldSession.cs tests/AcDream.Core.Net.Tests/Messages/InventoryActionsTests.cs git commit -m "feat(core): D.5.3/B.2 — ShortcutStore + AddShortcut/RemoveShortcut wire + senders" -m "Co-Authored-By: Claude Opus 4.8 (1M context) " ``` --- ## Task 2: Spine extensions — lift hook + ghost snapshot + drop-on-hit-only **Files:** - Modify: `src/AcDream.App/UI/IItemListDragHandler.cs`, `src/AcDream.App/UI/UiItemSlot.cs`, `src/AcDream.App/UI/UiRoot.cs` - Test: `tests/AcDream.App.Tests/UI/DragDropSpineTests.cs` - [ ] **Step 1: Write/adjust the failing tests** in `tests/AcDream.App.Tests/UI/DragDropSpineTests.cs` First, the `SpyHandler` (nested in `DragDropSpineTests`) needs to record `OnDragLift`. UPDATE the `SpyHandler` class to add: ```csharp public (UiItemList list, UiItemSlot cell, ItemDragPayload payload)? LastLift; public void OnDragLift(UiItemList list, UiItemSlot cell, ItemDragPayload p) { LastLift = (list, cell, p); } ``` Then REPLACE the existing `DropReleased_notAccepted_skipsDispatch` test (its premise — Data0==0 skips — is gone under the retail model) with: ```csharp [Fact] public void DropReleased_dispatchesToHandler_regardlessOfData0() { // Retail model: reaching the cell means a real slot was hit (FinishDrag only delivers on a // hit), so the handler is authoritative — it dispatches whether or not Data0 is set. var (list, cell, h) = ListWithHandler(); cell.OnEvent(new UiEvent(0u, cell, UiEventType.DropReleased, Data0: 0, Payload: SomePayload())); Assert.NotNull(h.LastDrop); } ``` Then ADD these new tests inside the class: ```csharp [Fact] public void DragBegin_callsHandlerOnDragLift() { var (list, cell, h) = ListWithHandler(); var p = SomePayload(); cell.OnEvent(new UiEvent(0u, cell, UiEventType.DragBegin, Payload: p)); Assert.NotNull(h.LastLift); Assert.Same(list, h.LastLift!.Value.list); Assert.Same(cell, h.LastLift.Value.cell); Assert.Same(p, h.LastLift.Value.payload); } [Fact] public void Ghost_isSnapshottedAtBeginDrag_survivesSourceCellClearing() { // The lift empties the source cell; the ghost must persist (snapshot at BeginDrag), not // re-read the now-empty cell. Drive the full UiRoot chain, then clear the source cell. var (root, _, cell) = RootWithBoundSlot(0x5001u); // icon tex 0x99 root.OnMouseDown(UiMouseButton.Left, 10, 10); root.OnMouseMove(20, 10); // BeginDrag → snapshot ghost cell.Clear(); // simulate the lift emptying the source Assert.Equal((0x99u, 32, 32), root.DragGhostForTest); } [Fact] public void FinishDrag_overNothing_deliversNoDrop_butLiftStands() { // Drag from a slot with a handler, release over empty space → no HandleDropRelease (off-bar), // but OnDragLift already fired at begin (the lift). Requires the slot inside a list w/ handler. var root = new UiRoot { Width = 800, Height = 600 }; var list = new UiItemList(_ => (1u, 1, 1)) { Left = 0, Top = 0, Width = 32, Height = 32 }; list.Cell.Width = 32; list.Cell.Height = 32; list.Cell.SetItem(0x5001u, 0x99u); var h = new SpyHandler(); list.RegisterDragHandler(h); root.AddChild(list); root.OnMouseDown(UiMouseButton.Left, 10, 10); root.OnMouseMove(20, 10); // BeginDrag → OnDragLift root.OnMouseUp(UiMouseButton.Left, 600, 500); // release over empty space Assert.NotNull(h.LastLift); // lift happened Assert.Null(h.LastDrop); // no drop dispatched (off-bar) Assert.Null(root.DragSource); // cleaned up } ``` - [ ] **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 — `IItemListDragHandler.OnDragLift` not defined (SpyHandler doesn't satisfy the interface), `root.DragGhostForTest` not defined. - [ ] **Step 3: Add OnDragLift to the interface** In `src/AcDream.App/UI/IItemListDragHandler.cs`, add as the FIRST member of the interface: ```csharp /// 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). No restore on cancel. void OnDragLift(UiItemList sourceList, UiItemSlot sourceCell, ItemDragPayload payload); ``` - [ ] **Step 4: UiItemSlot — DragBegin fires the lift; DropReleased ungated** In `src/AcDream.App/UI/UiItemSlot.cs` `OnEvent`, change the `DragBegin` case from `return true;` to: ```csharp case UiEventType.DragBegin: // Notify the source list's handler so it can lift (remove + wire) — retail // RecvNotice_ItemListBeginDrag → RemoveShortcut. UiRoot snapshotted the ghost first. if (FindList() is { DragHandler: { } lh } liftList && e.Payload is ItemDragPayload lp) lh.OnDragLift(liftList, this, lp); return true; ``` And change the `DropReleased` case — drop the `e.Data0 == 1` gate (reaching the cell = a real slot was hit; the handler is authoritative): ```csharp case UiEventType.DropReleased: _dragAccept = DragAcceptState.None; if (FindList() is { DragHandler: { } dh } dl && e.Payload is ItemDragPayload dp) dh.HandleDropRelease(dl, this, dp); return true; ``` - [ ] **Step 5: UiRoot — snapshot the ghost, full opacity, deliver-on-hit-only** In `src/AcDream.App/UI/UiRoot.cs`: (a) Add the snapshot field near `DragPayload` (~line 73): `private (uint tex, int w, int h)? _dragGhost;` and a test accessor near it: `internal (uint tex, int w, int h)? DragGhostForTest => _dragGhost;` (b) In `BeginDrag`, snapshot the ghost BEFORE firing `DragBegin`: ```csharp 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 — the DragBegin handler may empty the source cell var e = new UiEvent(source.EventId, source, UiEventType.DragBegin, Payload: payload); source.OnEvent(in e); } ``` (c) Change `GhostAlpha` to `1.0f` and make `DrawDragGhost` use the snapshot: ```csharp private const float GhostAlpha = 1.0f; // retail m_dragIcon is the full icon, no fade 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)); } ``` (d) Replace `FinishDrag` to deliver only on a real hit + clear the ghost: ```csharp private void FinishDrag(int x, int y) { var (t, lx, ly) = HitTestTopDown(x, y); if (t is not null) { // Dropped on a real element — deliver DropReleased; the hit cell's handler places. // A non-item target's OnEvent ignores it, so an off-bar drop leaves the lift's removal. var e = new UiEvent(DragSource!.EventId, t, UiEventType.DropReleased, Data1: (int)lx, Data2: (int)ly, Payload: DragPayload); t.OnEvent(in e); } // else: dropped on nothing — no drop fires; the lift (drag-begin removal) stands (retail). DragSource = null; DragPayload = null; _dragGhost = null; _lastDragHoverTarget = null; } ``` - [ ] **Step 6: Run to verify it passes** Run: `dotnet test tests/AcDream.App.Tests/AcDream.App.Tests.csproj --filter "FullyQualifiedName~DragDropSpine"` Expected: PASS (the updated + new spine tests; the existing arming, use-vs-drag, overlay, and window-topology tests still pass — `CompletedDrag_doesNotFireUse` still holds because a completed drag takes the `FinishDrag` path before any Click). - [ ] **Step 7: Run the full app suite (no regressions)** Run: `dotnet test tests/AcDream.App.Tests/AcDream.App.Tests.csproj` Expected: all pass (the ToolbarController tests still pass — `ToolbarController` already implements the interface from B.1; you'll add the `OnDragLift` body in Task 3, but a default-throwing stub isn't present, so confirm: if the build fails because `ToolbarController` doesn't implement the new `OnDragLift` member, add a temporary `public void OnDragLift(...) {}` no-op in `ToolbarController` now and flesh it out in Task 3. Prefer to just do Task 3's `OnDragLift` body — but to keep Task 2 self-contained + green, a one-line empty `OnDragLift` is acceptable here and gets its real body in Task 3.) - [ ] **Step 8: Commit** ```bash git add src/AcDream.App/UI/IItemListDragHandler.cs src/AcDream.App/UI/UiItemSlot.cs src/AcDream.App/UI/UiRoot.cs tests/AcDream.App.Tests/UI/DragDropSpineTests.cs src/AcDream.App/UI/Layout/ToolbarController.cs git commit -m "feat(ui): D.5.3/B.2 — spine: drag-lift hook + ghost snapshot (full opacity) + drop-on-hit-only" -m "Co-Authored-By: Claude Opus 4.8 (1M context) " ``` --- ## Task 3: ToolbarController — live handler (store + reorder/remove + wire + green cross) **Files:** - Modify: `src/AcDream.App/UI/Layout/ToolbarController.cs`, `src/AcDream.App/Rendering/GameWindow.cs`, `docs/architecture/retail-divergence-register.md` - Test: `tests/AcDream.App.Tests/UI/Layout/ToolbarControllerTests.cs` - [ ] **Step 1: Write the failing tests** — append inside `ToolbarControllerTests` class ```csharp // ── B.2: live drag handler (store + reorder/remove + wire) ─────────────── private static (System.Collections.Generic.List<(uint i,uint g)> adds, System.Collections.Generic.List removes) NewSpies(out System.Action add, out System.Action rem) { var adds = new System.Collections.Generic.List<(uint,uint)>(); var removes = new System.Collections.Generic.List(); add = (i, g) => adds.Add((i, g)); rem = i => removes.Add(i); return (adds, removes); } [Fact] public void OnDragLift_removesSourceSlot_sendsRemove_emptiesCell() { var (layout, slots, _) = FakeToolbar(); var repo = new ClientObjectTable(); repo.AddOrUpdate(new ClientObject { ObjectId = 0x5001u, WeenieClassId = 1u, IconId = 0x06001234u }); var shortcuts = new System.Collections.Generic.List { new(Index: 3, ObjectGuid: 0x5001u, SpellId: 0, Layer: 0) }; var (adds, removes) = NewSpies(out var add, out var rem); var ctrl = ToolbarController.Bind(layout, repo, () => shortcuts, iconIds: (_,_,_,_,_) => 0x77u, useItem: _ => { }, sendAddShortcut: add, sendRemoveShortcut: rem); Assert.Equal(0x5001u, slots[Row1[3]].Cell.ItemId); // bound at slot 3 var payload = new ItemDragPayload(0x5001u, ItemDragSource.ShortcutBar, 3, slots[Row1[3]].Cell); ctrl.OnDragLift(slots[Row1[3]], slots[Row1[3]].Cell, payload); Assert.Contains(3u, removes); // RemoveShortcut(3) sent Assert.Equal(0u, slots[Row1[3]].Cell.ItemId); // source slot now empty } [Fact] public void HandleDropRelease_ontoOccupied_swaps_andSendsRetailSequence() { var (layout, slots, _) = FakeToolbar(); var repo = new ClientObjectTable(); repo.AddOrUpdate(new ClientObject { ObjectId = 0x5001u, WeenieClassId = 1u, IconId = 0x06001234u }); // A repo.AddOrUpdate(new ClientObject { ObjectId = 0x5002u, WeenieClassId = 1u, IconId = 0x06005678u }); // B var shortcuts = new System.Collections.Generic.List { new(Index: 3, ObjectGuid: 0x5001u, SpellId: 0, Layer: 0), new(Index: 5, ObjectGuid: 0x5002u, SpellId: 0, Layer: 0) }; var (adds, removes) = NewSpies(out var add, out var rem); var ctrl = ToolbarController.Bind(layout, repo, () => shortcuts, iconIds: (_,_,_,_,_) => 0x77u, useItem: _ => { }, sendAddShortcut: add, sendRemoveShortcut: rem); // Lift A from slot 3, then drop on occupied slot 5 (holds B). var payload = new ItemDragPayload(0x5001u, ItemDragSource.ShortcutBar, 3, slots[Row1[3]].Cell); ctrl.OnDragLift(slots[Row1[3]], slots[Row1[3]].Cell, payload); ctrl.HandleDropRelease(slots[Row1[5]], slots[Row1[5]].Cell, payload); // End state: A at slot 5, B bumped to the vacated source slot 3. Assert.Equal(0x5001u, slots[Row1[5]].Cell.ItemId); Assert.Equal(0x5002u, slots[Row1[3]].Cell.ItemId); // Wire (retail sequence): RemoveShortcut(3)[lift], RemoveShortcut(5)[evict B], AddShortcut(5,A), AddShortcut(3,B) Assert.Equal(new[] { 3u, 5u }, removes.ToArray()); Assert.Contains((5u, 0x5001u), adds); Assert.Contains((3u, 0x5002u), adds); } [Fact] public void HandleDropRelease_ontoEmpty_placesOnly_sourceStaysEmpty() { var (layout, slots, _) = FakeToolbar(); var repo = new ClientObjectTable(); repo.AddOrUpdate(new ClientObject { ObjectId = 0x5001u, WeenieClassId = 1u, IconId = 0x06001234u }); var shortcuts = new System.Collections.Generic.List { new(Index: 3, ObjectGuid: 0x5001u, SpellId: 0, Layer: 0) }; var (adds, removes) = NewSpies(out var add, out var rem); var ctrl = ToolbarController.Bind(layout, repo, () => shortcuts, iconIds: (_,_,_,_,_) => 0x77u, useItem: _ => { }, sendAddShortcut: add, sendRemoveShortcut: rem); var payload = new ItemDragPayload(0x5001u, ItemDragSource.ShortcutBar, 3, slots[Row1[3]].Cell); ctrl.OnDragLift(slots[Row1[3]], slots[Row1[3]].Cell, payload); ctrl.HandleDropRelease(slots[Row1[7]], slots[Row1[7]].Cell, payload); // slot 7 empty Assert.Equal(0x5001u, slots[Row1[7]].Cell.ItemId); // A placed at 7 Assert.Equal(0u, slots[Row1[3]].Cell.ItemId); // source 3 stays empty Assert.Contains((7u, 0x5001u), adds); Assert.DoesNotContain(adds, a => a.i == 3u); // no bump (target was empty) } [Fact] public void ToolbarSlots_useGreenCrossAcceptSprite() { var (layout, slots, _) = FakeToolbar(); ToolbarController.Bind(layout, new ClientObjectTable(), () => System.Array.Empty(), iconIds: (_,_,_,_,_) => 0u, useItem: _ => { }); Assert.Equal(0x060011FAu, slots[Row1[0]].Cell.DragAcceptSprite); // green cross, not the ring F9 } ``` - [ ] **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.Bind` has no `sendAddShortcut`/`sendRemoveShortcut` params; `OnDragLift` is a no-op stub (from Task 2); `DragAcceptSprite` not set to FA. - [ ] **Step 3: Add the store + wire fields + Bind params** In `src/AcDream.App/UI/Layout/ToolbarController.cs`: (a) Add fields near the other private fields: ```csharp private readonly AcDream.Core.Items.ShortcutStore _store = new(); private bool _storeLoaded; private readonly Action? _sendAddShortcut; // (index, objectGuid) private readonly Action? _sendRemoveShortcut; // (index) ``` (b) Add the two params to the private ctor signature and the `Bind` factory (both, at the end, optional), and assign them in the ctor: ```csharp // ctor params (add after emptyDigits): Action? sendAddShortcut = null, Action? sendRemoveShortcut = null, // ctor body (with the other assignments): _sendAddShortcut = sendAddShortcut; _sendRemoveShortcut = sendRemoveShortcut; ``` (Mirror the same two params on the `public static ToolbarController Bind(...)` signature and forward them into the `new ToolbarController(...)` call.) (c) In the ctor's slot loop (where B.1 already does `RegisterDragHandler(this)` + `SlotIndex` + `SourceKind`), add the green-cross sprite: ```csharp list.Cell.DragAcceptSprite = 0x060011FAu; // green cross (toolbar), not the ring 0x060011F9 (inventory) ``` - [ ] **Step 4: Switch Populate + IsShortcutGuid to the store; add the handler bodies** (a) Replace `Populate()` with the store-driven version (lazy-load once): ```csharp public void Populate() { // Lazy-load the store from the login shortcut list the first time it's present (PD arrives // after Bind); thereafter the store is authoritative and drag ops mutate it directly. if (!_storeLoaded && _shortcuts().Count > 0) { _store.Load(_shortcuts()); _storeLoaded = true; } foreach (var list in _slots) list?.Cell.Clear(); for (int slot = 0; slot < _slots.Length; slot++) { uint guid = _store.Get(slot); if (guid == 0) continue; var list = _slots[slot]; if (list is null) continue; var item = _repo.Get(guid); if (item is null) continue; // deferred: ObjectAdded re-calls Populate uint tex = _iconIds(item.Type, item.IconId, item.IconUnderlayId, item.IconOverlayId, item.Effects); list.Cell.SetItem(guid, tex); } RestampShortcutNumbers(); } ``` (b) Replace `IsShortcutGuid` to read the store: ```csharp private bool IsShortcutGuid(uint guid) { for (int s = 0; s < AcDream.Core.Items.ShortcutStore.SlotCount; s++) if (_store.Get(s) == guid) return true; return false; } ``` (c) Replace the Task-2 stub `OnDragLift` (and the B.1 `HandleDropRelease` logging stub) with the real bodies: ```csharp /// public void OnDragLift(UiItemList sourceList, UiItemSlot sourceCell, ItemDragPayload payload) { // Retail RecvNotice_ItemListBeginDrag → RemoveShortcut (0x004bd930/0x004bd450): the lifted // shortcut leaves the bar (+ wire) the instant the drag starts; it's re-placed only on a // drop onto a slot. Off-bar release leaves it removed. _store.Remove(payload.SourceSlot); _sendRemoveShortcut?.Invoke((uint)payload.SourceSlot); Populate(); } /// public void HandleDropRelease(UiItemList targetList, UiItemSlot targetCell, ItemDragPayload payload) { // Retail gmToolbarUI::HandleDropRelease (0x004be7c0) within-bar reorder branch (deep-dive §5): // evict the target's occupant, place the dragged item there, and bump the evicted item into // the (now-vacated) source slot if it's free. int target = targetCell.SlotIndex; uint evicted = _store.Get(target); // RemoveShortcutInSlotNum(target) if (evicted != 0) { _store.Remove(target); _sendRemoveShortcut?.Invoke((uint)target); } _store.Set(target, payload.ObjId); // AddShortcut(dragged, target) _sendAddShortcut?.Invoke((uint)target, payload.ObjId); if (evicted != 0 && evicted != payload.ObjId && _store.IsEmpty(payload.SourceSlot)) { // displaced → vacated source slot _store.Set(payload.SourceSlot, evicted); _sendAddShortcut?.Invoke((uint)payload.SourceSlot, evicted); } Populate(); } ``` (Delete the old logging `Console.WriteLine` `HandleDropRelease` body and the `OnDragOver` stays as-is.) - [ ] **Step 5: Run to verify the controller tests pass** Run: `dotnet test tests/AcDream.App.Tests/AcDream.App.Tests.csproj --filter "FullyQualifiedName~ToolbarController"` Expected: PASS (new B.2 tests + the existing B.1 controller tests — `Bind_registersDragHandler_andStampsSlots`, `Populate_bindsShortcutToCorrectSlot` etc. still hold; note `Populate_bindsShortcutToCorrectSlot` exercises the lazy-load path now and should still bind 0x5001 to slot 0). - [ ] **Step 6: Inject the session sends at the GameWindow toolbar Bind** In `src/AcDream.App/Rendering/GameWindow.cs`, at the `ToolbarController.Bind(...)` call (~line 2013), add the two send actions (mirror the `sendQueryHealth: g => _liveSession?.SendQueryHealth(g)` style used by `SelectedObjectController.Bind` just below it): ```csharp sendAddShortcut: (i, g) => _liveSession?.SendAddShortcut(i, g), sendRemoveShortcut: i => _liveSession?.SendRemoveShortcut(i), ``` (Add them as the last arguments to the `ToolbarController.Bind(...)` call, after `emptyDigits:`.) - [ ] **Step 7: Update the divergence register** In `docs/architecture/retail-divergence-register.md`: - **Reword AP-47** (the ghost row) — remove the reduced-alpha claim (the ghost is now full opacity, matching retail). New text for the Divergence cell: `Cursor drag ghost reuses the full composited m_pIcon (incl. type-default underlay) instead of retail's dedicated underlay-less m_pDragIcon. Opacity now matches retail (full).` and the Risk cell: `The ghost carries the opaque type-default underlay backing rather than retail's underlay-less copy — a subtle look difference while dragging, no functional effect.` (Where/oracle columns unchanged.) - **Delete the TS-33 row** entirely (the logging stub is replaced by the real store + wire) and change the TS section header count `## 4. Temporary stopgap (TS) — 32 rows` → `— 31 rows`. - [ ] **Step 8: Full build + test (no regressions)** Run: `dotnet build AcDream.slnx -c Debug` then `dotnet test tests/AcDream.App.Tests/AcDream.App.Tests.csproj` and `dotnet test tests/AcDream.Core.Tests/AcDream.Core.Tests.csproj` Expected: build green; all pass. - [ ] **Step 9: Commit** ```bash git add src/AcDream.App/UI/Layout/ToolbarController.cs src/AcDream.App/Rendering/GameWindow.cs tests/AcDream.App.Tests/UI/Layout/ToolbarControllerTests.cs docs/architecture/retail-divergence-register.md git commit -m "feat(ui): D.5.3/B.2 — toolbar reorder/remove via ShortcutStore + wire + green-cross overlay" -m "Co-Authored-By: Claude Opus 4.8 (1M context) " ``` --- ## Final verification - [ ] **Full solution:** `dotnet build AcDream.slnx -c Debug`; `dotnet test tests/AcDream.Core.Tests/...`, `tests/AcDream.Core.Net.Tests/...`, `tests/AcDream.App.Tests/...` — all green. - [ ] **Spec acceptance §9 self-check:** UiRoot/UiItemSlot stay item-agnostic; AP-47 reworded; TS-33 deleted; wire builder renamed with no stale callers. - [ ] **Hand to visual verification** (user, `ACDREAM_RETAIL_UI=1`, in-world): lift a hotbar item → source slot empties immediately; full-opacity icon follows the cursor; hovered slots show a green CROSS; drop on another slot → moves there (occupant bumps to source); drop off-bar → removed; single click still uses; changes persist after relog. - [ ] **Update memory** (`project_d2b_retail_ui.md`) with the B.2 landing + the retail remove-on-lift model; refresh #141 / roadmap as needed.