From 7193bed309c0f808db1d85c3d25f5da568100d04 Mon Sep 17 00:00:00 2001 From: Erik Date: Sat, 20 Jun 2026 15:05:02 +0200 Subject: [PATCH] docs(D.5.3/B.2): toolbar shortcut drag implementation plan MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bite-sized TDD plan, three slices: (1) Core ShortcutStore + the AddShortcut 0x019C/RemoveShortcut 0x019D wire (rename BuildAddShortcut to the real Index/ObjectId/SpellId/Layer fields; SendAddShortcut/SendRemoveShortcut); (2) spine extensions (OnDragLift hook; ghost snapshotted at BeginDrag at full opacity; FinishDrag delivers a drop only on a real hit); (3) ToolbarController as the live handler (store-driven Populate w/ lazy-load; green-cross FA overlay; OnDragLift removes + HandleDropRelease places + bumps displaced→source; wire actions injected from GameWindow). Amends AP-47, deletes TS-33. Spec + plan preapproved; executing subagent-driven next. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../2026-06-20-d2b-toolbar-shortcut-drag.md | 699 ++++++++++++++++++ 1 file changed, 699 insertions(+) create mode 100644 docs/superpowers/plans/2026-06-20-d2b-toolbar-shortcut-drag.md diff --git a/docs/superpowers/plans/2026-06-20-d2b-toolbar-shortcut-drag.md b/docs/superpowers/plans/2026-06-20-d2b-toolbar-shortcut-drag.md new file mode 100644 index 00000000..f0054329 --- /dev/null +++ b/docs/superpowers/plans/2026-06-20-d2b-toolbar-shortcut-drag.md @@ -0,0 +1,699 @@ +# 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.