docs(D.5.3/B.2): toolbar shortcut drag interactivity design spec
From the user's visual-gate feedback + a retail decomp trace this session. Retail's model is remove-on-lift / place-on-drop / no-restore (confirmed: RecvNotice_ItemListBeginDrag 0x004bd930 → RemoveShortcut 0x004bd450 at lift; HandleDropRelease 0x004be7c0 places + bumps displaced → source; off-bar drop leaves it removed). This unifies the user's points 3/4/5 into one mechanism. Verified the drop sprite against client_portal.dat: 0x060011F9 = green RING (inventory), 0x060011FA = green CROSS (toolbar) — user was right. Scope (toolbar-internal; drag-from-inventory is Stream C): spine extensions (lift hook on IItemListDragHandler; ghost snapshotted at BeginDrag at full opacity so it survives the source emptying; FinishDrag delivers a drop only on a real hit, off-bar = nothing); a mutable 18-slot ShortcutStore; ToolbarController as the live handler (OnDragLift removes; HandleDropRelease places + bumps); the AddShortcut 0x019C / RemoveShortcut 0x019D wire (fix the BuildAddShortcut param names; SendAddShortcut/SendRemoveShortcut on WorldSession); green-cross overlay. Amends AP-47 (ghost now full opacity), retires TS-33 (real wire replaces the stub). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
acdefc2e21
commit
19f1b8b614
1 changed files with 386 additions and 0 deletions
|
|
@ -0,0 +1,386 @@
|
|||
# D.2b toolbar shortcut drag interactivity (Stream B.2) — design
|
||||
|
||||
**Date:** 2026-06-20
|
||||
**Phase:** D.2b retail-UI → D.5 core panels. Stream **B.2** of the 2026-06-18 handoff — make the
|
||||
toolbar shortcut drag *functional* (reorder / remove) and *retail-faithful*, on top of the B.1
|
||||
spine (`9d48346..acdefc2`, shipped + visually confirmed this session).
|
||||
**Branch:** `claude/hopeful-maxwell-214a12`.
|
||||
**Driver:** user visual-gate feedback 2026-06-20 (6 points) + the retail research below.
|
||||
|
||||
**Read alongside:**
|
||||
- `docs/research/2026-06-16-action-bar-toolbar-deep-dive.md` §5 (HandleDropRelease/RemoveShortcut/wire).
|
||||
- `docs/research/2026-06-16-ui-item-slot-icon-dragdrop-spine-deep-dive.md` §5 (the cell drag chain).
|
||||
- `docs/superpowers/specs/2026-06-20-d2b-drag-drop-spine-design.md` (B.1 — what this extends).
|
||||
- `claude-memory/project_d2b_retail_ui.md`.
|
||||
|
||||
---
|
||||
|
||||
## 1. Goal & non-goals
|
||||
|
||||
**Goal.** A player drags a hotbar shortcut, sees the item icon lift off the slot (slot empties) and
|
||||
follow the cursor at full opacity with a green-cross drop indicator; dropping on another slot moves
|
||||
it there (reordering, bumping any occupant back to the source slot); dropping anywhere else removes
|
||||
it from the bar. All changes are sent to the server (`AddShortcut 0x019C` / `RemoveShortcut 0x019D`)
|
||||
so they persist. This realizes the user's 6 visual-gate points as retail's single **remove-on-lift /
|
||||
place-on-drop / no-restore** mechanism.
|
||||
|
||||
**In scope:**
|
||||
1. **Spine extensions** (small, to the B.1 toolkit): a drag-LIFT hook to the source's handler;
|
||||
full-opacity ghost snapshotted at drag-begin (survives the source emptying); `FinishDrag`
|
||||
delivers a drop only when released on a real element (off-bar = no drop fires).
|
||||
2. **`ShortcutStore`** — a mutable 18-slot model (port of retail `ShortCutManager::shortCuts_[18]`).
|
||||
3. **`ToolbarController` as the live drag handler** — `OnDragLift` removes; `HandleDropRelease`
|
||||
places + bumps displaced → source; both drive the store, the wire, and a re-`Populate()`.
|
||||
4. **The wire** — fix `BuildAddShortcut` param names; add `WorldSession.SendAddShortcut` /
|
||||
`SendRemoveShortcut`.
|
||||
5. **Green-cross accept overlay** (`0x060011FA`) on toolbar cells (verified against the dat).
|
||||
|
||||
**Out of scope (later streams):**
|
||||
- **Drag FROM inventory onto the bar** (the `flags & 0xE == 0` "fresh-from-inventory" branch) — needs
|
||||
the inventory window as a drag source (Stream C). This spec is reorder-within-bar + remove only
|
||||
(the `flags & 4` branch).
|
||||
- Spell shortcuts (the `SpellId|Layer` payload) — items only (`spellId=layer=0`); the store carries a
|
||||
spell field for forward-compat but the toolbar binds item guids.
|
||||
- Server-pushed shortcut mutations after login (the store is client-authoritative post-`Load`).
|
||||
- The selected-object mana meter / stack-split UI (issue #141 deferred bits).
|
||||
|
||||
---
|
||||
|
||||
## 2. Retail grounding (the model — CONFIRMED by research 2026-06-20)
|
||||
|
||||
Retail's toolbar drag is **remove-on-lift, place-on-drop, no-restore** (decomp trace, this session):
|
||||
|
||||
- **On drag-begin** (`UIElement_ItemList::ItemList_BeginDrag` 0x004e32d0 → broadcasts msg `0x21` →
|
||||
`gmToolbarUI::RecvNotice_ItemListBeginDrag` 0x004bd930): `PrepareDragIcon` makes the **full icon**
|
||||
the cursor ghost (m_dragIcon), then `gmToolbarUI::RemoveShortcut` (0x004bd450) **removes the
|
||||
shortcut**: `ItemList_Flush` → cell → empty state `0x1000001c`, `Event_RemoveShortCut` sends
|
||||
`0x019D`, and `m_lastShortcutNumDragged` = the source slot. The slot is now empty; the item is "in
|
||||
hand."
|
||||
- **On mouse-up over a slot** (`UIElementManager::StopDragandDrop` 0x00459810 → success →
|
||||
`gmToolbarUI::HandleDropRelease` 0x004be7c0): within-bar reorder branch (`flags & 4`):
|
||||
`RemoveShortcutInSlotNum(target)` (evict the occupant, get its objId) → `AddShortcut(draggedObjId,
|
||||
target)` (`0x019C`); if an item was displaced and the source slot is free, `AddShortcut(displaced,
|
||||
m_lastShortcutNumDragged)` — the bumped item lands in the vacated source slot. (deep-dive §5.)
|
||||
- **On mouse-up over nothing** (`m_pElementLastDragCursorOver == null` → success=0): no
|
||||
`HandleDropRelease`, no re-add — the shortcut stays gone (already removed + `0x019D` sent at lift).
|
||||
- **No cancel/restore path.** `gmToolbarUI` has no end-drag/restore vtable entry. Lifted-then-not-
|
||||
landed = permanently removed (server already told).
|
||||
- **Drop indicator** is a per-slot OVERLAY sprite on `m_elem_Icon_DragAccept` (`0x1000045A`), NOT a
|
||||
cursor change (`UpdateCursorState` is combat/target-only). The toolbar accept sprite is the **green
|
||||
cross `0x060011FA`** (verified by exporting the `0x060011F7..FA` family from `client_portal.dat`:
|
||||
F7=green move-arrow, F8=red ∅ reject, F9=green **ring** [inventory], FA=green **cross** [toolbar]);
|
||||
reject = `0x060011F8`.
|
||||
|
||||
**Wire (`ShortCutData`, CONFIRMED 3 refs — deep-dive §131-145):**
|
||||
- `AddShortCut 0x019C` body after the 12-byte GameAction envelope: `Index(u32), ObjectId(u32),
|
||||
SpellId(u16), Layer(u16)` (item → `ObjectId=guid, SpellId=Layer=0`).
|
||||
- `RemoveShortCut 0x019D` body: `Index(u32)`.
|
||||
- ACE handles the remove-then-add reorder pattern (`Player_Character.cs:254`).
|
||||
|
||||
---
|
||||
|
||||
## 3. Architecture — components & boundaries
|
||||
|
||||
```
|
||||
press+move on OCCUPIED slot ─► UiRoot.BeginDrag
|
||||
│ snapshot ghost (full icon) BEFORE the lift clears the source cell
|
||||
│ fire DragBegin on the source cell
|
||||
▼
|
||||
UiItemSlot.OnEvent(DragBegin) ─► FindList().DragHandler.OnDragLift(list, cell, payload)
|
||||
▼
|
||||
ToolbarController.OnDragLift ─► ShortcutStore.Remove(srcSlot); SendRemoveShortcut(srcSlot); Populate()
|
||||
(source slot now empty; ghost still shows the snapshot)
|
||||
drag-over target slot ─► UiItemSlot DragEnter ─► handler.OnDragOver → green-cross accept overlay (FA)
|
||||
release ─► UiRoot.FinishDrag:
|
||||
├─ over a slot ─► that cell.OnEvent(DropReleased) ─► handler.HandleDropRelease(target, payload)
|
||||
│ ShortcutStore place + bump displaced→srcSlot; SendAddShortcut×N; Populate()
|
||||
└─ over nothing ─► no DropReleased delivered → lift's removal stands (item gone)
|
||||
```
|
||||
|
||||
**Boundaries:** `UiRoot`/`UiElement`/`UiItemSlot`/`UiItemList` stay item-agnostic — they gain a lift
|
||||
*hook* and a ghost snapshot, nothing item-specific. `ShortcutStore` is pure logic in `AcDream.Core`
|
||||
(testable, no GL). The wire lives in `AcDream.Core.Net`. `ToolbarController` (App) orchestrates.
|
||||
Honors structure Rules 1/2/3/6.
|
||||
|
||||
---
|
||||
|
||||
## 4. New & changed types (precise)
|
||||
|
||||
### 4.1 Spine — `IItemListDragHandler` gains the lift hook (`UI/IItemListDragHandler.cs`)
|
||||
```csharp
|
||||
public interface IItemListDragHandler
|
||||
{
|
||||
/// <summary>The drag STARTED from a cell in this list — retail's
|
||||
/// RecvNotice_ItemListBeginDrag → RemoveShortcut (decomp 0x004bd930/0x004bd450): the handler
|
||||
/// removes the lifted item from its model + wire, so the source slot empties immediately. The
|
||||
/// item is "in hand" until HandleDropRelease (place) or the drag ends off-target (stays removed).</summary>
|
||||
void OnDragLift(UiItemList sourceList, UiItemSlot sourceCell, ItemDragPayload payload);
|
||||
|
||||
bool OnDragOver(UiItemList targetList, UiItemSlot targetCell, ItemDragPayload payload);
|
||||
void HandleDropRelease(UiItemList targetList, UiItemSlot targetCell, ItemDragPayload payload);
|
||||
}
|
||||
```
|
||||
|
||||
### 4.2 Spine — `UiItemSlot` (`UI/UiItemSlot.cs`)
|
||||
- `OnEvent` `DragBegin` case changes from `return true` (no-op) to: notify the list's handler so it
|
||||
can lift:
|
||||
```csharp
|
||||
case UiEventType.DragBegin:
|
||||
if (FindList() is { DragHandler: { } h } list && e.Payload is ItemDragPayload p)
|
||||
h.OnDragLift(list, this, p);
|
||||
return true;
|
||||
```
|
||||
- `DropReleased` case: drop the `e.Data0 == 1` gate — reaching the cell means a real slot was hit
|
||||
(`FinishDrag` only delivers on a hit). The handler is authoritative (place; drop-on-self re-adds):
|
||||
```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;
|
||||
```
|
||||
(`DragEnter`/`DragOver` overlay handling unchanged from B.1.)
|
||||
|
||||
### 4.3 Spine — `UiRoot` (`UI/UiRoot.cs`)
|
||||
- Add `private (uint tex, int w, int h)? _dragGhost;`. In `BeginDrag`, **snapshot the ghost before
|
||||
firing `DragBegin`** (the lift will empty the source cell, so a live re-read would vanish):
|
||||
```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 — survives the source emptying
|
||||
var e = new UiEvent(source.EventId, source, UiEventType.DragBegin, Payload: payload);
|
||||
source.OnEvent(in e); // → cell → handler.OnDragLift (clears the source slot)
|
||||
}
|
||||
```
|
||||
- `DrawDragGhost` uses the snapshot, not a live read; `GhostAlpha` → **1.0** (full opacity, retail
|
||||
m_dragIcon):
|
||||
```csharp
|
||||
private const float GhostAlpha = 1.0f;
|
||||
private void DrawDragGhost(UiRenderContext ctx)
|
||||
{
|
||||
if (_dragGhost is not { } g || g.tex == 0) return;
|
||||
ctx.DrawSprite(g.tex, MouseX - g.w / 2f, MouseY - g.h / 2f, g.w, g.h,
|
||||
0f, 0f, 1f, 1f, new Vector4(1f, 1f, 1f, GhostAlpha));
|
||||
}
|
||||
```
|
||||
- `FinishDrag` delivers a drop ONLY to a real hit element; off-bar (null hit) fires nothing; clear
|
||||
the ghost:
|
||||
```csharp
|
||||
private void FinishDrag(int x, int y)
|
||||
{
|
||||
var (t, lx, ly) = HitTestTopDown(x, y);
|
||||
if (t is not null)
|
||||
{
|
||||
var e = new UiEvent(DragSource!.EventId, t, UiEventType.DropReleased,
|
||||
Data1: (int)lx, Data2: (int)ly, Payload: DragPayload);
|
||||
t.OnEvent(in e); // the hit cell's handler places; a non-item target ignores it → item stays removed
|
||||
}
|
||||
// else: dropped off any element — no drop fires; the lift's removal stands (retail).
|
||||
DragSource = null; DragPayload = null; _dragGhost = null; _lastDragHoverTarget = null;
|
||||
}
|
||||
```
|
||||
(`GetDragGhost()` on `UiElement`/`UiItemSlot` stays as-is — `BeginDrag` calls it once.)
|
||||
|
||||
### 4.4 NEW — `ShortcutStore` (`src/AcDream.Core/Items/ShortcutStore.cs`)
|
||||
Port of retail `ShortCutManager::shortCuts_[18]` (`acclient.h:36492`). Pure logic.
|
||||
```csharp
|
||||
public sealed class ShortcutStore
|
||||
{
|
||||
public const int SlotCount = 18;
|
||||
private readonly uint[] _objIds = new uint[SlotCount]; // 0 = empty
|
||||
|
||||
/// <summary>Replace all slots from the login PlayerDescription shortcut list.</summary>
|
||||
public void Load(IReadOnlyList<PlayerDescriptionParser.ShortcutEntry> entries)
|
||||
{
|
||||
System.Array.Clear(_objIds);
|
||||
foreach (var e in entries)
|
||||
if (e.Index < SlotCount && e.ObjectGuid != 0) _objIds[e.Index] = e.ObjectGuid;
|
||||
}
|
||||
|
||||
public uint Get(int slot) => (uint)slot < SlotCount ? _objIds[slot] : 0u;
|
||||
public bool IsEmpty(int slot) => Get(slot) == 0u;
|
||||
public void Set(int slot, uint objId) { if ((uint)slot < SlotCount) _objIds[slot] = objId; }
|
||||
public void Remove(int slot) { if ((uint)slot < SlotCount) _objIds[slot] = 0u; }
|
||||
}
|
||||
```
|
||||
|
||||
### 4.5 Wire — `InventoryActions.BuildAddShortcut` rename (`Core.Net/Messages/InventoryActions.cs`)
|
||||
Byte layout is already correct; FIX the misleading param names (handoff §B.2; deep-dive §131-145):
|
||||
```csharp
|
||||
/// <summary>Pin an item/spell to a quickbar slot. ShortCutData = Index(u32),ObjectId(u32),
|
||||
/// SpellId(u16),Layer(u16). For an item: objectGuid + spellId=layer=0.</summary>
|
||||
public static byte[] BuildAddShortcut(uint seq, uint index, uint objectGuid, ushort spellId, ushort layer)
|
||||
{
|
||||
byte[] body = new byte[24];
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(body, GameActionEnvelope);
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(4), seq);
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(8), AddShortcutOpcode); // 0x019C
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(12), index);
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(16), objectGuid);
|
||||
BinaryPrimitives.WriteUInt16LittleEndian(body.AsSpan(20), spellId);
|
||||
BinaryPrimitives.WriteUInt16LittleEndian(body.AsSpan(22), layer);
|
||||
return body;
|
||||
}
|
||||
```
|
||||
(`BuildRemoveShortcut(seq, slotIndex)` is correct — unchanged.) Any existing caller of the old
|
||||
4-arg `BuildAddShortcut` must be updated; grep first (likely none wired today).
|
||||
|
||||
### 4.6 Wire — `WorldSession` sends (`Core.Net/WorldSession.cs`, mirror `SendChangeCombatMode` :1134)
|
||||
```csharp
|
||||
public void SendAddShortcut(uint index, uint objectGuid, ushort spellId = 0, ushort layer = 0)
|
||||
{
|
||||
uint seq = NextGameActionSequence();
|
||||
SendGameAction(InventoryActions.BuildAddShortcut(seq, index, objectGuid, spellId, layer));
|
||||
}
|
||||
public void SendRemoveShortcut(uint index)
|
||||
{
|
||||
uint seq = NextGameActionSequence();
|
||||
SendGameAction(InventoryActions.BuildRemoveShortcut(seq, index));
|
||||
}
|
||||
```
|
||||
|
||||
### 4.7 `ToolbarController` — the live handler (`UI/Layout/ToolbarController.cs`)
|
||||
- Holds a `ShortcutStore _store`. `Populate()` reads `_store` (18 slots → `repo.Get(objId)` → icon)
|
||||
INSTEAD of the read-only `_shortcuts()` provider. **Lazy-load-once:** the PlayerDescription shortcut
|
||||
list arrives AFTER Bind (at login), so load the store the first time `_shortcuts()` is non-empty,
|
||||
then treat it as authoritative:
|
||||
```csharp
|
||||
// top of Populate(), before rendering:
|
||||
if (!_storeLoaded && _shortcuts().Count > 0) { _store.Load(_shortcuts()); _storeLoaded = true; }
|
||||
```
|
||||
Bind's initial `Populate` (pre-login) sees an empty list and skips the load; the existing
|
||||
`repo.ObjectAdded → Populate` path (shortcut items arriving via `CreateObject`, by which time the PD
|
||||
shortcut list is set) triggers the one-time load. After load, drag ops mutate `_store` directly and
|
||||
it is never reloaded within the session (a relog = a fresh process → `_storeLoaded` resets). No
|
||||
GameWindow change is needed for loading.
|
||||
- `IsShortcutGuid(guid)` (the repo-event gate) checks `_store` (any slot holds `guid`) rather than
|
||||
`_shortcuts()`, so an item dragged ONTO the bar (a new guid) still gets its later updates rendered.
|
||||
- Each toolbar cell: `DragAcceptSprite = 0x060011FAu` (green cross). (`DragRejectSprite` stays
|
||||
`0x060011F8`.)
|
||||
- Inject two `Action`s for the wire (so the controller stays testable without a live session):
|
||||
`Action<uint,uint> sendAdd` = `(index, objId) => session.SendAddShortcut(index, objId)`,
|
||||
`Action<uint> sendRemove` = `index => session.SendRemoveShortcut(index)`. Null in tests.
|
||||
- `OnDragLift(sourceList, sourceCell, payload)` — retail `RemoveShortcut`:
|
||||
```csharp
|
||||
_store.Remove(payload.SourceSlot);
|
||||
_sendRemove?.Invoke((uint)payload.SourceSlot);
|
||||
Populate();
|
||||
```
|
||||
- `HandleDropRelease(targetList, targetCell, payload)` — retail reorder branch (deep-dive §5):
|
||||
```csharp
|
||||
int target = targetCell.SlotIndex;
|
||||
uint evicted = _store.Get(target); // RemoveShortcutInSlotNum
|
||||
if (evicted != 0) { _store.Remove(target); _sendRemove?.Invoke((uint)target); }
|
||||
_store.Set(target, payload.ObjId); _sendAdd?.Invoke((uint)target, payload.ObjId); // AddShortcut
|
||||
if (evicted != 0 && evicted != payload.ObjId && _store.IsEmpty(payload.SourceSlot))
|
||||
{ // bump displaced → vacated source slot
|
||||
_store.Set(payload.SourceSlot, evicted);
|
||||
_sendAdd?.Invoke((uint)payload.SourceSlot, evicted);
|
||||
}
|
||||
Populate();
|
||||
```
|
||||
- `OnDragOver` unchanged from B.1 (accept any real item → green-cross overlay).
|
||||
|
||||
---
|
||||
|
||||
## 5. Data flow — frame by frame (reorder slot 3 → occupied slot 5)
|
||||
|
||||
1. Press+move on slot 3 (objId A). `BeginDrag`: snapshot ghost = A's icon; fire `DragBegin`.
|
||||
2. Cell 3 → `OnDragLift`: `_store.Remove(3)` + `SendRemoveShortcut(3)` (0x019D) + `Populate()` →
|
||||
slot 3 shows empty. Ghost (A's full icon) follows the cursor.
|
||||
3. Drag over slot 5 (objId B) → green-cross accept overlay on slot 5.
|
||||
4. Release on slot 5 → `FinishDrag` delivers `DropReleased` to cell 5 → `HandleDropRelease`:
|
||||
`evicted=B`; `Remove(5)`+`SendRemoveShortcut(5)`; `Set(5,A)`+`SendAddShortcut(5,A)`; source slot 3
|
||||
is empty → `Set(3,B)`+`SendAddShortcut(3,B)`; `Populate()` → slot 5 = A, slot 3 = B (swapped).
|
||||
5. (Off-bar variant) release over the 3D world → no `DropReleased` → A stays removed → slot 3 empty,
|
||||
A gone from the bar (server already told at step 2).
|
||||
|
||||
---
|
||||
|
||||
## 6. Edge cases
|
||||
|
||||
- **Drop on self (slot 3 → slot 3):** at lift slot 3 emptied; at drop `evicted=0`, `Set(3,A)` +
|
||||
`SendAddShortcut(3,A)` → A back at 3. Net no-op (2 wire msgs — retail does the same).
|
||||
- **Drop on a non-item element** (chrome / another window): `FinishDrag` delivers `DropReleased` to
|
||||
it; its `OnEvent` doesn't handle it → no place → A stays removed (off-bar). Correct.
|
||||
- **Ghost lifetime:** snapshotted at `BeginDrag`, cleared in `FinishDrag` — independent of the source
|
||||
cell emptying. A cancelled drag (mouse-up off-bar) still clears `_dragGhost`.
|
||||
- **Empty-slot lift:** an empty slot is not a drag source (`IsDragSource => ItemId != 0` from B.1) →
|
||||
no drag arms → no lift. (And empty slots still move the window — IA-12, from B.1.)
|
||||
- **Spell shortcuts in the loaded list** (`ObjectGuid == 0`, a spell): `ShortcutStore.Load` skips
|
||||
them (item-only this stream); they neither render nor drag. Forward-compat: the store could hold
|
||||
spell entries later.
|
||||
|
||||
---
|
||||
|
||||
## 7. Divergence register (update in the implementation commits)
|
||||
|
||||
- **AP-47 (amend):** the ghost is now **full opacity** (retail m_dragIcon) — retire the
|
||||
`GhostAlpha=0.6` reduced-alpha approximation. The row's REMAINING approximation: the ghost reuses
|
||||
the full composited `m_pIcon` (which includes the type-default underlay) rather than retail's
|
||||
dedicated underlay-less `m_pDragIcon`. Reword AP-47 to drop the alpha claim, keep the underlay note.
|
||||
- **TS-33 (retire):** the toolbar `HandleDropRelease` logging stub is replaced by the real
|
||||
store-mutation + `AddShortcut`/`RemoveShortcut` wire. Delete the row.
|
||||
- **No new row** for remove-on-lift / off-bar-remove — it is the faithful retail mechanism (it
|
||||
*reduces* divergence). The store being client-authoritative post-login matches retail's
|
||||
`ShortCutManager` (client owns the array; server is notified via the wire).
|
||||
|
||||
---
|
||||
|
||||
## 8. Testing (conformance)
|
||||
|
||||
Core tests (`tests/AcDream.Core.Tests/`):
|
||||
1. `ShortcutStore`: `Load` maps Index→ObjId (skips ObjectGuid==0 + out-of-range); `Set`/`Remove`/
|
||||
`Get`/`IsEmpty`; bounds-safe.
|
||||
2. `BuildAddShortcut`: 24-byte body, envelope+seq+`0x019C`+index+objectGuid+spellId(u16)+layer(u16) at
|
||||
the right offsets (golden bytes). `BuildRemoveShortcut`: 16 bytes, index at +12.
|
||||
|
||||
App tests (`tests/AcDream.App.Tests/`):
|
||||
3. Spine: `BeginDrag` snapshots the ghost so it survives the source cell being cleared mid-drag (clear
|
||||
the source cell after begin; assert the drawn ghost source is still the snapshot — assert via a
|
||||
testable `UiRoot` accessor for `_dragGhost`).
|
||||
4. Spine: `FinishDrag` over null delivers NO `DropReleased` (a spy cell/handler isn't called); over a
|
||||
real cell, delivers it (drop the Data0 gate — drop-on-self now dispatches).
|
||||
5. `ToolbarController.OnDragLift`: removes the source slot from the store, invokes `sendRemove(src)`,
|
||||
and the source cell empties after `Populate`.
|
||||
6. `ToolbarController.HandleDropRelease` reorder onto an OCCUPIED target: store ends with dragged@target
|
||||
+ displaced@source; `sendRemove`+`sendAdd` called with the retail sequence/args. Onto an EMPTY
|
||||
target: dragged@target, source stays empty, displaced-branch not taken. Drop-on-self: re-adds to
|
||||
source.
|
||||
7. Update the B.1 tests changed by §4.2/§4.3: `DropReleased_notAccepted_skipsDispatch` →
|
||||
`DropReleased_alwaysDispatchesToHandler` (the cell dispatches whenever it receives a DropReleased);
|
||||
the full-chain drag tests still pass (a completed reorder fires `HandleDropRelease`, not `Clicked`).
|
||||
|
||||
---
|
||||
|
||||
## 9. Acceptance criteria
|
||||
|
||||
- [ ] `dotnet build` + `dotnet test` green (Core + App).
|
||||
- [ ] AP-47 reworded (no alpha); TS-33 deleted; wire builder renamed; no stray old-signature callers.
|
||||
- [ ] `UiRoot`/`UiElement`/`UiItemSlot` remain item-agnostic (the lift hook is generic; only
|
||||
`ToolbarController` knows shortcuts).
|
||||
- [ ] **Visual (user), `ACDREAM_RETAIL_UI=1`, in-world:** lift a hotbar item → **slot empties
|
||||
immediately**, **full-opacity** icon follows the cursor with a **green cross** over hovered
|
||||
slots; drop on another slot → it **moves there** (occupant bumps to the source slot); drop
|
||||
off-bar → it's **removed**; a single click still **uses** the item. Changes **persist after
|
||||
relog** (the wire reached ACE).
|
||||
- [ ] Memory crib + roadmap/ISSUES updated; #141's note refreshed if relevant.
|
||||
|
||||
---
|
||||
|
||||
## 10. Subagent task slices
|
||||
|
||||
1. **Core: `ShortcutStore` + wire** — `ShortcutStore.cs` + `BuildAddShortcut` rename +
|
||||
`WorldSession.SendAddShortcut`/`SendRemoveShortcut` + Core tests (§8.1-2).
|
||||
2. **Spine extensions** — `IItemListDragHandler.OnDragLift`; `UiItemSlot` DragBegin→lift + DropReleased
|
||||
ungate; `UiRoot` ghost snapshot + full opacity + `FinishDrag` deliver-on-hit-only; update the B.1
|
||||
tests (§8.3-4, §8.7).
|
||||
3. **ToolbarController handler** — `_store` + `Load` + `Populate`-from-store; green-cross sprite;
|
||||
`OnDragLift`/`HandleDropRelease`; wire `Action`s; GameWindow injects the session sends; controller
|
||||
tests (§8.5-6). Update AP-47, delete TS-33.
|
||||
|
||||
(1 and 2 are independent; 3 depends on both.)
|
||||
Loading…
Add table
Add a link
Reference in a new issue