docs(D.5.3/B.2): toolbar shortcut drag implementation plan
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>
This commit is contained in:
parent
19f1b8b614
commit
7193bed309
1 changed files with 699 additions and 0 deletions
699
docs/superpowers/plans/2026-06-20-d2b-toolbar-shortcut-drag.md
Normal file
699
docs/superpowers/plans/2026-06-20-d2b-toolbar-shortcut-drag.md
Normal file
|
|
@ -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) <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`:
|
||||
|
||||
```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<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`:
|
||||
|
||||
```csharp
|
||||
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:
|
||||
|
||||
```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
|
||||
/// <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:
|
||||
|
||||
```csharp
|
||||
/// <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**
|
||||
|
||||
```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) <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:
|
||||
|
||||
```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
|
||||
/// <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:
|
||||
|
||||
```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) <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 `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<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:
|
||||
|
||||
```csharp
|
||||
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:
|
||||
|
||||
```csharp
|
||||
// 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:
|
||||
|
||||
```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
|
||||
/// <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):
|
||||
|
||||
```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) <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.
|
||||
Loading…
Add table
Add a link
Reference in a new issue