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) <noreply@anthropic.com>
35 KiB
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—BuildAddShortcutsignature/field fix.src/AcDream.Core.Net/WorldSession.cs—SendAddShortcut/SendRemoveShortcut.tests/AcDream.Core.Net.Tests/Messages/InventoryActionsTests.cs— updateBuildAddShortcut_ThreeFields.src/AcDream.App/UI/IItemListDragHandler.cs— addOnDragLift.src/AcDream.App/UI/UiItemSlot.cs—DragBegin→lift;DropReleasedungate.src/AcDream.App/UI/UiRoot.cs—_dragGhostsnapshot;GhostAlpha=1;FinishDragdeliver-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) <noreply@anthropic.com>.
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:
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<PlayerDescriptionParser.ShortcutEntry>
{
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:
using System.Collections.Generic;
using AcDream.Core.Net.Messages;
namespace AcDream.Core.Items;
/// <summary>
/// Mutable client-side model of the 18 toolbar shortcut slots — port of retail
/// <c>ShortCutManager::shortCuts_[18]</c> (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.
/// </summary>
public sealed class ShortcutStore
{
public const int SlotCount = 18;
private readonly uint[] _objIds = new uint[SlotCount];
/// <summary>Replace all slots from the login PlayerDescription shortcut list (item entries only).</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[(int)e.Index] = e.ObjectGuid;
}
/// <summary>Bound object guid at <paramref name="slot"/>, or 0 (empty / out of range).</summary>
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:
[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:
/// <summary>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.</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);
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:
/// <summary>Send AddShortcut (0x019C) — pin an item to toolbar slot <paramref name="index"/>.
/// Retail: CM_Character::Event_AddShortCut. Mirrors the SendChangeCombatMode pattern.</summary>
public void SendAddShortcut(uint index, uint objectGuid, ushort spellId = 0, ushort layer = 0)
{
uint seq = NextGameActionSequence();
SendGameAction(InventoryActions.BuildAddShortcut(seq, index, objectGuid, spellId, layer));
}
/// <summary>Send RemoveShortcut (0x019D) — clear toolbar slot <paramref name="index"/>.
/// Retail: CM_Character::Event_RemoveShortCut.</summary>
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
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) <noreply@anthropic.com>"
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:
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:
[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:
[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:
/// <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). No restore on cancel.</summary>
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:
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):
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:
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:
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:
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
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) <noreply@anthropic.com>"
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
ToolbarControllerTestsclass
// ── B.2: live drag handler (store + reorder/remove + wire) ───────────────
private static (System.Collections.Generic.List<(uint i,uint g)> adds,
System.Collections.Generic.List<uint> removes) NewSpies(out System.Action<uint,uint> add, out System.Action<uint> rem)
{
var adds = new System.Collections.Generic.List<(uint,uint)>();
var removes = new System.Collections.Generic.List<uint>();
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<PlayerDescriptionParser.ShortcutEntry>
{ 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<PlayerDescriptionParser.ShortcutEntry>
{ 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<PlayerDescriptionParser.ShortcutEntry>
{ 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<PlayerDescriptionParser.ShortcutEntry>(),
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:
private readonly AcDream.Core.Items.ShortcutStore _store = new();
private bool _storeLoaded;
private readonly Action<uint, uint>? _sendAddShortcut; // (index, objectGuid)
private readonly Action<uint>? _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:
// ctor params (add after emptyDigits):
Action<uint, uint>? sendAddShortcut = null,
Action<uint>? 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:
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):
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:
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:
/// <inheritdoc/>
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();
}
/// <inheritdoc/>
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):
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
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) <noreply@anthropic.com>"
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.