Merge branch 'claude/hopeful-maxwell-214a12'

# Conflicts:
#	docs/ISSUES.md
This commit is contained in:
Erik 2026-06-20 20:32:21 +02:00
commit abbd97bc7d
26 changed files with 3489 additions and 62 deletions

View file

@ -84,6 +84,23 @@ Copy this block when adding a new issue:
---
## #144 — Empty item-slot press+drag+release still emits a Click
**Status:** OPEN
**Severity:** LOW
**Filed:** 2026-06-20
**Component:** ui — D.2b drag-drop spine (B.1)
**Description:** Pressing an EMPTY `UiItemSlot`, moving the cursor >3 px (which makes `UiRoot.BeginDrag` cancel the drag because the empty cell's `GetDragPayload()` returns null), then releasing over the same cell still emits a `Click` (the press becomes a click on release, since no drag armed). This is the toolkit's general click semantics — every non-draggable widget (buttons included) fires Click after sub-gesture mouse movement; only an *armed* drag suppresses the click. Today it is a **no-op**: the toolbar's `Clicked` handler guards `if (Cell.ItemId != 0)`, so an empty-slot click uses nothing.
**Root cause / status:** Surfaced in the B.1 spine code review (Task 3). Whether retail fires nothing vs. a no-op click on an empty cell after a >3 px move is **unverified** — deliberately NOT "fixed" with a speculative `_dragCancelled` guard, because that would make empty item-slots behave differently from every other widget and would be guessing at retail behavior (CLAUDE.md forbids both). Becomes relevant only if a future panel (e.g. the inventory grid in Stream C) wires an empty-cell click handler that does NOT guard on occupancy. Action when touched: verify retail's empty-cell press+move+release behavior (cdb/decomp) before changing anything; if it must differ, it earns a divergence-register row.
**Files:** `src/AcDream.App/UI/UiRoot.cs` (`BeginDrag` cancel path, `OnMouseUp` Click emit), `src/AcDream.App/UI/UiItemSlot.cs` (`OnEvent` Click→Clicked).
**Research:** `docs/superpowers/specs/2026-06-20-d2b-drag-drop-spine-design.md` §6.
---
## #141 — Toolbar interactivity — selected-object display
**Status:** IN PROGRESS (D.5.3a health + name + flash — DONE & visually confirmed 2026-06-20; mana + stack slider still deferred). Renumbered from #140 on the 2026-06-20 main merge — A7 Fix D held #140 on main; this branch's commits/spec still reference #140.

View file

@ -56,7 +56,7 @@ accepted-divergence entries (#96, #49, #50).
| IA-13 | GameEventType registry deliberately omits event types retail ignores; unknown events fall through unhandled | `src/AcDream.Core.Net/Messages/GameEventType.cs:11` | Retail also ignores them — dropping matches retail by construction | If the "retail ignores X" judgment is wrong for any opcode (or a server mod uses one), the event is silently dropped with no diagnostic pointing at the omission | retail GameEvent dispatch (ignored-event set) |
| IA-14 | Rendering + dat-handling base is WorldBuilder's tested port, not a fresh retail-decomp port (Phase N.4/O design stance) | `docs/architecture/worldbuilder-inventory.md` (code at `src/AcDream.{Core,App}/Rendering/Wb/`) | WB visually verified on the AC world, MIT, same stack; known WB↔retail deltas resolved case-by-case — terrain split kept retail `FSplitNESW` (**#51**, pinned by `SplitFormulaDivergenceTest`), scenery drift accepted (AP-31) | A WB-upstream divergence not yet caught ships silently as "our" behavior; guard = the inventory doc's 🟢/🔴 split + per-formula divergence tests | retail decomp per algorithm; `tests/.../SplitFormulaDivergenceTest.cs` |
| IA-15 | D.2b retail UI is our own UiHost/UiElement retained-mode tree drawing dat-sprite window frames, not a byte-port of keystone.dll's LayoutDesc binary tree. Both the vitals window (`LayoutDesc 0x2100006C`) and the chat window (`LayoutDesc 0x21000006`) are rendered by the LayoutDesc importer; `UiNineSlicePanel`/`RetailChromeSprites` now back only plugin panels | `src/AcDream.App/UI/Layout/LayoutImporter.cs` (vitals + chat) + `src/AcDream.App/UI/Layout/ChatWindowController.cs` | keystone.dll has no PDB/decomp so a byte-port is impossible by definition; we mirror retail's ElementDesc field model + controls.ini tokens, and the chrome sprites ARE the real dat RenderSurfaces (Step-0 prove-out 2026-06-14 confirmed 0x06004CC2 center + 0x060074BF..C6 bevel). The 8-piece edge/corner→position mapping is DATA-DRIVEN from the dat: the `LayoutImporter` reads `LayoutDesc 0x2100006C`/`0x21000006` and resolves chrome element positions + sprite ids directly from parsed dat fields; vitals locked by the conformance fixture `tests/AcDream.App.Tests/UI/Layout/fixtures/vitals_2100006C.json` | Remaining residual risk: anchor resolution at non-800×600 and the controls.ini cascade still lack an oracle — layout scaling at non-reference resolution and stylesheet token inheritance differ silently | `LayoutDesc 0x2100006C`/`0x21000006` (SHIPPED); `docs/research/2026-06-15-layoutdesc-format.md`; controls.ini tokens; keystone.dll layout eval (no PDB) |
| IA-17 | Toolbar window FRAME is toolkit-supplied (per-window UiNineSlicePanel 8-piece bevel, drawn over content via UiElement.OnDrawAfterChildren) rather than the window-manager-owned chrome retail paints uniformly around every window | `src/AcDream.App/Rendering/GameWindow.cs` (toolbar mount) + `src/AcDream.App/UI/UiNineSlicePanel.cs` | LayoutDesc 0x21000016 has NO baked frame; retail's toolbar frame is window-manager chrome (keystone.dll). We draw the same reusable 8-piece bevel chat/vitals use; border drawn over content so the toolbar's 2px-wide row-2 right cap (W=8) can't poke through. Same pattern as the chat window. | Until a central window manager owns chrome uniformly, per-window wraps can drift (size/offset/z-order) from each other and from retail; the border-over-content rule is the toolkit's, not the WM's | gmToolbarUI WM chrome (keystone.dll, no PDB); no bevel ids in LayoutDesc 0x21000016 (toolbar dump) |
| IA-17 | Toolbar window FRAME is toolkit-supplied (per-window UiNineSlicePanel 8-piece bevel, drawn over content via UiElement.OnDrawAfterChildren) rather than the window-manager-owned chrome retail paints uniformly around every window. It also supports a toolkit-defined collapse-to-one-row (bottom-edge resize snapping between a row-1-only and a two-row height, row-2 visibility tied to the stop) — retail's real collapse is keystone.dll (no decomp) and the dat stacks both rows always. | `src/AcDream.App/Rendering/GameWindow.cs` (toolbar mount) + `src/AcDream.App/UI/UiNineSlicePanel.cs` + `src/AcDream.App/UI/UiCollapsibleFrame.cs`; spec: `docs/superpowers/specs/2026-06-20-d2b-toolbar-collapse-design.md` | LayoutDesc 0x21000016 has NO baked frame; retail's toolbar frame is window-manager chrome (keystone.dll). We draw the same reusable 8-piece bevel chat/vitals use; border drawn over content so the toolbar's 2px-wide row-2 right cap (W=8) can't poke through. Same pattern as the chat window. | Until a central window manager owns chrome uniformly, per-window wraps can drift (size/offset/z-order) from each other and from retail; the border-over-content rule is the toolkit's, not the WM's | gmToolbarUI WM chrome (keystone.dll, no PDB); no bevel ids in LayoutDesc 0x21000016 (toolbar dump) |
| IA-18 | Effect overlay tile (enum 0x10000005) is a `ReplaceColor` SURFACE SOURCE — pure-white pixels in the composited drag icon are replaced PER-PIXEL with the same (x,y) pixel of the effect tile (the SURFACE overload `SurfaceWindow::ReplaceColor` 0x004415b0), preserving the tile's texture/gradient; the tile itself is NOT blitted as an additional layer. This IS faithful retail behavior. **Anti-regression: do NOT re-implement this as a blit layer NOR as a flat-color replace (it is a per-pixel surface copy).** | `src/AcDream.App/UI/IconComposer.cs` (`ReplaceWhiteFromSurface`) | Faithful port of `IconData::RenderIcons` @407614 → the SURFACE overload `ReplaceColor` 0x004415b0 (`dst[x,y]=src[x,y]` where `dst==white`); confirmed via clean Ghidra decompile + named decomp + visual (the Energy Crystal's blue is a gradient, 2026-06-17). | A blit-layer or flat-color re-implementation would show the wrong effect look (no gradient) — the visual-verification regression that retired the mean-color approximation | `IconData::RenderIcons` acclient_2013_pseudo_c.txt:407524; `ReplaceColor` SURFACE overload 0x004415b0:71656; `docs/research/2026-06-17-stateful-icon-RESOLVED.md` |
---
@ -97,7 +97,7 @@ accepted-divergence entries (#96, #49, #50).
---
## 3. Documented approximation (AP) — 42 rows
## 3. Documented approximation (AP) — 43 rows
| # | Divergence | Where (file:line) | Why it is safe / justified | Risk if assumption breaks | Retail oracle |
|---|---|---|---|---|---|
@ -146,10 +146,11 @@ accepted-divergence entries (#96, #49, #50).
| AP-42 | `UiMenu` item model is flat (label + opaque payload, single-level popup); retail `UIElement_Menu::MakePopup @0x46d310` supports hierarchical nested submenus via recursive popup chain | `src/AcDream.App/UI/UiMenu.cs` | The chat talk-focus menu is single-level (14 rows, 2 columns, no submenu); hierarchy is latent and unreachable through the chat window — no behavioral difference in the current usage | A future menu with nested submenus would render flat (only the top-level items drawn, no drill-down) | `UIElement_Menu::MakePopup` @0x46d310 |
| AP-45 | `PublicUpdatePropertyInt (0x02CE)` sequence byte parsed-past but not honored; last update wins (no freshness check against sequence number) | `src/AcDream.Core.Net/Messages/PublicUpdatePropertyInt.cs` | Loopback ACE rarely reorders; latest-wins matches `PrivateUpdateVital`/`UpdatePosition`'s existing non-sequence behavior. Sequence tracking added when needed alongside TS-26. | A reordered 0x02CE on a real network could apply a stale UiEffects value — item icon temporarily shows the wrong effect state, corrected on next update | `PublicUpdatePropertyInt` sequence byte (ACE GameMessagePublicUpdatePropertyInt) |
| AP-46 | Health-meter gate approximation: retail shows the health meter for `IsPlayer() || pet_owner || ClientCombatSystem::ObjectIsAttackable()` (full PK/faction logic); acdream's `GameWindow.IsHealthBarTarget` uses the server PWD bits `BF_ATTACKABLE (0x10)` OR `BF_PLAYER (0x8)` | `src/AcDream.App/Rendering/GameWindow.cs` (`IsHealthBarTarget`) → `SelectedObjectController` | The PWD `BF_ATTACKABLE`/`BF_PLAYER` bits distinguish monsters + players (bar) from friendly/vendor NPCs (name-only) for the M1.5 dev loop; the pet case and the full ObjectIsAttackable PK/faction refinement (free-PK, PK-vs-PK, PKLite) are not ported | A PK/faction edge (e.g. a hostile-flagged player whose `BF_ATTACKABLE` is unset, or a pet) could show/hide the bar where retail differs — no impact on the non-PK PvE dev loop | `ClientCombatSystem::ObjectIsAttackable` acclient_2013_pseudo_c.txt:375385; `BF_ATTACKABLE` acclient.h:6437 |
| AP-47 | Cursor drag ghost reuses the full composited `m_pIcon` (incl. type-default underlay) instead of retail's dedicated `m_pDragIcon` (base + custom-overlay, NO type-default underlay). Opacity now matches retail (full). | `src/AcDream.App/UI/UiRoot.cs` (`DrawDragGhost`/`GhostAlpha`) → `src/AcDream.App/UI/UiItemSlot.cs` (`GetDragGhost`) | Cosmetic only — the dragged item is still unambiguously identifiable; building the second underlay-less composite is deferred polish; the ghost is item-agnostic in UiRoot via `GetDragGhost()` | 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. | `IconData::RenderIcons` acclient_2013_pseudo_c.txt:407594-407625 (m_pDragIcon path); deep-dive §3.2/§5.5 |
---
## 4. Temporary stopgap (TS) — 31 rows
## 4. Temporary stopgap (TS) — 32 rows
| # | Divergence | Where (file:line) | Why it is safe / justified | Risk if assumption breaks | Retail oracle |
|---|---|---|---|---|---|

View file

@ -0,0 +1,752 @@
# Drag-drop spine (Stream B.1) 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:** Complete the widget-level drag-drop machine so an item icon can be picked up, dragged with a cursor-following ghost, hovered slots flip accept/reject, and the drop is dispatched to the owning panel's handler — with a visible toolbar stub proving the chain this session.
**Architecture:** `UiRoot` already holds the device-level drag state machine. We add (1) a typed `ItemDragPayload` + `IItemListDragHandler`, (2) two `UiElement` virtuals (`GetDragPayload`/`GetDragGhost`) so `UiRoot` stays item-agnostic, (3) drag/drop event handling + an accept/reject overlay on the cell (`UiItemSlot`), which delegates the accept decision + dispatch UP to its parent `UiItemList`'s registered handler, and (4) a logging toolbar stub handler (wire deferred to B.2). Spec: `docs/superpowers/specs/2026-06-20-d2b-drag-drop-spine-design.md`.
**Tech Stack:** C# / .NET 10, xUnit (`[Fact]`, `Assert.*`), the `src/AcDream.App/UI/` retained-mode toolkit. `InternalsVisibleTo("AcDream.App.Tests")` is set — internal members are test-visible. Tests are pure-logic (no GL); the cursor ghost render is visual-only (no unit test), confirmed by the user at the end.
---
## File Structure
**Create:**
- `src/AcDream.App/UI/ItemDragPayload.cs``ItemDragSource` enum + `ItemDragPayload` record (the drag snapshot).
- `src/AcDream.App/UI/IItemListDragHandler.cs` — the panel-handler interface.
- `tests/AcDream.App.Tests/UI/DragDropSpineTests.cs` — toolkit-level conformance.
**Modify:**
- `src/AcDream.App/UI/UiItemList.cs``DragHandler` property + `RegisterDragHandler`.
- `src/AcDream.App/UI/UiElement.cs``GetDragPayload()` + `GetDragGhost()` virtuals.
- `src/AcDream.App/UI/UiItemSlot.cs``SlotIndex`, `SourceKind`, accept/reject overlay, the two overrides, drag/drop `OnEvent` cases, `FindList`, MouseDown→Click use move.
- `src/AcDream.App/UI/UiRoot.cs``BeginDrag` payload pull + cancel; cursor ghost in `Draw`.
- `src/AcDream.App/UI/Layout/ToolbarController.cs` — implement `IItemListDragHandler`, register on each slot list, set `SlotIndex`/`SourceKind`, stub `HandleDropRelease`.
- `tests/AcDream.App.Tests/UI/Layout/ToolbarControllerTests.cs` — fix `Click_emitsUseForBoundItem` (send Click not MouseDown), add registration tests.
- `docs/architecture/retail-divergence-register.md` — add **AP-47** (Task 3) + **TS-33** (Task 4).
**Build/test commands** (run from repo root):
- Build: `dotnet build src/AcDream.App/AcDream.App.csproj -c Debug`
- Test a class: `dotnet test tests/AcDream.App.Tests/AcDream.App.Tests.csproj --filter "FullyQualifiedName~DragDropSpine"`
- Full app tests: `dotnet test tests/AcDream.App.Tests/AcDream.App.Tests.csproj`
**Every commit ends with the trailer** `Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>` (per CLAUDE.md).
---
## Task 1: Payload types + handler interface + UiItemList registration
**Files:**
- Create: `src/AcDream.App/UI/ItemDragPayload.cs`
- Create: `src/AcDream.App/UI/IItemListDragHandler.cs`
- Modify: `src/AcDream.App/UI/UiItemList.cs`
- Test: `tests/AcDream.App.Tests/UI/DragDropSpineTests.cs`
- [ ] **Step 1: Write the failing test**
Create `tests/AcDream.App.Tests/UI/DragDropSpineTests.cs`:
```csharp
using AcDream.App.UI;
using Xunit;
namespace AcDream.App.Tests.UI;
public class DragDropSpineTests
{
// A spy handler used across the spine tests.
private sealed class SpyHandler : IItemListDragHandler
{
public bool AcceptResult = true;
public (UiItemList list, UiItemSlot cell, ItemDragPayload payload)? LastOver;
public (UiItemList list, UiItemSlot cell, ItemDragPayload payload)? LastDrop;
public bool OnDragOver(UiItemList list, UiItemSlot cell, ItemDragPayload p)
{ LastOver = (list, cell, p); return AcceptResult; }
public void HandleDropRelease(UiItemList list, UiItemSlot cell, ItemDragPayload p)
{ LastDrop = (list, cell, p); }
}
[Fact]
public void Payload_holdsAllFields()
{
var src = new UiItemSlot();
var p = new ItemDragPayload(0x5001u, ItemDragSource.ShortcutBar, 3, src);
Assert.Equal(0x5001u, p.ObjId);
Assert.Equal(ItemDragSource.ShortcutBar, p.SourceKind);
Assert.Equal(3, p.SourceSlot);
Assert.Same(src, p.SourceCell);
}
[Fact]
public void UiItemList_registerDragHandler_roundtrips()
{
var list = new UiItemList(_ => (0u, 0, 0));
Assert.Null(list.DragHandler);
var h = new SpyHandler();
list.RegisterDragHandler(h);
Assert.Same(h, list.DragHandler);
}
}
```
- [ ] **Step 2: Run test to verify it fails**
Run: `dotnet test tests/AcDream.App.Tests/AcDream.App.Tests.csproj --filter "FullyQualifiedName~DragDropSpine"`
Expected: FAIL to compile — `ItemDragPayload`, `ItemDragSource`, `IItemListDragHandler`, `UiItemList.DragHandler`, `RegisterDragHandler` not defined.
- [ ] **Step 3: Create the payload + enum**
Create `src/AcDream.App/UI/ItemDragPayload.cs`:
```csharp
namespace AcDream.App.UI;
/// <summary>
/// Where a dragged item came from — the retail <c>InqDropIconInfo</c> flag
/// distinction (<c>flags &amp; 0xE == 0</c> fresh-from-inventory vs
/// <c>flags &amp; 4</c> within-list reorder) expressed as a typed enum. The drop
/// handler maps SourceKind + target back to the fresh-vs-reorder decision.
/// Decomp anchors: gmToolbarUI 0x004bd162 / 0x004bd1af; InqDropIconInfo 230533.
/// </summary>
public enum ItemDragSource { Inventory, ShortcutBar, Equipment, Ground }
/// <summary>
/// Snapshot of a drag-in-progress, taken at drag-begin (so a server move arriving
/// mid-drag can't mutate it under us). Port of retail's <c>m_dragElement</c> +
/// <c>InqDropIconInfo</c> out-params (objId/container/flags, decomp 230533).
/// <para><c>SourceContainer</c> is intentionally NOT stored: the handler resolves the
/// LIVE container via <c>ClientObjectTable.Get(ObjId).ContainerId</c> at drop — the
/// same container id retail reads off the dragged element, single source of truth.</para>
/// </summary>
public sealed record ItemDragPayload(
uint ObjId, // dragged weenie guid (retail itemID, +0x5FC)
ItemDragSource SourceKind, // what kind of slot it left
int SourceSlot, // the source cell's SlotIndex (retail m_lastShortcutNumDragged)
UiItemSlot SourceCell); // back-ref: reorder-restore / clear source state / ghost
```
- [ ] **Step 4: Create the handler interface**
Create `src/AcDream.App/UI/IItemListDragHandler.cs`:
```csharp
namespace AcDream.App.UI;
/// <summary>
/// A panel controller implements this and registers itself on each of its
/// <see cref="UiItemList"/>s. Port of retail's <c>m_dragHandler</c> vtable
/// (<c>RegisterItemListDragHandler</c>, decomp 230461; confirmed acclient
/// 0x004a539e + the gmToolbarUI block 0x004bdd89).
/// <para><see cref="OnDragOver"/> decides the accept/reject OVERLAY only (advisory).
/// <see cref="HandleDropRelease"/> is authoritative — it performs the action, or
/// no-ops to reject.</para>
/// </summary>
public interface IItemListDragHandler
{
/// <summary>True ⇒ the target cell shows the accept (green) frame; false ⇒ reject (red).</summary>
bool OnDragOver(UiItemList targetList, UiItemSlot targetCell, ItemDragPayload payload);
/// <summary>Perform the drop (issue the per-panel wire action), or no-op to reject.</summary>
void HandleDropRelease(UiItemList targetList, UiItemSlot targetCell, ItemDragPayload payload);
}
```
- [ ] **Step 5: Add registration to UiItemList**
In `src/AcDream.App/UI/UiItemList.cs`, add inside the class (e.g. just after the `SpriteResolve` property at ~line 25):
```csharp
/// <summary>The drag handler this list routes drops to (a panel controller).
/// Null = the list accepts no drops. Retail: UIElement_ItemList::m_dragHandler.</summary>
public IItemListDragHandler? DragHandler { get; private set; }
/// <summary>Register the panel's drag handler on this list. Retail:
/// UIElement_ItemList::RegisterItemListDragHandler (decomp 230461).</summary>
public void RegisterDragHandler(IItemListDragHandler handler) => DragHandler = handler;
```
- [ ] **Step 6: Run test to verify it passes**
Run: `dotnet test tests/AcDream.App.Tests/AcDream.App.Tests.csproj --filter "FullyQualifiedName~DragDropSpine"`
Expected: PASS (2 tests).
- [ ] **Step 7: Commit**
```bash
git add src/AcDream.App/UI/ItemDragPayload.cs src/AcDream.App/UI/IItemListDragHandler.cs src/AcDream.App/UI/UiItemList.cs tests/AcDream.App.Tests/UI/DragDropSpineTests.cs
git commit -m "feat(ui): D.5.3/B.1 — drag payload + handler interface + UiItemList registration" -m "Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>"
```
---
## Task 2: UiElement drag hooks + UiItemSlot drag/drop + overlay
**Files:**
- Modify: `src/AcDream.App/UI/UiElement.cs`
- Modify: `src/AcDream.App/UI/UiItemSlot.cs`
- Modify: `tests/AcDream.App.Tests/UI/Layout/ToolbarControllerTests.cs` (fix the one MouseDown→Click test)
- Test: `tests/AcDream.App.Tests/UI/DragDropSpineTests.cs` (add cases)
- [ ] **Step 1: Write the failing tests** (append to `DragDropSpineTests.cs`, inside the class)
```csharp
// ── UiItemSlot drag-source payload/ghost ────────────────────────────────
[Fact]
public void GetDragPayload_emptyCell_isNull()
=> Assert.Null(new UiItemSlot().GetDragPayload());
[Fact]
public void GetDragPayload_boundCell_snapshotsFields()
{
var cell = new UiItemSlot { SlotIndex = 4, SourceKind = ItemDragSource.ShortcutBar };
cell.SetItem(0x5001u, 0x99u);
var p = Assert.IsType<ItemDragPayload>(cell.GetDragPayload());
Assert.Equal(0x5001u, p.ObjId);
Assert.Equal(ItemDragSource.ShortcutBar, p.SourceKind);
Assert.Equal(4, p.SourceSlot);
Assert.Same(cell, p.SourceCell);
}
[Fact]
public void GetDragGhost_emptyCell_isNull()
=> Assert.Null(new UiItemSlot().GetDragGhost());
[Fact]
public void GetDragGhost_boundCell_returnsIconTuple()
{
var cell = new UiItemSlot { Width = 32, Height = 32 };
cell.SetItem(0x5001u, 0x99u);
var g = cell.GetDragGhost();
Assert.NotNull(g);
Assert.Equal(0x99u, g!.Value.tex);
Assert.Equal(32, g.Value.w);
Assert.Equal(32, g.Value.h);
}
// ── cell drop-target: DragEnter overlay + DropReleased dispatch ──────────
private static (UiItemList list, UiItemSlot cell, SpyHandler h) ListWithHandler()
{
var list = new UiItemList(_ => (1u, 1, 1)); // non-zero resolve so overlay draw is harmless
var h = new SpyHandler();
list.RegisterDragHandler(h);
return (list, list.Cell, h);
}
private static ItemDragPayload SomePayload()
=> new(0x5001u, ItemDragSource.ShortcutBar, 0, new UiItemSlot());
[Fact]
public void DragEnter_setsAcceptOverlay_whenHandlerAccepts()
{
var (_, cell, h) = ListWithHandler();
h.AcceptResult = true;
cell.OnEvent(new UiEvent(0u, cell, UiEventType.DragEnter, Payload: SomePayload()));
Assert.Equal(UiItemSlot.DragAcceptState.Accept, cell.DragAcceptVisual);
}
[Fact]
public void DragEnter_setsRejectOverlay_whenHandlerRejects()
{
var (_, cell, h) = ListWithHandler();
h.AcceptResult = false;
cell.OnEvent(new UiEvent(0u, cell, UiEventType.DragEnter, Payload: SomePayload()));
Assert.Equal(UiItemSlot.DragAcceptState.Reject, cell.DragAcceptVisual);
}
[Fact]
public void DragOver_resetsOverlayToNeutral()
{
var (_, cell, h) = ListWithHandler();
cell.OnEvent(new UiEvent(0u, cell, UiEventType.DragEnter, Payload: SomePayload()));
cell.OnEvent(new UiEvent(0u, cell, UiEventType.DragOver, Payload: SomePayload()));
Assert.Equal(UiItemSlot.DragAcceptState.None, cell.DragAcceptVisual);
}
[Fact]
public void DropReleased_accepted_dispatchesToHandler()
{
var (list, cell, h) = ListWithHandler();
var p = SomePayload();
cell.OnEvent(new UiEvent(0u, cell, UiEventType.DropReleased, Data0: 1, Payload: p));
Assert.NotNull(h.LastDrop);
Assert.Same(list, h.LastDrop!.Value.list);
Assert.Same(cell, h.LastDrop.Value.cell);
Assert.Same(p, h.LastDrop.Value.payload);
}
[Fact]
public void DropReleased_notAccepted_skipsDispatch()
{
var (_, cell, h) = ListWithHandler();
cell.OnEvent(new UiEvent(0u, cell, UiEventType.DropReleased, Data0: 0, Payload: SomePayload()));
Assert.Null(h.LastDrop);
}
```
- [ ] **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 — `GetDragPayload`/`GetDragGhost` not on `UiItemSlot`, `SlotIndex`/`SourceKind`/`DragAcceptVisual`/`DragAcceptState` not defined.
- [ ] **Step 3: Add the two virtuals to UiElement**
In `src/AcDream.App/UI/UiElement.cs`, add to the "Virtual overrides" region (e.g. after `OnEvent` at ~line 204):
```csharp
/// <summary>The data this element carries when a drag begins. <see cref="UiRoot"/>
/// pulls this on drag-promote; a NULL return CANCELS the drag (retail:
/// ItemList_BeginDrag only arms an occupied cell). Default null = not draggable.</summary>
public virtual object? GetDragPayload() => null;
/// <summary>The texture <see cref="UiRoot"/> paints at the cursor while this element
/// is the drag source: (GL handle, width, height). Null = no ghost. Keeps
/// <see cref="UiRoot"/> item-agnostic. Retail analog: m_dragIcon (decomp 229738).</summary>
public virtual (uint tex, int w, int h)? GetDragGhost() => null;
```
- [ ] **Step 4: Add drag state + overrides + event handling to UiItemSlot**
In `src/AcDream.App/UI/UiItemSlot.cs`:
(a) Add fields/props after `SourceKind` area — insert after the `IconTexture` property (~line 22):
```csharp
/// <summary>This cell's own index within its panel (0..17 toolbar; container slot
/// for inventory). Distinct from <see cref="ShortcutNum"/> (the 19 label, -1 on the
/// bottom row). Set by the controller; used as the drag payload's SourceSlot and to
/// identify the drop TARGET slot.</summary>
public int SlotIndex { get; set; } = -1;
/// <summary>What kind of slot this is, for the drag payload (retail InqDropIconInfo
/// flags). Controller overrides; default Inventory.</summary>
public ItemDragSource SourceKind { get; set; } = ItemDragSource.Inventory;
/// <summary>Drag-rollover accept frame (retail ItemSlot_DragOver_Accept 0x060011F9,
/// state id 0x10000041). Configurable; guard id != 0 before resolving.</summary>
public uint DragAcceptSprite { get; set; } = 0x060011F9u;
/// <summary>Drag-rollover reject frame (retail ItemSlot_DragOver_Reject 0x060011F8,
/// state id 0x10000040).</summary>
public uint DragRejectSprite { get; set; } = 0x060011F8u;
/// <summary>Accept/reject overlay state while a drag hovers this cell.</summary>
public enum DragAcceptState { None, Accept, Reject }
private DragAcceptState _dragAccept = DragAcceptState.None;
/// <summary>Current overlay state — internal so unit tests can assert it (InternalsVisibleTo).</summary>
internal DragAcceptState DragAcceptVisual => _dragAccept;
```
(b) Add the two overrides (anywhere in the class, e.g. after `Clear()`):
```csharp
/// <inheritdoc/>
public override object? GetDragPayload()
=> ItemId != 0 ? new ItemDragPayload(ItemId, SourceKind, SlotIndex, this) : null;
/// <inheritdoc/>
public override (uint tex, int w, int h)? GetDragGhost()
=> ItemId != 0 && IconTexture != 0 ? (IconTexture, (int)Width, (int)Height) : null;
/// <summary>Walk up to the containing <see cref="UiItemList"/> (the drop handler owner).</summary>
private UiItemList? FindList()
{
UiElement? e = Parent;
while (e is not null) { if (e is UiItemList l) return l; e = e.Parent; }
return null;
}
```
(c) Replace the `OnEvent` method (currently MouseDown→Clicked) with:
```csharp
/// <inheritdoc/>
public override bool OnEvent(in UiEvent e)
{
switch (e.Type)
{
// Use fires on CLICK (mouse-up over the same cell), not MouseDown — so a
// drag (press + >3px move) does NOT also use the item. UiRoot suppresses the
// post-drag Click (UiRoot.cs FinishDrag returns before the Click emit).
case UiEventType.MouseDown:
return true; // consume the press; no use here
case UiEventType.Click:
Clicked?.Invoke();
return true;
case UiEventType.DragBegin:
return true; // we're the source; payload already pulled by UiRoot
case UiEventType.DragEnter: // pointer entered me mid-drag → ask the list's handler
_dragAccept = (FindList() is { DragHandler: { } h } list
&& e.Payload is ItemDragPayload p && h.OnDragOver(list, this, p))
? DragAcceptState.Accept : DragAcceptState.Reject;
return true;
case UiEventType.DragOver: // UiRoot fires this on LEAVE → neutral
_dragAccept = DragAcceptState.None;
return true;
case UiEventType.DropReleased:
_dragAccept = DragAcceptState.None;
if (e.Data0 == 1 // accepted = dropped on a DIFFERENT element (skips self/empty)
&& FindList() is { DragHandler: { } dh } dl
&& e.Payload is ItemDragPayload dp)
dh.HandleDropRelease(dl, this, dp);
return true;
}
return false;
}
```
(d) In `OnDraw`, add the accept/reject overlay AFTER the digit-overlay block (at the very end of `OnDraw`):
```csharp
// Drag-rollover accept/reject frame (retail SetDragAcceptState 0x10000041/40).
// Guard id != 0 BEFORE resolving — resolve(0) returns the 1×1 magenta placeholder
// with a non-zero GL handle (feedback_ui_resolve_zero_magenta).
if (_dragAccept != DragAcceptState.None && SpriteResolve is not null)
{
uint id = _dragAccept == DragAcceptState.Accept ? DragAcceptSprite : DragRejectSprite;
if (id != 0)
{
var (tex, _, _) = SpriteResolve(id);
if (tex != 0)
ctx.DrawSprite(tex, 0f, 0f, Width, Height, 0f, 0f, 1f, 1f, Vector4.One);
}
}
```
- [ ] **Step 5: Fix the existing controller test that sent MouseDown**
In `tests/AcDream.App.Tests/UI/Layout/ToolbarControllerTests.cs`, in `Click_emitsUseForBoundItem`, change the event from `MouseDown` to `Click` (the test name already says "Click"):
```csharp
// Use now fires on Click (mouse-up), not MouseDown — drag/click disambiguation.
slots[Row1[0]].Cell.OnEvent(new UiEvent(0u, null, UiEventType.Click));
```
- [ ] **Step 6: Run to verify it passes**
Run: `dotnet test tests/AcDream.App.Tests/AcDream.App.Tests.csproj --filter "FullyQualifiedName~DragDropSpine|FullyQualifiedName~ToolbarController|FullyQualifiedName~UiItemSlot"`
Expected: PASS (the new DragDropSpine cases, the fixed ToolbarController test, and all UiItemSlot tests).
- [ ] **Step 7: Commit**
```bash
git add src/AcDream.App/UI/UiElement.cs src/AcDream.App/UI/UiItemSlot.cs tests/AcDream.App.Tests/UI/DragDropSpineTests.cs tests/AcDream.App.Tests/UI/Layout/ToolbarControllerTests.cs
git commit -m "feat(ui): D.5.3/B.1 — UiItemSlot drag source + drop target + accept/reject overlay" -m "Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>"
```
---
## Task 3: UiRoot payload injection + cursor ghost (+ AP-47)
**Files:**
- Modify: `src/AcDream.App/UI/UiRoot.cs`
- Modify: `docs/architecture/retail-divergence-register.md`
- Test: `tests/AcDream.App.Tests/UI/DragDropSpineTests.cs` (add full-chain cases)
- [ ] **Step 1: Write the failing tests** (append to `DragDropSpineTests.cs`, inside the class)
```csharp
// ── Full UiRoot chain: arming + use-vs-drag ─────────────────────────────
// A bound, hit-testable slot inside a list, sized for the hit-test.
private static (UiRoot root, UiItemList list, UiItemSlot cell) RootWithBoundSlot(uint itemId)
{
var root = new UiRoot { Width = 800, Height = 600 };
var list = new UiItemList(_ => (1u, 1, 1)) { Left = 0, Top = 0, Width = 32, Height = 32 };
// Tests don't run OnDraw (which sizes the cell), so size the cell explicitly.
list.Cell.Width = 32; list.Cell.Height = 32;
if (itemId != 0) list.Cell.SetItem(itemId, 0x99u);
root.AddChild(list);
return (root, list, list.Cell);
}
[Fact]
public void BeginDrag_arms_whenPayloadNonNull()
{
var (root, _, cell) = RootWithBoundSlot(0x5001u);
root.OnMouseDown(UiMouseButton.Left, 10, 10);
root.OnMouseMove(20, 10); // >3px → promote to drag
Assert.Same(cell, root.DragSource);
Assert.IsType<ItemDragPayload>(root.DragPayload);
}
[Fact]
public void BeginDrag_doesNotArm_whenPayloadNull_emptySlot()
{
var (root, _, _) = RootWithBoundSlot(0u); // empty cell → GetDragPayload null
root.OnMouseDown(UiMouseButton.Left, 10, 10);
root.OnMouseMove(20, 10);
Assert.Null(root.DragSource); // never armed
}
[Fact]
public void Click_withoutDrag_firesUse()
{
var (root, _, cell) = RootWithBoundSlot(0x5001u);
bool used = false;
cell.Clicked = () => used = true;
root.OnMouseDown(UiMouseButton.Left, 10, 10);
root.OnMouseUp(UiMouseButton.Left, 10, 10); // no move → Click emitted
Assert.True(used);
}
[Fact]
public void CompletedDrag_doesNotFireUse()
{
var (root, _, cell) = RootWithBoundSlot(0x5001u);
bool used = false;
cell.Clicked = () => used = true;
root.OnMouseDown(UiMouseButton.Left, 10, 10);
root.OnMouseMove(20, 10); // promote to drag
root.OnMouseUp(UiMouseButton.Left, 20, 10); // FinishDrag, NOT Click
Assert.False(used);
}
```
- [ ] **Step 2: Run to verify it fails**
Run: `dotnet test tests/AcDream.App.Tests/AcDream.App.Tests.csproj --filter "FullyQualifiedName~DragDropSpine"`
Expected: FAIL — `BeginDrag_doesNotArm_whenPayloadNull_emptySlot` fails (current `BeginDrag` arms unconditionally with `payload: null`, so `DragSource` is non-null), and `CompletedDrag_doesNotFireUse` may behave wrong because a null-payload drag would still arm.
- [ ] **Step 3: Change BeginDrag to pull the payload + cancel when null**
In `src/AcDream.App/UI/UiRoot.cs`, replace the `BeginDrag` method (~line 450):
```csharp
private void BeginDrag(UiElement source)
{
// Pull the payload from the source; a null payload (e.g. an empty item cell)
// CANCELS the drag — retail's ItemList_BeginDrag only arms an occupied cell.
var payload = source.GetDragPayload();
if (payload is null) { _dragCandidate = false; return; }
DragSource = source;
DragPayload = payload;
var e = new UiEvent(source.EventId, source, UiEventType.DragBegin, Payload: payload);
source.OnEvent(in e);
}
```
Update the single call site (~line 188 in `OnMouseMove`) from `BeginDrag(Captured, payload: null);` to:
```csharp
BeginDrag(Captured);
```
- [ ] **Step 4: Add the cursor ghost to Draw**
In `src/AcDream.App/UI/UiRoot.cs`, replace the `Draw` method (~line 133) so it paints the ghost in the overlay layer, and add the helper + constant:
```csharp
public void Draw(UiRenderContext ctx)
{
DrawSelfAndChildren(ctx);
// Open popups/menus + the drag ghost draw ON TOP of the whole tree (overlay layer).
ctx.BeginOverlayLayer();
DrawOverlays(ctx);
DrawDragGhost(ctx);
ctx.EndOverlayLayer();
}
/// <summary>Translucency of the cursor-following drag ghost. AP-47: we reuse the
/// item's full composited icon at this alpha rather than retail's dedicated
/// underlay-less m_pDragIcon.</summary>
private const float GhostAlpha = 0.6f;
/// <summary>Paint the drag ghost at the cursor. The texture comes from the source
/// element's <see cref="UiElement.GetDragGhost"/> so UiRoot stays item-agnostic; the
/// ghost is NOT a tree element, so it never intercepts hit-tests.</summary>
private void DrawDragGhost(UiRenderContext ctx)
{
if (DragSource?.GetDragGhost() 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));
}
```
- [ ] **Step 5: Run to verify it passes**
Run: `dotnet test tests/AcDream.App.Tests/AcDream.App.Tests.csproj --filter "FullyQualifiedName~DragDropSpine"`
Expected: PASS (all DragDropSpine tests).
- [ ] **Step 6: Add the AP-47 divergence row**
In `docs/architecture/retail-divergence-register.md`: bump the AP section header count `## 3. Documented approximation (AP) — 42 rows``— 43 rows`, and append this row at the END of the AP table (after the `AP-46` row, before the section's `---`):
```markdown
| AP-47 | Cursor drag ghost reuses the full composited `m_pIcon` at reduced alpha (`GhostAlpha=0.6`) instead of retail's dedicated `m_pDragIcon` (base + custom-overlay, NO type-default underlay) | `src/AcDream.App/UI/UiRoot.cs` (`DrawDragGhost`/`GhostAlpha`) → `src/AcDream.App/UI/UiItemSlot.cs` (`GetDragGhost`) | Cosmetic only — the dragged item is still unambiguously identifiable; building the second underlay-less composite is deferred polish; the ghost is item-agnostic in UiRoot via `GetDragGhost()` | The ghost shows the opaque type-default underlay backing rather than retail's underlay-less translucent copy — a subtle look difference while dragging, no functional effect | `IconData::RenderIcons` acclient_2013_pseudo_c.txt:407594-407625 (m_pDragIcon path); deep-dive §3.2/§5.5 |
```
- [ ] **Step 7: Commit**
```bash
git add src/AcDream.App/UI/UiRoot.cs docs/architecture/retail-divergence-register.md tests/AcDream.App.Tests/UI/DragDropSpineTests.cs
git commit -m "feat(ui): D.5.3/B.1 — UiRoot payload injection + cursor drag ghost (AP-47)" -m "Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>"
```
---
## Task 4: Toolbar stub handler + wiring (+ TS-33 + registration tests)
**Files:**
- Modify: `src/AcDream.App/UI/Layout/ToolbarController.cs`
- Modify: `tests/AcDream.App.Tests/UI/Layout/ToolbarControllerTests.cs`
- Modify: `docs/architecture/retail-divergence-register.md`
- [ ] **Step 1: Write the failing tests** (append to `ToolbarControllerTests.cs`, inside the class)
```csharp
// ── B.1: drag-drop spine wiring ──────────────────────────────────────────
/// <summary>Bind registers the controller as each slot list's drag handler and
/// stamps every cell's SlotIndex + SourceKind=ShortcutBar.</summary>
[Fact]
public void Bind_registersDragHandler_andStampsSlots()
{
var (layout, slots, _) = FakeToolbar();
var repo = new ClientObjectTable();
var ctrl = ToolbarController.Bind(layout, repo,
() => Array.Empty<PlayerDescriptionParser.ShortcutEntry>(),
iconIds: (_,_,_,_,_) => 0u, useItem: _ => { });
for (int i = 0; i < Row1.Length; i++)
{
Assert.Same(ctrl, slots[Row1[i]].DragHandler);
Assert.Equal(i, slots[Row1[i]].Cell.SlotIndex);
Assert.Equal(ItemDragSource.ShortcutBar, slots[Row1[i]].Cell.SourceKind);
}
// Bottom row slots are indices 9..17.
for (int j = 0; j < Row2.Length; j++)
Assert.Equal(9 + j, slots[Row2[j]].Cell.SlotIndex);
}
/// <summary>OnDragOver accepts a real item (ObjId != 0). Eligibility (IsShortcutEligible)
/// is Stream B.2; the stub accepts any non-empty payload.</summary>
[Fact]
public void OnDragOver_acceptsRealItem()
{
var (layout, slots, _) = FakeToolbar();
var ctrl = ToolbarController.Bind(layout, new ClientObjectTable(),
() => Array.Empty<PlayerDescriptionParser.ShortcutEntry>(),
iconIds: (_,_,_,_,_) => 0u, useItem: _ => { });
var list = slots[Row1[0]];
var payload = new ItemDragPayload(0x5001u, ItemDragSource.Inventory, 0, new UiItemSlot());
Assert.True(ctrl.OnDragOver(list, list.Cell, payload));
}
/// <summary>HandleDropRelease is a logging stub (TS-33): it must not throw and must not
/// mutate the target slot (no wire / no store yet).</summary>
[Fact]
public void HandleDropRelease_isInertStub()
{
var (layout, slots, _) = FakeToolbar();
var ctrl = ToolbarController.Bind(layout, new ClientObjectTable(),
() => Array.Empty<PlayerDescriptionParser.ShortcutEntry>(),
iconIds: (_,_,_,_,_) => 0u, useItem: _ => { });
var list = slots[Row1[2]];
var payload = new ItemDragPayload(0x5001u, ItemDragSource.ShortcutBar, 0, new UiItemSlot());
var ex = Record.Exception(() => ctrl.HandleDropRelease(list, list.Cell, payload));
Assert.Null(ex);
Assert.Equal(0u, list.Cell.ItemId); // unchanged — inert stub
}
```
- [ ] **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` doesn't implement `IItemListDragHandler` (`OnDragOver`/`HandleDropRelease`), `DragHandler` not registered.
- [ ] **Step 3: Make ToolbarController implement the handler + wire registration**
In `src/AcDream.App/UI/Layout/ToolbarController.cs`:
(a) Change the class declaration:
```csharp
public sealed class ToolbarController : IItemListDragHandler
```
(b) In the constructor, extend the slot loop (currently `_slots[i] = layout.FindElement(SlotIds[i]) as UiItemList; if (_slots[i] is { } list) WireClick(list);`) to also register the handler + stamp the cell:
```csharp
for (int i = 0; i < SlotIds.Length; i++)
{
_slots[i] = layout.FindElement(SlotIds[i]) as UiItemList;
if (_slots[i] is { } list)
{
WireClick(list);
// B.1 drag-drop spine: this controller is the drop handler for every
// toolbar slot list; each cell knows its slot index + that it's a
// shortcut-bar source (retail UIElement_ItemList::RegisterItemListDragHandler).
list.RegisterDragHandler(this);
list.Cell.SlotIndex = i;
list.Cell.SourceKind = AcDream.App.UI.ItemDragSource.ShortcutBar;
}
}
```
(c) Add the interface implementation (e.g. at the end of the class):
```csharp
// ── IItemListDragHandler (B.1 spine) ─────────────────────────────────────
// Retail: gmToolbarUI is the m_dragHandler for every shortcut slot list.
// The accept/reject gate (IsShortcutEligible) and the AddShortcut/RemoveShortcut
// wire are Stream B.2; this stub accepts any real item and LOGS the drop (TS-33).
/// <inheritdoc/>
public bool OnDragOver(UiItemList targetList, UiItemSlot targetCell, ItemDragPayload payload)
=> payload.ObjId != 0;
/// <inheritdoc/>
public void HandleDropRelease(UiItemList targetList, UiItemSlot targetCell, ItemDragPayload payload)
{
// TS-33: no wire yet. Stream B.2 replaces this with the ShortcutStore mutation +
// AddShortcut 0x019C / RemoveShortcut 0x019D sends (gmToolbarUI::HandleDropRelease :197971).
Console.WriteLine($"[B.2 TODO] drop obj 0x{payload.ObjId:X8} from {payload.SourceKind} " +
$"slot {payload.SourceSlot} → toolbar slot {targetCell.SlotIndex}");
}
```
(d) Confirm `using AcDream.App.UI;` is present at the top (it is). The `ItemDragSource`/`ItemDragPayload`/`UiItemSlot`/`UiItemList`/`IItemListDragHandler` types are all in `AcDream.App.UI`.
- [ ] **Step 4: Run to verify it passes**
Run: `dotnet test tests/AcDream.App.Tests/AcDream.App.Tests.csproj --filter "FullyQualifiedName~ToolbarController"`
Expected: PASS (the 3 new B.1 tests + all existing ToolbarController tests, including the fixed Click test).
- [ ] **Step 5: Add the TS-33 divergence row**
In `docs/architecture/retail-divergence-register.md`: bump the TS section header count `## 4. Temporary stopgap (TS) — 31 rows``— 32 rows`, and append this row at the END of the TS table (after the `TS-32` row, before the section's `---`):
```markdown
| TS-33 | Toolbar `IItemListDragHandler.HandleDropRelease` is a logging stub — no `AddShortcut 0x019C` / `RemoveShortcut 0x019D` wire, no mutable `ShortcutStore`; a drop onto the bar logs and does nothing | `src/AcDream.App/UI/Layout/ToolbarController.cs` (`HandleDropRelease`) | The B.1 spine ships the drag machine (payload/ghost/overlay/dispatch) independent of the wire; the shortcut store + add/remove/reorder sends are Stream B.2, which needs the inventory window as a drag source too | A player dragging onto/within the hotbar sees the ghost + accept overlay but the drop is inert until B.2 — add/remove/reorder don't persist | `gmToolbarUI::HandleDropRelease` acclient_2013_pseudo_c.txt:197971; `AddShortcut`/`RemoveShortcut` 0x019C/0x019D |
```
- [ ] **Step 6: Run the FULL app test suite (no regressions)**
Run: `dotnet build src/AcDream.App/AcDream.App.csproj -c Debug` then `dotnet test tests/AcDream.App.Tests/AcDream.App.Tests.csproj`
Expected: build green; all tests pass.
- [ ] **Step 7: Commit**
```bash
git add src/AcDream.App/UI/Layout/ToolbarController.cs tests/AcDream.App.Tests/UI/Layout/ToolbarControllerTests.cs docs/architecture/retail-divergence-register.md
git commit -m "feat(ui): D.5.3/B.1 — toolbar drag handler stub + spine wiring (TS-33)" -m "Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>"
```
---
## Final verification
- [ ] **Full solution build + test:** `dotnet build AcDream.slnx -c Debug` and `dotnet test tests/AcDream.App.Tests/AcDream.App.Tests.csproj` — both green.
- [ ] **Self-check the acceptance criteria** in the spec §9 (UiRoot item-agnostic; AP-47 + TS-33 added; counts bumped).
- [ ] **Hand back for visual verification** (the user runs the client): with `ACDREAM_RETAIL_UI=1`, in-world — grab a hotbar item and drag: a translucent ghost follows the cursor; the hovered slot shows the accept (green) frame; on release over another slot the log prints `[B.2 TODO] drop obj … → toolbar slot N`; the source item stays put; a plain click still USES the item. (Launch per CLAUDE.md "Running the client" — build green first.)
- [ ] **Update memory** if a durable lesson emerged; note in `docs/ISSUES.md` / roadmap only if scope shifted.
```

View 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.

View file

@ -0,0 +1,367 @@
# D.2b drag-drop spine (Stream B.1) — design
**Date:** 2026-06-20
**Phase:** D.2b retail-UI engine → D.5 core panels. Stream **B.1** of the
2026-06-18 handoff (the shared drag-drop infra that BOTH the shortcut-drag stream
(B) and the inventory window (C) sit on). Build it once, well — it is the
critical-path lynchpin.
**Branch:** `claude/hopeful-maxwell-214a12` (== main == `31d7ffd`).
**Spec author:** lead-engineer brainstorm 2026-06-20 (4 design decisions confirmed
by the user).
**Read alongside:**
- `docs/research/2026-06-16-ui-item-slot-icon-dragdrop-spine-deep-dive.md` §5 / §5.7
(the pseudocode IS the spec; sub-element + flag anchors).
- `docs/research/2026-06-18-d53-bar-finish-and-inventory-handoff.md` §B.1
(current-code readiness).
- `claude-memory/project_d2b_retail_ui.md` (the toolkit), `feedback_ui_resolve_zero_magenta`
(the 0-id → magenta footgun; guard on the id, not the GL handle).
---
## 1. Goal & non-goals
**Goal.** Complete the widget-level drag-drop machine so a player can pick up an
item icon, see a translucent ghost track the cursor, watch hovered slots flip to
**accept (green) / reject (red)**, and drop — at which point the drop is dispatched
to the owning panel's handler with a fully-resolved payload. `UiRoot` already holds
the *device-level* drag state machine (`BeginDrag`/`UpdateDragHover`/`FinishDrag`,
promoted on >3 px move, live-wired to Silk.NET). This stream supplies the five
missing pieces (handoff §B.1): payload injection, the cursor ghost, drop-target
hooks on the cell, the accept/reject overlay, and the panel-handler interface.
**In scope (the spine — generic, shared):**
1. Payload injection into `UiRoot.BeginDrag` (today passes `payload: null`).
2. A cursor-following drag ghost, painted by `UiRoot`, item-agnostic.
3. Drop-target hooks on `UiItemSlot` (DragEnter/Over/DropReleased → accept/reject
overlay + dispatch).
4. The `IItemListDragHandler` interface + registration on `UiItemList`.
5. A typed `ItemDragPayload` describing what is dragged and where from.
6. Moving the item-cell's use-trigger from MouseDown → Click (drag/click
disambiguation; also more retail-faithful).
7. A **visible toolbar stub handler** so the chain is verifiable this session.
**Out of scope (explicitly deferred — later streams):**
- The `AddShortcut 0x019C` / `RemoveShortcut 0x019D` wire and the mutable
`ShortcutStore` (Stream **B.2**).
- The inventory window, `UiItemList` N-cell grid mode, window manager
(Streams **C / window-mgr**).
- Stack-split drag (the entry/slider `0x100001A3/A4`), give-to-NPC, drop-to-ground,
wield wire — all per-panel **HandleDropRelease** opcode selection (the deep-dive
§5.7 opcode table; lands with each consuming panel).
- The faithful `m_pDragIcon` (base+overlay-no-underlay) drag composite — MVP reuses
the existing full icon at reduced alpha (AP-47).
- Esc-to-cancel-drag (retail has it; future polish).
---
## 2. Retail grounding (the four confirmed decisions)
The named decomp (`acclient_2013_pseudo_c.txt`) was greped to anchor each piece;
the spine introduces **no new wire format** (opcodes are B.2/C), so the workflow's
grep→cross-ref→pseudocode step applies to the *event-chain semantics* and
`InqDropIconInfo`, both confirmed below.
### 2.1 The retail chain (deep-dive §5.15.5)
- The cell `UIElement_UIItem` is BOTH drag source and drop target. On
left-press-and-move it walks to its parent `UIElement_ItemList` and calls
`ItemList_BeginDrag` (`ListenToElementMessage` decomp 229344, msg `0x21`).
- Every cell is a drop target *by construction*`PostInit` sets the
`CatchDroppedItem` attribute `0x36` true (decomp 229744).
- On drag-over the cell forwards to `ItemList_DragOver`; the LIST routes to its
registered `m_dragHandler` (`RegisterItemListDragHandler`, decomp 230461;
confirmed at acclient 0x004a539e + the gmToolbarUI block 0x004bdd89).
- `InqDropIconInfo(dragElement, &objId, &containerId, &flags)` reads the dragged
element's properties (decomp 230533); the **flags** word (confirmed live at
`gmToolbarUI` 0x004bd162 / 0x004bd1af): **`flags & 0xE == 0`** ⇒ fresh-from-
inventory; **`flags & 4`** ⇒ within-list reorder. Accept/reject overlay state ids:
neutral `0x1000003f`, **reject `0x10000040`**, accept `0x10000041`
(`SetDragAcceptState`, confirmed 0x004bd16d).
- The drag ghost `m_dragIcon` (id `0x10000345`) is a root-level translucent copy of
the icon, tracking the cursor (decomp 229738; §5.5).
### 2.2 How the four picks map (all confirmed with the user)
| # | Decision | Pick | Retail basis |
|---|---|---|---|
| 1 | Payload shape | typed `ItemDragPayload` record, snapshotted at begin | mirrors `InqDropIconInfo`'s {objId, container, flags}; our `flags` derived at drop from SourceKind+target |
| 2 | Ghost render | painted by `UiRoot` via a generic `GetDragGhost()` hook | retail's root-level `m_dragElement` floats above all windows |
| 3 | Drop unit | **cell** hits + shows overlay; **list** owns the handler | retail cell→`ItemList_DragOver``m_dragHandler` |
| 4 | PR scope | infra + a **visible toolbar stub** handler | so the ghost/overlay/dispatch are confirmable now; wire is B.2 |
---
## 3. Architecture — components & boundaries
All code lives in `src/AcDream.App/UI/` (the retail UI tree; NOT the ImGui devtools
path). No new project references; `UiRoot` stays item-agnostic (it learns the
payload/ghost only through two `UiElement` virtuals).
```
Silk.NET mouse ─► UiRoot (device drag machine; UNCHANGED chain, + payload pull + ghost)
│ BeginDrag(source): payload = source.GetDragPayload(); cancel if null
│ Draw(): paint source.GetDragGhost() at the cursor (overlay layer)
UiItemSlot (the CELL — drag source + drop target + overlay owner)
│ GetDragPayload() → ItemDragPayload | null (null = empty cell, no drag)
│ GetDragGhost() → (tex,w,h) | null
│ OnEvent: Click→use; DragEnter→ask handler→overlay; DropReleased→dispatch
▼ (walks Parent to)
UiItemList (the GRID — owns the registered handler)
│ DragHandler : IItemListDragHandler
IItemListDragHandler (a panel controller implements this)
bool OnDragOver(list, cell, payload) → accept/reject (overlay only)
void HandleDropRelease(list, cell, payload) → the action (wire = per panel)
ToolbarController (registers itself; STUB this stream — logs, no wire = TS-33)
```
**Boundary rules honored:** Rule 1 (no fat feature body in `GameWindow` — all logic
in the UI classes + the controller); Rule 3 (panels target the toolkit, never a
backend); `UiRoot` depends on nothing item-specific (Rule-2-spirit: the generic
toolkit core doesn't reach up into the item layer — it pulls through virtuals).
---
## 4. New & changed types (precise signatures)
### 4.1 NEW — `ItemDragPayload` + `ItemDragSource` (`UI/ItemDragPayload.cs`)
```csharp
namespace AcDream.App.UI;
/// <summary>Where a dragged item came from — the retail InqDropIconInfo flag
/// distinction (flags&0xE==0 fresh-from-inventory vs flags&4 within-list reorder)
/// expressed as a typed enum. The drop handler maps SourceKind+target back to the
/// fresh-vs-reorder decision. Decomp: gmToolbarUI 0x004bd162 / 0x004bd1af.</summary>
public enum ItemDragSource { Inventory, ShortcutBar, Equipment, Ground }
/// <summary>Snapshot of a drag-in-progress, taken at drag-begin (so a server move
/// arriving mid-drag can't mutate it under us). Port of retail's m_dragElement +
/// InqDropIconInfo out-params (objId/container/flags, decomp 230533).
/// SourceContainer is NOT stored — the handler resolves the LIVE container from
/// ClientObjectTable.Get(ObjId).ContainerId at drop (single source of truth; same
/// value retail reads off the element, see §7).</summary>
public sealed record ItemDragPayload(
uint ObjId, // dragged weenie guid (retail itemID, +0x5FC)
ItemDragSource SourceKind, // what kind of slot it left
int SourceSlot, // the source cell's SlotIndex (retail m_lastShortcutNumDragged)
UiItemSlot SourceCell); // back-ref: reorder-restore / clear source state / ghost texture
```
### 4.2 CHANGED — `UiElement` (two new virtuals; default null)
```csharp
/// <summary>The data this element carries when a drag begins. UiRoot.BeginDrag pulls
/// this; a NULL return CANCELS the drag (retail: ItemList_BeginDrag only arms an
/// occupied cell). Default null = not draggable.</summary>
public virtual object? GetDragPayload() => null;
/// <summary>The texture UiRoot paints at the cursor while this element is the drag
/// source: (GL handle, width, height). Null = no ghost. Keeps UiRoot item-agnostic.</summary>
public virtual (uint tex, int w, int h)? GetDragGhost() => null;
```
### 4.3 CHANGED — `UiRoot`
- `BeginDrag(UiElement source)` (drop the `payload` param): pull
`var payload = source.GetDragPayload();` → if `null`, set `_dragCandidate = false`
and **return without arming** (no `DragSource`, no event). Else set
`DragSource`/`DragPayload`, fire `DragBegin` with the payload. Call site (line 188)
becomes `BeginDrag(Captured);`.
- `Draw(ctx)`: after `DrawOverlays`, still inside the overlay layer, draw the ghost:
```csharp
if (DragSource?.GetDragGhost() is { tex: var t, w: var gw, h: var gh } && t != 0)
ctx.DrawSprite(t, MouseX - gw/2f, MouseY - gh/2f, gw, gh, 0,0,1,1,
new Vector4(1,1,1, GhostAlpha)); // GhostAlpha = 0.6f
```
Root transform is at origin during `Draw`, so cursor coords are absolute. The
ghost is NOT a tree element → it never intercepts hit-tests.
### 4.4 CHANGED — `UiItemSlot`
- Add `public int SlotIndex { get; set; } = -1;` (the cell's own index within its
panel — 0..17 toolbar, container slot for inventory; distinct from `ShortcutNum`,
the 19 LABEL which is -1 on the bottom row).
- Add `public ItemDragSource SourceKind { get; set; } = ItemDragSource.Inventory;`
(controller overrides; toolbar = `ShortcutBar`).
- Add drag-accept overlay sprites (confirmed ids; configurable; guard `id != 0`
before resolving — the magenta footgun):
```csharp
public uint DragAcceptSprite { get; set; } = 0x060011F9u; // ItemSlot_DragOver_Accept
public uint DragRejectSprite { get; set; } = 0x060011F8u; // ItemSlot_DragOver_Reject
private enum DragAccept { None, Accept, Reject }
private DragAccept _dragAccept = DragAccept.None;
```
- `GetDragPayload()``ItemId != 0 ? new ItemDragPayload(ItemId, SourceKind, SlotIndex, this) : null`.
- `GetDragGhost()``ItemId != 0 && IconTexture != 0 ? (IconTexture, (int)Width, (int)Height) : null`.
- **Move use from MouseDown → Click** (the disambiguation fix): `OnEvent` keeps
`MouseDown``return true` (still consume the press, but DON'T fire use), and adds
`Click` (0x01) → `Clicked?.Invoke(); return true`. (`UiRoot` already suppresses the
post-drag Click at line 311, so a completed drag won't also use the item; a
press-release-without-move still emits Click → use.) `WireClick` in
`ToolbarController` is unchanged — it still assigns `Clicked`.
- `OnEvent` new cases (acdream's UiEventType is the contract — these are acdream's
DragEnter/DragOver/DropReleased, NOT retail's raw per-cell 0x21/0x3e/0x15 codes;
see §6 note):
```csharp
case UiEventType.DragBegin: return true; // we're the source; payload already pulled
case UiEventType.DragEnter: // pointer entered me mid-drag
_dragAccept = (FindList() is {} l && l.DragHandler is {} h
&& e.Payload is ItemDragPayload p && h.OnDragOver(l, this, p))
? DragAccept.Accept : DragAccept.Reject;
return true;
case UiEventType.DragOver: _dragAccept = DragAccept.None; return true; // UiRoot fires on LEAVE
case UiEventType.DropReleased:
_dragAccept = DragAccept.None;
if (e.Data0 == 1 // accepted = dropped on a DIFFERENT element (skips self/empty)
&& FindList() is {} dl && dl.DragHandler is {} dh
&& e.Payload is ItemDragPayload dp)
dh.HandleDropRelease(dl, this, dp);
return true;
```
`FindList()` walks `Parent` until a `UiItemList`. The accept/reject overlay draws
in `OnDraw` on top of the icon+digit, `id != 0` guarded.
### 4.5 NEW — `IItemListDragHandler` (`UI/IItemListDragHandler.cs`)
```csharp
/// <summary>A panel controller implements this and registers itself on each of its
/// UiItemLists. Port of retail's m_dragHandler vtable (RegisterItemListDragHandler,
/// decomp 230461). OnDragOver decides the accept/reject OVERLAY only (advisory);
/// HandleDropRelease is authoritative — it does the action (or no-ops to reject).</summary>
public interface IItemListDragHandler
{
bool OnDragOver(UiItemList targetList, UiItemSlot targetCell, ItemDragPayload payload);
void HandleDropRelease(UiItemList targetList, UiItemSlot targetCell, ItemDragPayload payload);
}
```
### 4.6 CHANGED — `UiItemList`
- Add `public IItemListDragHandler? DragHandler { get; private set; }` +
`public void RegisterDragHandler(IItemListDragHandler h) => DragHandler = h;`.
### 4.7 CHANGED — `ToolbarController` (the stub handler — TS-33)
- `: IItemListDragHandler`. In the ctor, after caching slots, set each cell's
`SlotIndex = i` and `SourceKind = ItemDragSource.ShortcutBar`, and
`list.RegisterDragHandler(this)`.
- `OnDragOver``payload.ObjId != 0` (accept any real item; retail's
`IsShortcutEligible` gate is B.2).
- `HandleDropRelease` → `Console.WriteLine($"[B.2 TODO] drop obj 0x{payload.ObjId:X8} " +
$"from {payload.SourceKind} slot {payload.SourceSlot} → toolbar slot {targetCell.SlotIndex}");`
No store mutation, no wire. (TS-33; replaced wholesale in B.2.)
---
## 5. Data flow — frame by frame
1. **Press** on an occupied toolbar slot → `UiRoot.OnMouseDown` sets `Captured = cell`,
`_dragCandidate = true` (cell isn't `CapturesPointerDrag`, isn't a window-drag).
2. **Move > 3 px**`UiRoot.OnMouseMove` calls `BeginDrag(cell)` → pulls
`cell.GetDragPayload()` = `ItemDragPayload{ObjId, ShortcutBar, srcSlot, cell}`
(non-null since occupied) → arms `DragSource`/`DragPayload`, fires `DragBegin`.
3. **Each move while dragging**`UpdateDragHover(x,y)` hit-tests; on target CHANGE
it fires `DragOver` (LEAVE) on the old cell → its overlay resets to neutral, and
`DragEnter` on the new cell → it asks its list's handler `OnDragOver` → overlay
flips accept/reject. Meanwhile `UiRoot.Draw` paints the ghost at the cursor.
4. **Release**`UiRoot.OnMouseUp`: `DragSource` set → `FinishDrag(x,y)` delivers
`DropReleased` to the cell under the cursor (`Data0 = 1` if a different element).
The cell → its list's `HandleDropRelease(list, cell, payload)` → (stub) logs.
`DragSource`/`DragPayload` cleared → ghost stops. Capture released.
5. **Empty-slot drag attempt** → step 2 `GetDragPayload()` returns null →
`_dragCandidate=false`, nothing armed; the eventual mouse-up emits Click (no-op,
`Clicked` guards `ItemId != 0`).
---
## 6. Edge cases & notes
- **Event-id namespace.** acdream's `UiEventType` (DragBegin 0x15, DragEnter 0x21,
DragOver 0x1C, DropReleased 0x3E) is the contract the cell handles — these are
`UiRoot`'s constants and differ from retail's raw per-cell `ListenToElementMessage`
codes (0x21 begin / 0x3e over / 0x15 drop). Do NOT try to match the retail per-cell
codes; the SEMANTICS match, the numbers are `UiRoot`'s. (IA-12 already covers "UI
toolkit mirrors behavior, not byte-layout.")
- **Hover re-eval cadence.** `UiRoot.UpdateDragHover` early-returns when the target
is unchanged, so accept/reject is computed once per cell-enter, not every move.
For item cells the decision is constant per (cell, payload), so this is
behavior-equivalent to retail's per-move `MouseOverTop` — no register row.
- **Self-drop / drop-on-nothing.** `FinishDrag` sets `Data0=0` when the target is the
source or null; the cell gates `HandleDropRelease` on `Data0==1`, so both are clean
no-ops (= "put it back").
- **Magenta footgun.** `DragAcceptSprite`/`DragRejectSprite` default non-zero; the
`OnDraw` overlay still guards `if (id != 0)` before `SpriteResolve`, never on the
returned GL handle (`feedback_ui_resolve_zero_magenta`).
- **No heavy diagnostics in the render loop.** The stub's `Console.WriteLine` fires
only on a discrete drop event (not per frame), so it can't eat dt-based animations.
---
## 7. Divergence register rows (add in the IMPLEMENTATION commit)
- **AP-47** (Documented approximation): *Drag ghost reuses the full composited
`m_pIcon` at reduced alpha (`GhostAlpha=0.6`) instead of retail's dedicated
`m_pDragIcon` (base + custom-overlay, NO underlay).* Where: `UiRoot.Draw` +
`UiItemSlot.GetDragGhost`. Why safe: cosmetic only — the dragged item is still
unambiguously identifiable; building the second composite is deferred polish. Risk:
the ghost shows the opaque type-default underlay backing rather than retail's
underlay-less translucent copy. Oracle: `IconData::RenderIcons` 407594-407625
(m_pDragIcon path); deep-dive §3.2/§5.5.
- **TS-33** (Temporary stopgap): *Toolbar `IItemListDragHandler.HandleDropRelease`
is a logging stub — no `AddShortcut 0x019C`/`RemoveShortcut 0x019D` wire, no
`ShortcutStore` mutation.* Where: `ToolbarController.HandleDropRelease`. Awaiting:
Stream B.2. Risk: a drop onto the bar visibly does nothing but log; reorder/add/
remove are inert until B.2. Oracle: `gmToolbarUI::HandleDropRelease`
acclient_2013_pseudo_c.txt:197971.
**Note (not a register row):** the payload omits `SourceContainer`; the handler
resolves the live container via `ClientObjectTable.Get(ObjId).ContainerId` at drop.
Same container id retail reads off the dragged element — single source of truth, no
stale snapshot → no observable behavior deviation.
---
## 8. Testing (conformance)
App-layer tests in `tests/AcDream.App.Tests/UI/` (no GL — pure logic):
1. `GetDragPayload` returns null for an empty cell, a correct `ItemDragPayload` for a
bound cell (ObjId/SourceKind/SourceSlot/SourceCell).
2. `BeginDrag` does NOT arm (`DragSource == null`) when the source's payload is null;
arms + fires `DragBegin` when non-null.
3. Drop dispatch: a fake `IItemListDragHandler` registered on a `UiItemList` receives
`OnDragOver` on DragEnter and `HandleDropRelease` on DropReleased with the right
`(list, cell, payload)`; NOT called on a `Data0==0` self/empty drop.
4. Accept/reject overlay state flips to Accept when the handler returns true, Reject
when false, None on leave.
5. Use-vs-drag: a Click (no intervening drag) fires `Clicked`; a completed drag does
not (drive `UiRoot` MouseDown→move>3px→MouseUp and assert `Clicked` didn't fire).
6. `FindList()` resolves the parent `UiItemList` through the cell's `Parent` chain.
Drive these through `UiRoot`'s public `OnMouseDown/OnMouseMove/OnMouseUp` where
possible (integration-level), so the device chain is exercised, not just the cell in
isolation.
---
## 9. Acceptance criteria
- [ ] `dotnet build` green; `dotnet test` green (existing + the new §8 tests).
- [ ] `UiRoot` has zero compile-time dependency on `UiItemSlot`/`ItemDragPayload`
(item-agnostic; only the two `UiElement` virtuals bridge).
- [ ] AP-47 + TS-33 rows added in the implementation commit; counts bumped.
- [ ] **Visual (user):** with `ACDREAM_RETAIL_UI=1`, in-world, grab a hotbar item
and drag it: a translucent ghost follows the cursor; the hovered slot shows the
accept (green) frame, a slot that rejects shows red; on release over another
slot the log prints `[B.2 TODO] drop obj … → toolbar slot N`; the source item
stays put (no wire yet). A plain click still USES the item; a drag does not.
- [ ] Memory updated if a durable lesson emerged; roadmap/ISSUES note if needed.
---
## 10. Subagent task slices (for the plan)
1. **Toolkit core**`ItemDragPayload`/`ItemDragSource`, the two `UiElement`
virtuals, `UiRoot.BeginDrag` payload pull + cancel, `UiRoot.Draw` ghost.
2. **Cell hooks**`UiItemSlot`: `SlotIndex`/`SourceKind`, `GetDragPayload`/
`GetDragGhost`, MouseDown→Click move, the DragEnter/Over/DropReleased cases +
accept/reject overlay; `IItemListDragHandler`; `UiItemList.DragHandler`.
3. **Toolbar stub + wiring**`ToolbarController : IItemListDragHandler`, register +
set SlotIndex/SourceKind, the logging `HandleDropRelease`; register rows.
4. **Tests** — the §8 suite.
(Slices 1→2 are sequential; 3 depends on 2; 4 can follow each.)

View file

@ -0,0 +1,181 @@
# D.2b toolbar collapse-to-one-row — design
**Date:** 2026-06-20
**Phase:** D.2b retail-UI, toolbar polish (follows D.5.3 B.1/B.2, both visually confirmed this session).
**Branch:** `claude/hopeful-maxwell-214a12`.
**Driver:** user request — the toolbar frame should resize vertically between **one row** (row 2 hidden,
the minimum) and **two rows** (row 2 shown), **snapping** between the two stops.
---
## 1. Goal & non-goals
**Goal.** The toolbar window can be collapsed to show only the top quickslot row (slots 19) or
expanded to show both rows (slots 118), by dragging its **bottom edge**. The drag **snaps** to the
nearer of two height stops — collapsed (row 2 hidden) or expanded (row 2 shown). Default = expanded
(today's look). Horizontal size stays fixed; the window still moves by grabbing empty cells / chrome
(IA-12). This is a toolkit UX defined from the user's retail observation — the real mechanism lives in
`keystone.dll` (no decomp); our research notes the dat just stacks two always-present rows, so the dat
encodes no collapse. Recorded as an amendment to **IA-17** (toolbar frame is toolkit-supplied).
**Non-goals:** a collapse/expand BUTTON (it's a bottom-edge resize); horizontal resize; persisting the
collapsed state across sessions (it resets to expanded each launch — persistence is the deferred
window-manager Plan-2); animating the snap.
---
## 2. Geometry (from the layout, not hardcoded)
The toolbar `LayoutDesc 0x21000016` root is **300×122**; the two rows are top `0x100001A7..AF` and
bottom `0x100006B7..BF`, with the bottom row's slots at content-y ≈ 90 (deep-dive §2a table, slot 9 at
`6,90`). Heights are computed at mount time from the actual layout, so there is no magic constant:
- `border` = `RetailChromeSprites.Border` (5 px).
- `ExpandedHeight` = `contentHeight + 2·border` (today's frame height; `contentHeight` = the imported
root's `Height`, 122).
- `CollapsedHeight` = `minRow2Top + 2·border`, where `minRow2Top` = the smallest `Top` among the nine
resolved row-2 slot elements (`0x100006B7..BF`). That cuts the frame just above row 2.
- `snapMidpoint` = `(CollapsedHeight + ExpandedHeight) / 2`.
---
## 3. Components
### 3.1 `UiElement``MaxHeight` + a `ResizableEdges` mask — `src/AcDream.App/UI/UiElement.cs`
Today resize clamps to a minimum only and (with `ResizeY`) treats BOTH vertical edges as grips. Add two
small generic members:
```csharp
/// <summary>Maximum height enforced while resizing (default unbounded). Pairs with MinHeight.</summary>
public float MaxHeight { get; set; } = float.MaxValue;
/// <summary>Which edges may start a resize, beyond the ResizeX/ResizeY axis gates. Default: all.
/// Set to e.g. ResizeEdges.Bottom to allow only a bottom-edge drag (the collapse toolbar).</summary>
public ResizeEdges ResizableEdges { get; set; } =
ResizeEdges.Left | ResizeEdges.Right | ResizeEdges.Top | ResizeEdges.Bottom;
```
(`MaxWidth` is YAGNI — only height needs it here.)
- **`UiRoot.HitEdges`** applies the mask at the end: `e &= w.ResizableEdges;` (after the existing
`ResizeX`/`ResizeY` masking). So a toolbar with `ResizableEdges = Bottom` only grips its bottom edge;
a press near the top edge falls through to window-move, not resize.
- **`UiRoot.ResizeRect`** gains a `maxH` parameter and clamps the Bottom/Top height branches:
`h = Math.Clamp(startH + dy, minH, maxH)` (Bottom) and the Top branch likewise. `OnMouseMove`'s resize
call passes `_resizeTarget.MaxHeight` (and `float.MaxValue` for the width's maxW). **This changes
`ResizeRect`'s signature — update its existing callers + the `UiRootInputTests.ResizeRect_*` tests to
pass the new `maxW`/`maxH` args** (`float.MaxValue` where unbounded, preserving their current
assertions).
### 3.2 `UiCollapsibleFrame : UiNineSlicePanel` (new) — `src/AcDream.App/UI/UiCollapsibleFrame.cs`
A toolbar-frame variant that snaps between two heights and toggles a set of "second-row" elements.
One clear responsibility: reconcile its height to a stop and the rows to that stop, every tick.
```csharp
public sealed class UiCollapsibleFrame : UiNineSlicePanel
{
public UiCollapsibleFrame(Func<uint,(uint,int,int)> resolveChrome) : base(resolveChrome) { }
public float CollapsedHeight { get; set; }
public float ExpandedHeight { get; set; }
/// <summary>Elements shown only when expanded (the row-2 slot lists). Hidden when collapsed.</summary>
public IReadOnlyList<UiElement> SecondRow { get; set; } = System.Array.Empty<UiElement>();
/// <summary>True when the frame is currently at (or nearer) the expanded stop.</summary>
public bool IsExpanded => Height >= (CollapsedHeight + ExpandedHeight) * 0.5f;
protected override void OnTick(double dt)
{
base.OnTick(dt);
if (ExpandedHeight <= CollapsedHeight) return; // not configured yet
// Snap to the nearer stop (the resize drag sets Height live; we resolve it to a stop so the
// frame always rests collapsed or expanded — never a half-row).
bool expanded = IsExpanded;
Height = expanded ? ExpandedHeight : CollapsedHeight;
// Row 2 is shown only when expanded. (No clipping needed — the dat content is top-anchored,
// so row-2 slots simply stop drawing when hidden; row 1 never moves.)
for (int i = 0; i < SecondRow.Count; i++) SecondRow[i].Visible = expanded;
}
}
```
Notes:
- The snap runs in `OnTick` (after the frame's `MinHeight`/`MaxHeight`-clamped resize drag set `Height`
that frame), so the rendered height is always a stop. With only two stops one row apart, this reads
as: drag the bottom edge past the midpoint → it jumps to the other stop + row 2 appears/hides.
- `IsExpanded`/the snap use the midpoint; `MinHeight`/`MaxHeight` (set by the mount) keep the drag
within `[Collapsed, Expanded]` so the midpoint test is well-defined.
### 3.3 GameWindow toolbar mount — `src/AcDream.App/Rendering/GameWindow.cs` (~line 2045)
- Build the frame as `UiCollapsibleFrame` instead of `UiNineSlicePanel` (same `ResolveChrome` ctor arg).
- After the content (`toolbarRoot`) is sized: compute `expandedH = toolbarContentH + 2·border`,
`collapsedH = minRow2Top + 2·border` where `minRow2Top` = `min` of the nine row-2 lists' `Top`
(resolve each via `toolbarLayout.FindElement(0x100006B7..BF)`; reuse `ToolbarController`'s row-2 id
list or inline the nine ids).
- Set on the frame: `Resizable = true; ResizableEdges = ResizeEdges.Bottom` (bottom-edge only — top
edge stays a move grip); `MinHeight = collapsedH; MaxHeight = expandedH; Height = expandedH` (default
expanded); `CollapsedHeight = collapsedH; ExpandedHeight = expandedH; SecondRow = <the nine row-2
elements>`. (`ResizeX`/`ResizeY` keep defaults; the `ResizableEdges = Bottom` mask is the operative
restriction.)
- Change `toolbarRoot.Anchors` from all-four-edges to **`Left | Top | Right`** (drop `Bottom`) so the
dat content keeps its full height and row 1 never reflows when the frame collapses; row 2 hides via
`Visible`. (Width is fixed — `ResizeX=false` — so the horizontal anchors are inert but harmless.)
---
## 4. Behavior walk-through
- **Launch:** frame at `ExpandedHeight`, both rows visible (unchanged from today).
- **Collapse:** grab the bottom edge, drag up past the midpoint → `OnTick` snaps `Height` to
`CollapsedHeight` and hides the nine row-2 slots. The frame is now a single-row bar; row 1 unchanged.
- **Expand:** drag the bottom edge down past the midpoint → snaps to `ExpandedHeight`, row 2 reappears.
- **Move:** unchanged — drag an empty cell / chrome to reposition (IA-12); occupied cells drag items
(B.1/B.2).
- **Edge cases:** `MinHeight`/`MaxHeight` clamp the drag to `[Collapsed, Expanded]`; the snap is
idempotent when not dragging (Height already at a stop). The collapsed state is per-session (resets
to expanded on relaunch).
---
## 5. Divergence register
**Amend IA-17** (toolbar window FRAME is toolkit-supplied): add that the frame also supports a
toolkit-defined **collapse-to-one-row** (bottom-edge resize snapping between a row-1-only and a
two-row height, row-2 visibility tied to the stop). Retail's real collapse mechanism is keystone.dll
(no decomp) and the dat encodes no collapse (both rows always present) — so this is our toolkit UX from
the user's retail observation, same justification class as the rest of IA-17. No new row; extend IA-17's
text + cite this spec.
---
## 6. Testing
`tests/AcDream.App.Tests/UI/`:
1. `UiCollapsibleFrame.OnTick` snap: set `CollapsedHeight=96`, `ExpandedHeight=128`; set `Height` just
below the midpoint (e.g. 100) + tick → `Height == 96` and every `SecondRow` element `Visible==false`;
set `Height` just above (e.g. 120) + tick → `Height == 128` and `SecondRow` `Visible==true`.
2. `UiCollapsibleFrame` not-configured guard: `ExpandedHeight==CollapsedHeight==0``OnTick` is a
no-op (no divide/no forced height).
3. `UiRoot.ResizeRect` MaxHeight clamp: a Bottom-edge resize with `dy` huge clamps `h` to `maxH`;
a Top-edge resize likewise; min still honored. (Drive via the existing `ResizeRect` static test
pattern in `UiRootInputTests`; also update the two pre-existing `ResizeRect_*` tests to the new
`maxW`/`maxH` signature — pass `float.MaxValue`, assertions unchanged.)
4. `UiRoot.HitEdges` honors `ResizableEdges`: a panel with `ResizableEdges = ResizeEdges.Bottom` returns
only `Bottom` when pressed near its bottom edge, and `None` near its top edge (which would otherwise
be a grip with `ResizeY` true).
A `UiCollapsibleFrame` needs a chrome resolver in tests — pass `_ => (1u,1,1)` (the existing
`UiNineSlicePanel` test pattern); `OnTick` doesn't draw, so no GL.
---
## 7. Acceptance
- [ ] `dotnet build` + `dotnet test` green.
- [ ] IA-17 amended.
- [ ] **Visual (user):** default shows both rows; dragging the toolbar's **bottom edge up** snaps it to
a single row (row 2 gone); dragging **down** snaps back to two rows; row 1 never moves/squishes;
the window still moves by dragging empty cells/chrome; item drag (B.1/B.2) still works.
---
## 8. Plan size
One small task (TDD): `UiElement.MaxHeight` + `ResizeRect` clamp → `UiCollapsibleFrame` + its tests →
the GameWindow mount swap + IA-17 amend. ~3 files + 1 test file. Suitable for a single implementer pass.

View file

@ -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.)

View file

@ -2018,7 +2018,9 @@ public sealed class GameWindow : IDisposable
combatState: Combat,
peaceDigits: toolbarPeaceDigits,
warDigits: toolbarWarDigits,
emptyDigits: toolbarEmptyDigits);
emptyDigits: toolbarEmptyDigits,
sendAddShortcut: (i, g) => _liveSession?.SendAddShortcut(i, g),
sendRemoveShortcut: i => _liveSession?.SendRemoveShortcut(i));
// Phase D.5.3a — selected-object strip (name, overlay state, health meter).
// Analogue of retail gmToolbarUI::HandleSelectionChanged
@ -2042,7 +2044,7 @@ public sealed class GameWindow : IDisposable
// thickness on every side, giving an outer window of 310×132.
const int toolbarBorder = AcDream.App.UI.RetailChromeSprites.Border;
float toolbarContentW = 300f, toolbarContentH = toolbarRoot.Height;
var toolbarFrame = new AcDream.App.UI.UiNineSlicePanel(ResolveChrome)
var toolbarFrame = new AcDream.App.UI.UiCollapsibleFrame(ResolveChrome)
{
Left = 10, Top = 300,
Width = toolbarContentW + 2 * toolbarBorder,
@ -2055,14 +2057,59 @@ public sealed class GameWindow : IDisposable
toolbarRoot.Top = toolbarBorder;
toolbarRoot.Width = toolbarContentW;
toolbarRoot.Height = toolbarContentH;
// Anchor content to all four edges so it reflows if the frame is resized.
// Anchor content to Left|Top|Right only — drop Bottom so the dat content
// keeps its full height when the frame collapses; row 2 hides via Visible,
// not reflow. (Width is fixed so the horizontal anchors are inert but harmless.)
toolbarRoot.Anchors = AcDream.App.UI.AnchorEdges.Left | AcDream.App.UI.AnchorEdges.Top
| AcDream.App.UI.AnchorEdges.Right | AcDream.App.UI.AnchorEdges.Bottom;
// The frame is the draggable window; the content itself is not.
toolbarRoot.ClickThrough = false;
| AcDream.App.UI.AnchorEdges.Right;
// The frame is the draggable window; the content itself is not. ClickThrough so the
// content panel never CLAIMS a hit — its behavioral children (slots, indicators) are
// hit children-first, and its empty areas fall through to the frame (move). Critically,
// when collapsed the row-2 band is hidden, so below the collapsed frame the content has
// no visible/hit children and ClickThrough lets clicks fall through (no phantom
// window-drag from where row 2 used to be). Same pattern as the chat content panel.
toolbarRoot.ClickThrough = true;
toolbarRoot.Draggable = false;
toolbarRoot.Resizable = false;
toolbarFrame.AddChild(toolbarRoot);
// Collapse-to-one-row: the frame's bottom edge snaps between a one-row (row 2 hidden)
// and two-row height. CollapsedHeight is computed from the layout (just above row 2),
// so there's no magic constant. Bottom-edge only; default expanded.
// The full row-2 band (all at content-y 90, toolbar dump 0x21000016): a 6px left
// edge-piece (0x100006B6) + the 9 slots (0x100006B7..BF) + an 8px right edge-piece
// (0x100006C0). Hide ALL 11 when collapsed — hiding only the 9 slots leaves the two
// edge-pieces drawing as black pillars below the bar.
uint[] row2Ids =
{
0x100006B6u,
0x100006B7u, 0x100006B8u, 0x100006B9u, 0x100006BAu, 0x100006BBu,
0x100006BCu, 0x100006BDu, 0x100006BEu, 0x100006BFu,
0x100006C0u,
};
var toolbarRow2 = new System.Collections.Generic.List<AcDream.App.UI.UiElement>();
float minRow2Top = float.MaxValue;
foreach (var id in row2Ids)
if (toolbarLayout.FindElement(id) is { } e2)
{
toolbarRow2.Add(e2);
if (e2.Top < minRow2Top) minRow2Top = e2.Top;
}
if (toolbarRow2.Count > 0)
{
float expandedH = toolbarContentH + 2 * toolbarBorder; // today's full height
float collapsedH = minRow2Top + 2 * toolbarBorder; // just above row 2
toolbarFrame.CollapsedHeight = collapsedH;
toolbarFrame.ExpandedHeight = expandedH;
toolbarFrame.SecondRow = toolbarRow2;
toolbarFrame.Resizable = true;
toolbarFrame.ResizableEdges = AcDream.App.UI.ResizeEdges.Bottom; // bottom edge only
toolbarFrame.MinHeight = collapsedH;
toolbarFrame.MaxHeight = expandedH;
// Height stays expandedH (already set at construction) = default expanded.
Console.WriteLine($"[D.2b] toolbar collapse: collapsed={collapsedH:0} expanded={expandedH:0} (row2Top={minRow2Top:0}).");
}
_uiHost.Root.AddChild(toolbarFrame);
Console.WriteLine("[D.5.1] retail toolbar window from LayoutDesc importer (0x21000016).");

View file

@ -0,0 +1,25 @@
namespace AcDream.App.UI;
/// <summary>
/// A panel controller implements this and registers itself on each of its
/// <see cref="UiItemList"/>s. Port of retail's <c>m_dragHandler</c> vtable
/// (<c>RegisterItemListDragHandler</c>, decomp 230461; confirmed acclient
/// 0x004a539e + the gmToolbarUI block 0x004bdd89).
/// <para><see cref="OnDragOver"/> decides the accept/reject OVERLAY only (advisory).
/// <see cref="HandleDropRelease"/> is authoritative — it performs the action, or
/// no-ops to reject.</para>
/// </summary>
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). No restore on cancel.</summary>
void OnDragLift(UiItemList sourceList, UiItemSlot sourceCell, ItemDragPayload payload);
/// <summary>True ⇒ the target cell shows the accept (green) frame; false ⇒ reject (red).</summary>
bool OnDragOver(UiItemList targetList, UiItemSlot targetCell, ItemDragPayload payload);
/// <summary>Perform the drop (issue the per-panel wire action), or no-op to reject.</summary>
void HandleDropRelease(UiItemList targetList, UiItemSlot targetCell, ItemDragPayload payload);
}

View file

@ -0,0 +1,24 @@
namespace AcDream.App.UI;
/// <summary>
/// Where a dragged item came from — the retail <c>InqDropIconInfo</c> flag
/// distinction (<c>flags &amp; 0xE == 0</c> fresh-from-inventory vs
/// <c>flags &amp; 4</c> within-list reorder) expressed as a typed enum. The drop
/// handler maps SourceKind + target back to the fresh-vs-reorder decision.
/// Decomp anchors: gmToolbarUI 0x004bd162 / 0x004bd1af; InqDropIconInfo 230533.
/// </summary>
public enum ItemDragSource { Inventory, ShortcutBar, Equipment, Ground }
/// <summary>
/// Snapshot of a drag-in-progress, taken at drag-begin (so a server move arriving
/// mid-drag can't mutate it under us). Port of retail's <c>m_dragElement</c> +
/// <c>InqDropIconInfo</c> out-params (objId/container/flags, decomp 230533).
/// <para><c>SourceContainer</c> is intentionally NOT stored: the handler resolves the
/// LIVE container via <c>ClientObjectTable.Get(ObjId).ContainerId</c> at drop — the
/// same container id retail reads off the dragged element, single source of truth.</para>
/// </summary>
public sealed record ItemDragPayload(
uint ObjId, // dragged weenie guid (retail itemID, +0x5FC)
ItemDragSource SourceKind, // what kind of slot it left
int SourceSlot, // the source cell's SlotIndex (retail m_lastShortcutNumDragged)
UiItemSlot SourceCell); // back-ref: reorder-restore / clear source state / ghost

View file

@ -23,7 +23,7 @@ namespace AcDream.App.UI.Layout;
/// <c>CreateObject</c> resolves a formerly-unknown guid.
/// </para>
/// </summary>
public sealed class ToolbarController
public sealed class ToolbarController : IItemListDragHandler
{
// Slot element ids in slot-index order (toolbar LayoutDesc 0x21000016, pre-dump).
// Row 1 = slots 0-8 (0x100001A7..0x100001AF), Row 2 = slots 9-17 (0x100006B7..0x100006BF).
@ -60,6 +60,10 @@ public sealed class ToolbarController
private readonly Func<IReadOnlyList<PlayerDescriptionParser.ShortcutEntry>> _shortcuts;
private readonly Func<ItemType, uint, uint, uint, uint, uint> _iconIds; // (itemType, icon, underlay, overlay, effects) → GL tex
private readonly Action<uint> _useItem; // guid → fire UseObject
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)
// Digit sprite DID arrays for slot labels (top row, numbers 1-9).
// Read from LayoutDesc 0x21000037, element 0x1000034A under composite 0x10000346.
@ -82,7 +86,9 @@ public sealed class ToolbarController
CombatState? combatState,
uint[]? peaceDigits,
uint[]? warDigits,
uint[]? emptyDigits)
uint[]? emptyDigits,
Action<uint, uint>? sendAddShortcut = null,
Action<uint>? sendRemoveShortcut = null)
{
_repo = repo;
_shortcuts = shortcuts;
@ -91,12 +97,23 @@ public sealed class ToolbarController
_peaceDigits = peaceDigits;
_warDigits = warDigits;
_emptyDigits = emptyDigits;
_sendAddShortcut = sendAddShortcut;
_sendRemoveShortcut = sendRemoveShortcut;
for (int i = 0; i < SlotIds.Length; i++)
{
_slots[i] = layout.FindElement(SlotIds[i]) as UiItemList;
if (_slots[i] is { } list)
{
WireClick(list);
// B.1 drag-drop spine: this controller is the drop handler for every
// toolbar slot list; each cell knows its slot index + that it's a
// shortcut-bar source (retail UIElement_ItemList::RegisterItemListDragHandler).
list.RegisterDragHandler(this);
list.Cell.SlotIndex = i;
list.Cell.SourceKind = ItemDragSource.ShortcutBar;
list.Cell.DragAcceptSprite = 0x060011FAu; // green cross (toolbar), not the ring 0x060011F9 (inventory)
}
}
// Cache the four mutually-exclusive combat-mode indicator elements.
@ -125,11 +142,20 @@ public sealed class ToolbarController
}
/// <summary>
/// Returns true if <paramref name="guid"/> is one of the currently-active shortcut guids.
/// Returns true if <paramref name="guid"/> is one of the currently-active shortcut guids
/// (i.e., present in the live <see cref="AcDream.Core.Items.ShortcutStore"/>).
/// Used to gate repo-event subscriptions so we don't re-populate on every creature spawn.
/// Falls back to scanning <c>_shortcuts()</c> before the store is loaded (pre-PD window).
/// </summary>
private bool IsShortcutGuid(uint guid)
{
if (_storeLoaded)
{
for (int s = 0; s < AcDream.Core.Items.ShortcutStore.SlotCount; s++)
if (_store.Get(s) == guid) return true;
return false;
}
// Store not yet loaded — fall back to the shortcuts provider (pre-PD window).
foreach (var sc in _shortcuts())
if (sc.ObjectGuid == guid) return true;
return false;
@ -174,10 +200,13 @@ public sealed class ToolbarController
CombatState? combatState = null,
uint[]? peaceDigits = null,
uint[]? warDigits = null,
uint[]? emptyDigits = null)
uint[]? emptyDigits = null,
Action<uint, uint>? sendAddShortcut = null,
Action<uint>? sendRemoveShortcut = null)
{
var c = new ToolbarController(layout, repo, shortcuts, iconIds, useItem, combatState,
peaceDigits, warDigits, emptyDigits);
peaceDigits, warDigits, emptyDigits,
sendAddShortcut, sendRemoveShortcut);
c.Populate();
return c;
}
@ -188,24 +217,31 @@ public sealed class ToolbarController
/// Entries whose item is not yet in the repo are silently skipped here; the
/// <c>ObjectAdded</c> event re-fires this method when the item arrives
/// (matching retail's <c>SetDelayedShortcutNum</c> deferred-rebind path).
/// As of B.2: the <see cref="AcDream.Core.Items.ShortcutStore"/> is the
/// authoritative in-session slot map; <c>_shortcuts()</c> seeds it on first call.
/// </summary>
public void Populate()
{
// Clear all slot cells first (flush).
// 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(System.Linq.Enumerable.Select(_shortcuts(), e => ((int)e.Index, e.ObjectGuid)));
_storeLoaded = true;
}
foreach (var list in _slots) list?.Cell.Clear();
foreach (var sc in _shortcuts())
for (int slot = 0; slot < _slots.Length; slot++)
{
if (sc.ObjectGuid == 0) continue; // spell-only shortcut — inventory phase
if (sc.Index >= (uint)_slots.Length) continue;
var list = _slots[(int)sc.Index];
uint guid = _store.Get(slot);
if (guid == 0) continue;
var list = _slots[slot];
if (list is null) continue;
var item = _repo.Get(sc.ObjectGuid);
if (item is null) continue; // deferred: ObjectAdded will re-call Populate
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(sc.ObjectGuid, tex);
list.Cell.SetItem(guid, tex);
}
// Re-stamp slot number labels after any item change.
@ -287,4 +323,46 @@ public sealed class ToolbarController
_useItem(list.Cell.ItemId);
};
}
// ── IItemListDragHandler (B.2 live handler) ──────────────────────────────
// Retail: gmToolbarUI is the m_dragHandler for every shortcut slot list.
// Retail model (remove-on-lift / place-on-drop / no-restore):
// lift → RemoveShortcut (0x019D) + store.Remove (slot empties immediately)
// drop → AddShortcut (0x019C) + optional swap of evicted item into source
// off-bar release → the lift's removal stands (no restore)
// Retail ref: gmToolbarUI::HandleDropRelease acclient_2013_pseudo_c.txt:197971
/// <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 bool OnDragOver(UiItemList targetList, UiItemSlot targetCell, ItemDragPayload payload)
=> payload.ObjId != 0;
/// <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();
}
}

View file

@ -0,0 +1,36 @@
using System;
using System.Collections.Generic;
namespace AcDream.App.UI;
/// <summary>
/// A toolbar-frame variant that snaps its height between two stops — collapsed (row 2 hidden) and
/// expanded (row 2 shown) — and toggles a set of "second-row" elements to match. Resized via the
/// bottom edge (the mount sets <see cref="UiElement.ResizableEdges"/> = Bottom); each tick it resolves
/// the dragged height to the nearer stop so the frame always rests collapsed or expanded — never a
/// half-row. Toolkit UX (keystone.dll has no decomp; the dat stacks both rows always) — see IA-17.
/// </summary>
public sealed class UiCollapsibleFrame : UiNineSlicePanel
{
public UiCollapsibleFrame(Func<uint, (uint, int, int)> resolve) : base(resolve) { }
public float CollapsedHeight { get; set; }
public float ExpandedHeight { get; set; }
/// <summary>Elements shown only when expanded (the row-2 slot lists). Hidden when collapsed.</summary>
public IReadOnlyList<UiElement> SecondRow { get; set; } = Array.Empty<UiElement>();
/// <summary>True when the frame is at (or nearer) the expanded stop.</summary>
public bool IsExpanded => Height >= (CollapsedHeight + ExpandedHeight) * 0.5f;
protected override void OnTick(double deltaSeconds)
{
base.OnTick(deltaSeconds);
if (ExpandedHeight <= CollapsedHeight) return; // not configured yet — no snap
bool expanded = IsExpanded;
Height = expanded ? ExpandedHeight : CollapsedHeight; // snap the dragged height to a stop
for (int i = 0; i < SecondRow.Count; i++) SecondRow[i].Visible = expanded;
}
/// <summary>Test hook — OnTick is protected. Drives one snap+visibility reconcile.</summary>
internal void TickForTest(double dt) => OnTick(dt);
}

View file

@ -113,15 +113,31 @@ public abstract class UiElement
/// drag-drop candidacy is suppressed in favour of the element's own handling.</summary>
public bool CapturesPointerDrag { get; set; }
/// <summary>If true, a left-press-and-move on this element starts a DRAG-DROP
/// (<see cref="UiRoot"/> promotes to BeginDrag) rather than moving a Draggable
/// ancestor window — so an item cell inside the toolbar frame drags the item, not
/// the window. Distinct from <see cref="CapturesPointerDrag"/> (a self-driven
/// interior drag like text selection, which does NOT promote to BeginDrag). Default
/// false; overridden by drag sources (e.g. an occupied <see cref="UiItemSlot"/>).</summary>
public virtual bool IsDragSource => false;
/// <summary>Minimum size enforced while resizing.</summary>
public float MinWidth { get; set; } = 40f;
public float MinHeight { get; set; } = 40f;
/// <summary>Maximum height enforced while resizing (default unbounded). Pairs with MinHeight.</summary>
public float MaxHeight { get; set; } = float.MaxValue;
/// <summary>Allow horizontal (width) resize. Ignored unless <see cref="Resizable"/>.</summary>
public bool ResizeX { get; set; } = true;
/// <summary>Allow vertical (height) resize. Ignored unless <see cref="Resizable"/>.</summary>
public bool ResizeY { get; set; } = true;
/// <summary>Which edges may start a resize, beyond the ResizeX/ResizeY axis gates. Default: all.
/// Set to e.g. <see cref="ResizeEdges.Bottom"/> to allow only a bottom-edge drag (the collapse toolbar).</summary>
public ResizeEdges ResizableEdges { get; set; } =
ResizeEdges.Left | ResizeEdges.Right | ResizeEdges.Top | ResizeEdges.Bottom;
/// <summary>Edges this element anchors to in its parent. Default Left|Top
/// (pinned top-left, fixed size — no reflow). Left|Right stretches width.</summary>
public AnchorEdges Anchors { get; set; } = AnchorEdges.Left | AnchorEdges.Top;
@ -203,6 +219,16 @@ public abstract class UiElement
/// </summary>
public virtual bool OnEvent(in UiEvent e) => false;
/// <summary>The data this element carries when a drag begins. <see cref="UiRoot"/>
/// pulls this on drag-promote; a NULL return CANCELS the drag (retail:
/// ItemList_BeginDrag only arms an occupied cell). Default null = not draggable.</summary>
public virtual object? GetDragPayload() => null;
/// <summary>The texture <see cref="UiRoot"/> paints at the cursor while this element
/// is the drag source: (GL handle, width, height). Null = no ghost. Keeps
/// <see cref="UiRoot"/> item-agnostic. Retail analog: m_dragIcon (decomp 229738).</summary>
public virtual (uint tex, int w, int h)? GetDragGhost() => null;
/// <summary>
/// Tooltip text for this widget. Retail fires event 0x07 after
/// ~1000ms hover, then queries the widget's virtual "GetString"

View file

@ -24,6 +24,14 @@ public sealed class UiItemList : UiElement
public Func<uint, (uint tex, int w, int h)>? SpriteResolve { get; set; }
/// <summary>The drag handler this list routes drops to (a panel controller).
/// Null = the list accepts no drops. Retail: UIElement_ItemList::m_dragHandler.</summary>
public IItemListDragHandler? DragHandler { get; private set; }
/// <summary>Register the panel's drag handler on this list. Retail:
/// UIElement_ItemList::RegisterItemListDragHandler (decomp 230461).</summary>
public void RegisterDragHandler(IItemListDragHandler handler) => DragHandler = handler;
/// <summary>Convenience for single-cell slots (the toolbar): the first cell.
/// Valid only while the list has at least one cell; after <see cref="Flush"/>
/// (the inventory-phase rebuild path) the list is empty until <see cref="AddItem"/>

View file

@ -21,6 +21,29 @@ public sealed class UiItemSlot : UiElement
/// <summary>Pre-composited icon GL texture for the bound item (0 = none).</summary>
public uint IconTexture { get; private set; }
/// <summary>This cell's own index within its panel (0..17 toolbar; container slot
/// for inventory). Distinct from <see cref="ShortcutNum"/> (the 19 label, -1 on the
/// bottom row). Set by the controller; used as the drag payload's SourceSlot and to
/// identify the drop TARGET slot.</summary>
public int SlotIndex { get; set; } = -1;
/// <summary>What kind of slot this is, for the drag payload (retail InqDropIconInfo
/// flags). Controller overrides; default Inventory.</summary>
public ItemDragSource SourceKind { get; set; } = ItemDragSource.Inventory;
/// <summary>Drag-rollover accept frame (retail ItemSlot_DragOver_Accept 0x060011F9,
/// state id 0x10000041). Configurable; guard id != 0 before resolving.</summary>
public uint DragAcceptSprite { get; set; } = 0x060011F9u;
/// <summary>Drag-rollover reject frame (retail ItemSlot_DragOver_Reject 0x060011F8,
/// state id 0x10000040).</summary>
public uint DragRejectSprite { get; set; } = 0x060011F8u;
/// <summary>Accept/reject overlay state while a drag hovers this cell.</summary>
public enum DragAcceptState { None, Accept, Reject }
private DragAcceptState _dragAccept = DragAcceptState.None;
/// <summary>Current overlay state — internal so unit tests can assert it (InternalsVisibleTo).</summary>
internal DragAcceptState DragAcceptVisual => _dragAccept;
/// <summary>Empty-slot sprite. Default = the generic toolbar empty-slot border
/// 0x060074CF (uiitem template 0x21000037, state ItemSlot_Empty). Configurable so
/// paperdoll equip slots can use their per-slot silhouettes later.</summary>
@ -37,6 +60,30 @@ public sealed class UiItemSlot : UiElement
public void Clear() { ItemId = 0; IconTexture = 0; }
/// <inheritdoc/>
public override object? GetDragPayload()
=> ItemId != 0 ? new ItemDragPayload(ItemId, SourceKind, SlotIndex, this) : null;
/// <inheritdoc/>
public override (uint tex, int w, int h)? GetDragGhost()
=> ItemId != 0 && IconTexture != 0 ? (IconTexture, (int)Width, (int)Height) : null;
/// <summary>An OCCUPIED slot is a drag source — a press-and-move picks up the item
/// rather than moving the toolbar window. An EMPTY slot is NOT a drag source, so a
/// press-and-move there falls through to the IA-12 whole-window-drag, keeping the bar
/// movable by its empty cells / chrome. Drives <see cref="UiRoot"/>'s mousedown
/// window-vs-item disambiguation (retail moves the window via a dragbar, never cells;
/// our whole-window-drag approximation reconciles by gating on occupancy).</summary>
public override bool IsDragSource => ItemId != 0;
/// <summary>Walk up to the containing <see cref="UiItemList"/> (the drop handler owner).</summary>
private UiItemList? FindList()
{
UiElement? e = Parent;
while (e is not null) { if (e is UiItemList l) return l; e = e.Parent; }
return null;
}
// ── Shortcut number (slot label) ─────────────────────────────────────────
// Port of UIElement_UIItem::SetShortcutNum (acclient_2013_pseudo_c.txt:229465).
// Retail draws the digit on the cell's ShortcutNum sub-element, picking the
@ -100,7 +147,40 @@ public sealed class UiItemSlot : UiElement
/// <inheritdoc/>
public override bool OnEvent(in UiEvent e)
{
if (e.Type == UiEventType.MouseDown) { Clicked?.Invoke(); return true; }
switch (e.Type)
{
// Use fires on CLICK (mouse-up over the same cell), not MouseDown — so a
// drag (press + >3px move) does NOT also use the item. UiRoot suppresses the
// post-drag Click (UiRoot.cs FinishDrag returns before the Click emit).
case UiEventType.MouseDown:
return true; // consume the press; no use here
case UiEventType.Click:
Clicked?.Invoke();
return true;
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;
case UiEventType.DragEnter: // pointer entered me mid-drag → ask the list's handler
_dragAccept = (FindList() is { DragHandler: { } h } list
&& e.Payload is ItemDragPayload p && h.OnDragOver(list, this, p))
? DragAcceptState.Accept : DragAcceptState.Reject;
return true;
case UiEventType.DragOver: // UiRoot fires this on LEAVE → neutral
_dragAccept = DragAcceptState.None;
return true;
case UiEventType.DropReleased:
_dragAccept = DragAcceptState.None;
if (FindList() is { DragHandler: { } dh } dl && e.Payload is ItemDragPayload dp)
dh.HandleDropRelease(dl, this, dp);
return true;
}
return false;
}
@ -139,5 +219,19 @@ public sealed class UiItemSlot : UiElement
}
}
}
// Drag-rollover accept/reject frame (retail SetDragAcceptState 0x10000041/40).
// Guard id != 0 BEFORE resolving — resolve(0) returns the 1×1 magenta placeholder
// with a non-zero GL handle (feedback_ui_resolve_zero_magenta).
if (_dragAccept != DragAcceptState.None && SpriteResolve is not null)
{
uint id = _dragAccept == DragAcceptState.Accept ? DragAcceptSprite : DragRejectSprite;
if (id != 0)
{
var (tex, _, _) = SpriteResolve(id);
if (tex != 0)
ctx.DrawSprite(tex, 0f, 0f, Width, Height, 0f, 0f, 1f, 1f, Vector4.One);
}
}
}
}

View file

@ -10,7 +10,7 @@ namespace AcDream.App.UI;
/// the widget is testable without GL. In production:
/// <c>id => { var t = cache.GetOrUploadRenderSurface(id, out var w, out var h); return (t, w, h); }</c>.
/// </summary>
public sealed class UiNineSlicePanel : UiPanel
public class UiNineSlicePanel : UiPanel
{
/// <summary>A placed chrome piece: destination rect in local pixel space.</summary>
public readonly record struct Rect(float X, float Y, float W, float H);

View file

@ -71,6 +71,9 @@ public sealed class UiRoot : UiElement
/// <summary>Current drag source (set between drag-begin and drop/cancel).</summary>
public UiElement? DragSource { get; private set; }
public object? DragPayload { get; private set; }
private (uint tex, int w, int h)? _dragGhost;
/// <summary>Snapshotted drag-ghost (tex,w,h), exposed for tests. See BeginDrag.</summary>
internal (uint tex, int w, int h)? DragGhostForTest => _dragGhost;
private UiElement? _lastDragHoverTarget;
private int _pressX, _pressY;
private bool _dragCandidate;
@ -141,9 +144,23 @@ public sealed class UiRoot : UiElement
// beats even rect backgrounds. Faithful to retail's root-level MakePopup.
ctx.BeginOverlayLayer();
DrawOverlays(ctx);
DrawDragGhost(ctx);
ctx.EndOverlayLayer();
}
private const float GhostAlpha = 1.0f; // retail m_dragIcon is the full icon, no fade
/// <summary>Paint the drag ghost at the cursor. The texture comes from the snapshotted
/// ghost captured in <see cref="BeginDrag"/> so UiRoot stays item-agnostic and the ghost
/// survives the source cell emptying on lift; the ghost is NOT a tree element, so it
/// never intercepts hit-tests.</summary>
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));
}
// ── Input entry points (called from GameWindow's Silk.NET handlers) ──
public void OnMouseMove(int x, int y)
@ -159,7 +176,8 @@ public sealed class UiRoot : UiElement
var (nx, ny, nw, nh) = ResizeRect(
_resizeStartX, _resizeStartY, _resizeStartW, _resizeStartH,
_resizeEdges, x - _resizeMouseX, y - _resizeMouseY,
_resizeTarget.MinWidth, _resizeTarget.MinHeight);
_resizeTarget.MinWidth, _resizeTarget.MinHeight,
float.MaxValue, _resizeTarget.MaxHeight);
_resizeTarget.Left = nx; _resizeTarget.Top = ny;
_resizeTarget.Width = nw; _resizeTarget.Height = nh;
return;
@ -185,7 +203,7 @@ public sealed class UiRoot : UiElement
if (Math.Abs(x - _pressX) > DragDistanceThreshold
|| Math.Abs(y - _pressY) > DragDistanceThreshold)
{
BeginDrag(Captured, payload: null);
BeginDrag(Captured);
}
}
if (DragSource is not null)
@ -244,6 +262,17 @@ public sealed class UiRoot : UiElement
_resizeMouseX = x; _resizeMouseY = y;
_dragCandidate = false;
}
else if (target.IsDragSource)
{
// A drag SOURCE (e.g. an occupied item cell) inside a Draggable window
// starts an item drag-drop, NOT a window move. UiRoot stays item-agnostic:
// it only reads the IsDragSource flag (the cell decides occupancy). The
// BeginDrag promotion happens on the >3px move (and cancels if the source's
// GetDragPayload() returns null). Empty cells are NOT drag sources, so they
// fall through to window.Draggable below (IA-12 whole-window-drag), keeping
// the bar movable by its empty cells / chrome.
_dragCandidate = true;
}
else if (target.CapturesPointerDrag)
{
// The pressed widget owns interior drags (e.g. text selection):
@ -447,10 +476,13 @@ public sealed class UiRoot : UiElement
// ── Drag-drop (retail event chain 0x15 → 0x21 → 0x1C → 0x3E) ────────
private void BeginDrag(UiElement source, object? payload)
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);
}
@ -483,16 +515,18 @@ public sealed class UiRoot : UiElement
private void FinishDrag(int x, int y)
{
var (t, lx, ly) = HitTestTopDown(x, y);
var target = t ?? DragSource!;
var accepted = t is not null && t != DragSource;
var e = new UiEvent(DragSource!.EventId, target, UiEventType.DropReleased,
Data0: accepted ? 1 : 0,
Data1: (int)lx, Data2: (int)ly,
Payload: DragPayload);
target.OnEvent(in e);
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;
}
@ -593,21 +627,23 @@ public sealed class UiRoot : UiElement
if (System.Math.Abs(y - b) <= grip) e |= ResizeEdges.Bottom;
if (!w.ResizeX) e &= ~(ResizeEdges.Left | ResizeEdges.Right);
if (!w.ResizeY) e &= ~(ResizeEdges.Top | ResizeEdges.Bottom);
e &= w.ResizableEdges;
return e;
}
/// <summary>Compute a resized rect from a start rect + drag delta + which edges,
/// clamping to (<paramref name="minW"/>,<paramref name="minH"/>). Left/Top edges
/// move the origin so the opposite edge stays put.</summary>
/// clamping to (<paramref name="minW"/>,<paramref name="minH"/>) and
/// (<paramref name="maxW"/>,<paramref name="maxH"/>). Left/Top edges move the
/// origin so the opposite edge stays put.</summary>
public static (float x, float y, float w, float h) ResizeRect(
float startX, float startY, float startW, float startH,
ResizeEdges edges, float dx, float dy, float minW, float minH)
ResizeEdges edges, float dx, float dy, float minW, float minH, float maxW, float maxH)
{
float x = startX, y = startY, w = startW, h = startH;
if ((edges & ResizeEdges.Right) != 0) w = System.Math.Max(minW, startW + dx);
if ((edges & ResizeEdges.Bottom) != 0) h = System.Math.Max(minH, startH + dy);
if ((edges & ResizeEdges.Left) != 0) { float nw = System.Math.Max(minW, startW - dx); x = startX + (startW - nw); w = nw; }
if ((edges & ResizeEdges.Top) != 0) { float nh = System.Math.Max(minH, startH - dy); y = startY + (startH - nh); h = nh; }
if ((edges & ResizeEdges.Right) != 0) w = System.Math.Clamp(startW + dx, minW, maxW);
if ((edges & ResizeEdges.Bottom) != 0) h = System.Math.Clamp(startH + dy, minH, maxH);
if ((edges & ResizeEdges.Left) != 0) { float nw = System.Math.Clamp(startW - dx, minW, maxW); x = startX + (startW - nw); w = nw; }
if ((edges & ResizeEdges.Top) != 0) { float nh = System.Math.Clamp(startH - dy, minH, maxH); y = startY + (startH - nh); h = nh; }
return (x, y, w, h);
}

View file

@ -95,17 +95,20 @@ public static class InventoryActions
return body;
}
/// <summary>Pin an item / spell to a quickbar slot.</summary>
/// <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 slotIndex, uint objectType, uint targetId)
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), slotIndex);
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(16), objectType);
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(20), targetId);
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;
}

View file

@ -1140,6 +1140,22 @@ public sealed class WorldSession : IDisposable
SendGameAction(body);
}
/// <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));
}
/// <summary>Send retail QueryHealth (0x01BF). Server replies UpdateHealth (0x01C0).</summary>
/// <remarks>
/// Retail anchor: <c>CM_Combat::Event_QueryHealth</c> / <c>gmToolbarUI::HandleSelectionChanged:198635</c>

View file

@ -0,0 +1,34 @@
using System;
using System.Collections.Generic;
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 shortcut list, 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 — entries with ObjGuid 0 (spell-only) are skipped on Load. Pure model
/// (no Core.Net dependency): callers project their wire entries to (slot, objGuid) pairs.
/// </summary>
public sealed class ShortcutStore
{
public const int SlotCount = 18;
private readonly uint[] _objIds = new uint[SlotCount];
/// <summary>Replace all slots from a (slot, objectGuid) sequence (item entries only;
/// ObjGuid 0 and out-of-range slots are skipped).</summary>
public void Load(IEnumerable<(int Slot, uint ObjGuid)> entries)
{
Array.Clear(_objIds);
foreach (var (slot, objGuid) in entries)
if ((uint)slot < SlotCount && objGuid != 0) _objIds[slot] = objGuid;
}
/// <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; }
}

View file

@ -0,0 +1,294 @@
using AcDream.App.UI;
using Xunit;
namespace AcDream.App.Tests.UI;
public class DragDropSpineTests
{
// A spy handler used across the spine tests.
private sealed class SpyHandler : IItemListDragHandler
{
public bool AcceptResult = true;
public (UiItemList list, UiItemSlot cell, ItemDragPayload payload)? LastOver;
public (UiItemList list, UiItemSlot cell, ItemDragPayload payload)? LastDrop;
public (UiItemList list, UiItemSlot cell, ItemDragPayload payload)? LastLift;
public void OnDragLift(UiItemList list, UiItemSlot cell, ItemDragPayload p)
{ LastLift = (list, cell, p); }
public bool OnDragOver(UiItemList list, UiItemSlot cell, ItemDragPayload p)
{ LastOver = (list, cell, p); return AcceptResult; }
public void HandleDropRelease(UiItemList list, UiItemSlot cell, ItemDragPayload p)
{ LastDrop = (list, cell, p); }
}
[Fact]
public void Payload_holdsAllFields()
{
var src = new UiItemSlot();
var p = new ItemDragPayload(0x5001u, ItemDragSource.ShortcutBar, 3, src);
Assert.Equal(0x5001u, p.ObjId);
Assert.Equal(ItemDragSource.ShortcutBar, p.SourceKind);
Assert.Equal(3, p.SourceSlot);
Assert.Same(src, p.SourceCell);
}
[Fact]
public void UiItemList_registerDragHandler_roundtrips()
{
var list = new UiItemList(_ => (0u, 0, 0));
Assert.Null(list.DragHandler);
var h = new SpyHandler();
list.RegisterDragHandler(h);
Assert.Same(h, list.DragHandler);
}
// ── UiItemSlot drag-source payload/ghost ────────────────────────────────
[Fact]
public void GetDragPayload_emptyCell_isNull()
=> Assert.Null(new UiItemSlot().GetDragPayload());
[Fact]
public void GetDragPayload_boundCell_snapshotsFields()
{
var cell = new UiItemSlot { SlotIndex = 4, SourceKind = ItemDragSource.ShortcutBar };
cell.SetItem(0x5001u, 0x99u);
var p = Assert.IsType<ItemDragPayload>(cell.GetDragPayload());
Assert.Equal(0x5001u, p.ObjId);
Assert.Equal(ItemDragSource.ShortcutBar, p.SourceKind);
Assert.Equal(4, p.SourceSlot);
Assert.Same(cell, p.SourceCell);
}
[Fact]
public void GetDragGhost_emptyCell_isNull()
=> Assert.Null(new UiItemSlot().GetDragGhost());
[Fact]
public void GetDragGhost_boundCell_returnsIconTuple()
{
var cell = new UiItemSlot { Width = 32, Height = 32 };
cell.SetItem(0x5001u, 0x99u);
var g = cell.GetDragGhost();
Assert.NotNull(g);
Assert.Equal(0x99u, g!.Value.tex);
Assert.Equal(32, g.Value.w);
Assert.Equal(32, g.Value.h);
}
// ── cell drop-target: DragEnter overlay + DropReleased dispatch ──────────
private static (UiItemList list, UiItemSlot cell, SpyHandler h) ListWithHandler()
{
var list = new UiItemList(_ => (1u, 1, 1)); // non-zero resolve so overlay draw is harmless
var h = new SpyHandler();
list.RegisterDragHandler(h);
return (list, list.Cell, h);
}
private static ItemDragPayload SomePayload()
=> new(0x5001u, ItemDragSource.ShortcutBar, 0, new UiItemSlot());
[Fact]
public void DragEnter_setsAcceptOverlay_whenHandlerAccepts()
{
var (_, cell, h) = ListWithHandler();
h.AcceptResult = true;
cell.OnEvent(new UiEvent(0u, cell, UiEventType.DragEnter, Payload: SomePayload()));
Assert.Equal(UiItemSlot.DragAcceptState.Accept, cell.DragAcceptVisual);
}
[Fact]
public void DragEnter_setsRejectOverlay_whenHandlerRejects()
{
var (_, cell, h) = ListWithHandler();
h.AcceptResult = false;
cell.OnEvent(new UiEvent(0u, cell, UiEventType.DragEnter, Payload: SomePayload()));
Assert.Equal(UiItemSlot.DragAcceptState.Reject, cell.DragAcceptVisual);
}
[Fact]
public void DragOver_resetsOverlayToNeutral()
{
var (_, cell, h) = ListWithHandler();
cell.OnEvent(new UiEvent(0u, cell, UiEventType.DragEnter, Payload: SomePayload()));
cell.OnEvent(new UiEvent(0u, cell, UiEventType.DragOver, Payload: SomePayload()));
Assert.Equal(UiItemSlot.DragAcceptState.None, cell.DragAcceptVisual);
}
[Fact]
public void DropReleased_accepted_dispatchesToHandler()
{
var (list, cell, h) = ListWithHandler();
var p = SomePayload();
cell.OnEvent(new UiEvent(0u, cell, UiEventType.DropReleased, Data0: 1, Payload: p));
Assert.NotNull(h.LastDrop);
Assert.Same(list, h.LastDrop!.Value.list);
Assert.Same(cell, h.LastDrop.Value.cell);
Assert.Same(p, h.LastDrop.Value.payload);
}
[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);
}
[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()
{
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()
{
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
}
// ── Full UiRoot chain: arming + use-vs-drag ─────────────────────────────
// A bound, hit-testable slot inside a list, sized for the hit-test.
private static (UiRoot root, UiItemList list, UiItemSlot cell) RootWithBoundSlot(uint itemId)
{
var root = new UiRoot { Width = 800, Height = 600 };
var list = new UiItemList(_ => (1u, 1, 1)) { Left = 0, Top = 0, Width = 32, Height = 32 };
// Tests don't run OnDraw (which sizes the cell), so size the cell explicitly.
list.Cell.Width = 32; list.Cell.Height = 32;
if (itemId != 0) list.Cell.SetItem(itemId, 0x99u);
root.AddChild(list);
return (root, list, list.Cell);
}
[Fact]
public void BeginDrag_arms_whenPayloadNonNull()
{
var (root, _, cell) = RootWithBoundSlot(0x5001u);
root.OnMouseDown(UiMouseButton.Left, 10, 10);
root.OnMouseMove(20, 10); // >3px → promote to drag
Assert.Same(cell, root.DragSource);
Assert.IsType<ItemDragPayload>(root.DragPayload);
}
[Fact]
public void BeginDrag_doesNotArm_whenPayloadNull_emptySlot()
{
var (root, _, _) = RootWithBoundSlot(0u); // empty cell → GetDragPayload null
root.OnMouseDown(UiMouseButton.Left, 10, 10);
root.OnMouseMove(20, 10);
Assert.Null(root.DragSource); // never armed
}
[Fact]
public void Click_withoutDrag_firesUse()
{
var (root, _, cell) = RootWithBoundSlot(0x5001u);
bool used = false;
cell.Clicked = () => used = true;
root.OnMouseDown(UiMouseButton.Left, 10, 10);
root.OnMouseUp(UiMouseButton.Left, 10, 10); // no move → Click emitted
Assert.True(used);
}
[Fact]
public void CompletedDrag_doesNotFireUse()
{
var (root, _, cell) = RootWithBoundSlot(0x5001u);
bool used = false;
cell.Clicked = () => used = true;
root.OnMouseDown(UiMouseButton.Left, 10, 10);
root.OnMouseMove(20, 10); // promote to drag
root.OnMouseUp(UiMouseButton.Left, 20, 10); // FinishDrag, NOT Click
Assert.False(used);
}
// ── no-handler / orphan-cell DragEnter defaults to Reject (review carry-forward) ──
[Fact]
public void DragEnter_orphanCell_noList_defaultsToReject()
{
var cell = new UiItemSlot(); // no parent list → FindList() null
cell.OnEvent(new UiEvent(0u, cell, UiEventType.DragEnter, Payload: SomePayload()));
Assert.Equal(UiItemSlot.DragAcceptState.Reject, cell.DragAcceptVisual);
}
[Fact]
public void DragEnter_listWithoutHandler_defaultsToReject()
{
var list = new UiItemList(_ => (1u, 1, 1)); // no RegisterDragHandler
list.Cell.OnEvent(new UiEvent(0u, list.Cell, UiEventType.DragEnter, Payload: SomePayload()));
Assert.Equal(UiItemSlot.DragAcceptState.Reject, list.Cell.DragAcceptVisual);
}
// ── item drag inside a Draggable window (the LIVE toolbar topology) ──────
// Regression (visual gate 2026-06-20): the slot sits inside the Draggable toolbar
// frame, so FindWindow returns the frame. An OCCUPIED slot must start an ITEM drag
// (IsDragSource), NOT move the window; an EMPTY slot falls through to whole-window
// drag (IA-12) so the bar stays movable by its empty cells / chrome. The earlier
// RootWithBoundSlot tests put the slot directly under the root (no draggable
// ancestor), so they could not catch this.
private static (UiRoot root, UiPanel frame, UiItemList list) DraggableFrameWithSlot(uint itemId)
{
var root = new UiRoot { Width = 800, Height = 600 };
var frame = new UiPanel { Left = 10, Top = 300, Width = 200, Height = 60, Draggable = true };
var list = new UiItemList(_ => (1u, 1, 1)) { Left = 5, Top = 5, Width = 32, Height = 32 };
list.Cell.Width = 32; list.Cell.Height = 32;
if (itemId != 0) list.Cell.SetItem(itemId, 0x99u);
frame.AddChild(list);
root.AddChild(frame);
return (root, frame, list);
}
[Fact]
public void OccupiedSlotInsideDraggableWindow_armsItemDrag_doesNotMoveWindow()
{
var (root, frame, list) = DraggableFrameWithSlot(0x5001u);
// Slot screen rect = frame(10,300)+list(5,5) → (15,305)..(47,337). Press inside, drag >3px.
root.OnMouseDown(UiMouseButton.Left, 20, 310);
root.OnMouseMove(40, 310);
Assert.Same(list.Cell, root.DragSource); // item drag armed
Assert.Equal(10f, frame.Left); // window did NOT move
Assert.Equal(300f, frame.Top);
}
[Fact]
public void EmptySlotInsideDraggableWindow_movesWindow_notItemDrag()
{
var (root, frame, _) = DraggableFrameWithSlot(0u); // empty slot → not a drag source
root.OnMouseDown(UiMouseButton.Left, 20, 310);
root.OnMouseMove(40, 310);
Assert.Null(root.DragSource); // no item drag
Assert.Equal(30f, frame.Left); // window moved (offX=20-10=10; new Left=40-10=30)
Assert.Equal(300f, frame.Top); // y unchanged (310-10=300)
}
}

View file

@ -89,8 +89,8 @@ public class ToolbarControllerTests
ToolbarController.Bind(layout, repo, () => shortcuts,
iconIds: (_,_,_,_,_) => 0x77u, useItem: g => used = g);
// UiEvent is a positional record struct: (SourceId, Target, Type, Data0..3, Payload)
slots[Row1[0]].Cell.OnEvent(new UiEvent(0u, null, UiEventType.MouseDown));
// Use now fires on Click (mouse-up), not MouseDown — drag/click disambiguation.
slots[Row1[0]].Cell.OnEvent(new UiEvent(0u, null, UiEventType.Click));
Assert.Equal(0x5001u, used);
}
@ -421,4 +421,177 @@ public class ToolbarControllerTests
Assert.Equal(callsAfterBind, iconCallCount); // unchanged
}
// ── B.1: drag-drop spine wiring ──────────────────────────────────────────
/// <summary>Bind registers the controller as each slot list's drag handler and
/// stamps every cell's SlotIndex + SourceKind=ShortcutBar.</summary>
[Fact]
public void Bind_registersDragHandler_andStampsSlots()
{
var (layout, slots, _) = FakeToolbar();
var repo = new ClientObjectTable();
var ctrl = ToolbarController.Bind(layout, repo,
() => Array.Empty<PlayerDescriptionParser.ShortcutEntry>(),
iconIds: (_,_,_,_,_) => 0u, useItem: _ => { });
for (int i = 0; i < Row1.Length; i++)
{
Assert.Same(ctrl, slots[Row1[i]].DragHandler);
Assert.Equal(i, slots[Row1[i]].Cell.SlotIndex);
Assert.Equal(ItemDragSource.ShortcutBar, slots[Row1[i]].Cell.SourceKind);
}
// Bottom row slots are indices 9..17; same handler + source kind as the top row.
for (int j = 0; j < Row2.Length; j++)
{
Assert.Same(ctrl, slots[Row2[j]].DragHandler);
Assert.Equal(9 + j, slots[Row2[j]].Cell.SlotIndex);
Assert.Equal(ItemDragSource.ShortcutBar, slots[Row2[j]].Cell.SourceKind);
}
}
/// <summary>OnDragOver accepts a real item (ObjId != 0). Eligibility (IsShortcutEligible)
/// is Stream B.2; the stub accepts any non-empty payload.</summary>
[Fact]
public void OnDragOver_acceptsRealItem()
{
var (layout, slots, _) = FakeToolbar();
var ctrl = ToolbarController.Bind(layout, new ClientObjectTable(),
() => Array.Empty<PlayerDescriptionParser.ShortcutEntry>(),
iconIds: (_,_,_,_,_) => 0u, useItem: _ => { });
var list = slots[Row1[0]];
var payload = new ItemDragPayload(0x5001u, ItemDragSource.Inventory, 0, new UiItemSlot());
Assert.True(ctrl.OnDragOver(list, list.Cell, payload));
}
/// <summary>OnDragOver rejects a ghost payload with ObjId 0 (the guard against an
/// empty cell that somehow produced a payload). Complements OnDragOver_acceptsRealItem.</summary>
[Fact]
public void OnDragOver_rejectsZeroObjId()
{
var (layout, slots, _) = FakeToolbar();
var ctrl = ToolbarController.Bind(layout, new ClientObjectTable(),
() => Array.Empty<PlayerDescriptionParser.ShortcutEntry>(),
iconIds: (_,_,_,_,_) => 0u, useItem: _ => { });
var list = slots[Row1[0]];
var payload = new ItemDragPayload(0u, ItemDragSource.Inventory, 0, new UiItemSlot());
Assert.False(ctrl.OnDragOver(list, list.Cell, payload));
}
// ── 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);
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);
Assert.Equal(0u, slots[Row1[3]].Cell.ItemId);
}
[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);
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);
Assert.Equal(0x5001u, slots[Row1[5]].Cell.ItemId);
Assert.Equal(0x5002u, slots[Row1[3]].Cell.ItemId);
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);
Assert.Equal(0x5001u, slots[Row1[7]].Cell.ItemId);
Assert.Equal(0u, slots[Row1[3]].Cell.ItemId);
Assert.Contains((7u, 0x5001u), adds);
Assert.DoesNotContain(adds, a => a.i == 3u);
}
[Fact]
public void HandleDropRelease_ontoSelf_reAddsToSource()
{
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); // slot 3 emptied
ctrl.HandleDropRelease(slots[Row1[3]], slots[Row1[3]].Cell, payload); // drop back on slot 3
Assert.Equal(0x5001u, slots[Row1[3]].Cell.ItemId); // re-added to source (net no-op)
Assert.Contains((3u, 0x5001u), adds); // AddShortcut(3, A) sent
Assert.Single(removes); // exactly RemoveShortcut(3) [from the lift]
Assert.Single(adds); // exactly AddShortcut(3, A) [the re-place]
}
[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
}
}

View file

@ -0,0 +1,63 @@
using AcDream.App.UI;
using Xunit;
namespace AcDream.App.Tests.UI;
public class UiCollapsibleFrameTests
{
private static UiCollapsibleFrame MakeFrame(out UiPanel row2a, out UiPanel row2b)
{
var f = new UiCollapsibleFrame(_ => (1u, 1, 1))
{
CollapsedHeight = 96f,
ExpandedHeight = 128f,
};
row2a = new UiPanel(); row2b = new UiPanel();
f.SecondRow = new UiElement[] { row2a, row2b };
return f;
}
[Fact]
public void Tick_belowMidpoint_snapsCollapsed_hidesSecondRow()
{
var f = MakeFrame(out var a, out var b);
f.Height = 100f; // nearer the collapsed stop (midpoint 112)
f.TickForTest(0.016);
Assert.Equal(96f, f.Height);
Assert.False(a.Visible);
Assert.False(b.Visible);
Assert.False(f.IsExpanded);
}
[Fact]
public void Tick_aboveMidpoint_snapsExpanded_showsSecondRow()
{
var f = MakeFrame(out var a, out var b);
f.Height = 120f; // nearer the expanded stop
f.TickForTest(0.016);
Assert.Equal(128f, f.Height);
Assert.True(a.Visible);
Assert.True(b.Visible);
Assert.True(f.IsExpanded);
}
[Fact]
public void Tick_notConfigured_isNoOp()
{
var f = new UiCollapsibleFrame(_ => (1u, 1, 1)); // Collapsed==Expanded==0
f.Height = 50f;
f.TickForTest(0.016);
Assert.Equal(50f, f.Height); // unchanged, no divide/no forced height
}
[Fact]
public void HitEdges_respectsResizableEdgesMask_bottomOnly()
{
var panel = new UiPanel { Left = 100, Top = 100, Width = 200, Height = 100,
Resizable = true, ResizableEdges = ResizeEdges.Bottom };
// bottom edge (y=200) → Bottom only
Assert.Equal(ResizeEdges.Bottom, UiRoot.HitEdges(panel, 200, 200, 5));
// top edge (y=100) → masked out → None
Assert.Equal(ResizeEdges.None, UiRoot.HitEdges(panel, 200, 100, 5));
}
}

View file

@ -157,7 +157,7 @@ public class UiRootInputTests
public void ResizeRect_RightBottom_GrowsSizeOnly()
{
var (x, y, w, h) = UiRoot.ResizeRect(10, 20, 100, 50,
ResizeEdges.Right | ResizeEdges.Bottom, dx: 30, dy: 15, minW: 40, minH: 40);
ResizeEdges.Right | ResizeEdges.Bottom, dx: 30, dy: 15, minW: 40, minH: 40, maxW: float.MaxValue, maxH: float.MaxValue);
Assert.Equal(10f, x); Assert.Equal(20f, y);
Assert.Equal(130f, w); Assert.Equal(65f, h);
}
@ -168,11 +168,32 @@ public class UiRootInputTests
// Drag left edge right by 80 on a 100-wide / min-40 window: width clamps to 40,
// origin shifts so the RIGHT edge (110) stays put → x = 70.
var (x, _, w, _) = UiRoot.ResizeRect(10, 20, 100, 50,
ResizeEdges.Left, dx: 80, dy: 0, minW: 40, minH: 40);
ResizeEdges.Left, dx: 80, dy: 0, minW: 40, minH: 40, maxW: float.MaxValue, maxH: float.MaxValue);
Assert.Equal(40f, w);
Assert.Equal(70f, x);
}
[Fact]
public void ResizeRect_Bottom_ClampsToMaxH()
{
// dy=1000 on a 50-tall window with maxH=128 → height clamps to 128, origin unchanged.
var (_, y, _, h) = UiRoot.ResizeRect(10, 20, 100, 50,
ResizeEdges.Bottom, dx: 0, dy: 1000, minW: 40, minH: 40, maxW: float.MaxValue, maxH: 128f);
Assert.Equal(128f, h);
Assert.Equal(20f, y);
}
[Fact]
public void ResizeRect_Top_ClampsToMaxH()
{
// Drag the top edge UP by 1000 on a 50-tall window with maxH=128 → height clamps to 128,
// origin shifts so the bottom edge (70) stays put → y = 20 + 50 - 128.
var (_, y, _, h) = UiRoot.ResizeRect(10, 20, 100, 50,
ResizeEdges.Top, dx: 0, dy: -1000, minW: 40, minH: 40, maxW: float.MaxValue, maxH: 128f);
Assert.Equal(128f, h);
Assert.Equal(20f + 50f - 128f, y);
}
[Fact]
public void HitEdges_DetectsCornerAndInteriorNone()
{

View file

@ -74,18 +74,25 @@ public sealed class InventoryActionsTests
}
[Fact]
public void BuildAddShortcut_ThreeFields()
public void BuildAddShortcut_ItemShortcut_FieldLayout()
{
byte[] body = InventoryActions.BuildAddShortcut(
seq: 1, slotIndex: 0, objectType: 1, targetId: 0x3E1);
// 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,
BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(8)));
Assert.Equal(0u,
BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(12)));
Assert.Equal(1u,
BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(16)));
Assert.Equal(0x3E1u,
BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(20)));
Assert.Equal(0u, BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(12))); // index
Assert.Equal(0x3E1u, BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(16))); // objectGuid
Assert.Equal((ushort)0, BinaryPrimitives.ReadUInt16LittleEndian(body.AsSpan(20))); // spellId
Assert.Equal((ushort)0, 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, BinaryPrimitives.ReadUInt16LittleEndian(body.AsSpan(20)));
Assert.Equal(3, BinaryPrimitives.ReadUInt16LittleEndian(body.AsSpan(22)));
}
[Fact]

View file

@ -0,0 +1,39 @@
using AcDream.Core.Items;
using Xunit;
namespace AcDream.Core.Tests.Items;
public class ShortcutStoreTests
{
[Fact]
public void Load_mapsSlotToObjId_skipsEmptyAndOutOfRange()
{
var store = new ShortcutStore();
store.Load(new (int, uint)[]
{
(0, 0x5001u),
(3, 0x5002u),
(5, 0u), // empty/spell → skipped
(99, 0x5003u), // out of range → skipped
});
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));
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
}
}