diff --git a/docs/ISSUES.md b/docs/ISSUES.md index 1d365fcb..035041d4 100644 --- a/docs/ISSUES.md +++ b/docs/ISSUES.md @@ -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. diff --git a/docs/architecture/retail-divergence-register.md b/docs/architecture/retail-divergence-register.md index 903a682b..48d38e34 100644 --- a/docs/architecture/retail-divergence-register.md +++ b/docs/architecture/retail-divergence-register.md @@ -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 | |---|---|---|---|---|---| diff --git a/docs/superpowers/plans/2026-06-20-d2b-drag-drop-spine.md b/docs/superpowers/plans/2026-06-20-d2b-drag-drop-spine.md new file mode 100644 index 00000000..71a6ac50 --- /dev/null +++ b/docs/superpowers/plans/2026-06-20-d2b-drag-drop-spine.md @@ -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) ` (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; + +/// +/// 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 anchors: gmToolbarUI 0x004bd162 / 0x004bd1af; InqDropIconInfo 230533. +/// +public enum ItemDragSource { Inventory, ShortcutBar, Equipment, Ground } + +/// +/// 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 intentionally NOT stored: the handler resolves the +/// LIVE container via ClientObjectTable.Get(ObjId).ContainerId at drop — the +/// same container id retail reads off the dragged element, single source of truth. +/// +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; + +/// +/// A panel controller implements this and registers itself on each of its +/// s. Port of retail's m_dragHandler vtable +/// (RegisterItemListDragHandler, decomp 230461; confirmed acclient +/// 0x004a539e + the gmToolbarUI block 0x004bdd89). +/// decides the accept/reject OVERLAY only (advisory). +/// is authoritative — it performs the action, or +/// no-ops to reject. +/// +public interface IItemListDragHandler +{ + /// True ⇒ the target cell shows the accept (green) frame; false ⇒ reject (red). + bool OnDragOver(UiItemList targetList, UiItemSlot targetCell, ItemDragPayload payload); + + /// Perform the drop (issue the per-panel wire action), or no-op to reject. + 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 + /// The drag handler this list routes drops to (a panel controller). + /// Null = the list accepts no drops. Retail: UIElement_ItemList::m_dragHandler. + public IItemListDragHandler? DragHandler { get; private set; } + + /// Register the panel's drag handler on this list. Retail: + /// UIElement_ItemList::RegisterItemListDragHandler (decomp 230461). + 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) " +``` + +--- + +## 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(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 + /// The data this element carries when a drag begins. + /// pulls this on drag-promote; a NULL return CANCELS the drag (retail: + /// ItemList_BeginDrag only arms an occupied cell). Default null = not draggable. + public virtual object? GetDragPayload() => null; + + /// The texture paints at the cursor while this element + /// is the drag source: (GL handle, width, height). Null = no ghost. Keeps + /// item-agnostic. Retail analog: m_dragIcon (decomp 229738). + 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 + /// This cell's own index within its panel (0..17 toolbar; container slot + /// for inventory). Distinct from (the 1–9 label, -1 on the + /// bottom row). Set by the controller; used as the drag payload's SourceSlot and to + /// identify the drop TARGET slot. + public int SlotIndex { get; set; } = -1; + + /// What kind of slot this is, for the drag payload (retail InqDropIconInfo + /// flags). Controller overrides; default Inventory. + public ItemDragSource SourceKind { get; set; } = ItemDragSource.Inventory; + + /// Drag-rollover accept frame (retail ItemSlot_DragOver_Accept 0x060011F9, + /// state id 0x10000041). Configurable; guard id != 0 before resolving. + public uint DragAcceptSprite { get; set; } = 0x060011F9u; + /// Drag-rollover reject frame (retail ItemSlot_DragOver_Reject 0x060011F8, + /// state id 0x10000040). + public uint DragRejectSprite { get; set; } = 0x060011F8u; + + /// Accept/reject overlay state while a drag hovers this cell. + public enum DragAcceptState { None, Accept, Reject } + private DragAcceptState _dragAccept = DragAcceptState.None; + /// Current overlay state — internal so unit tests can assert it (InternalsVisibleTo). + internal DragAcceptState DragAcceptVisual => _dragAccept; +``` + +(b) Add the two overrides (anywhere in the class, e.g. after `Clear()`): + +```csharp + /// + public override object? GetDragPayload() + => ItemId != 0 ? new ItemDragPayload(ItemId, SourceKind, SlotIndex, this) : null; + + /// + public override (uint tex, int w, int h)? GetDragGhost() + => ItemId != 0 && IconTexture != 0 ? (IconTexture, (int)Width, (int)Height) : null; + + /// Walk up to the containing (the drop handler owner). + 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 + /// + 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) " +``` + +--- + +## 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(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(); + } + + /// 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. + private const float GhostAlpha = 0.6f; + + /// Paint the drag ghost at the cursor. The texture comes from the source + /// element's so UiRoot stays item-agnostic; the + /// ghost is NOT a tree element, so it never intercepts hit-tests. + 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) " +``` + +--- + +## 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 ────────────────────────────────────────── + + /// Bind registers the controller as each slot list's drag handler and + /// stamps every cell's SlotIndex + SourceKind=ShortcutBar. + [Fact] + public void Bind_registersDragHandler_andStampsSlots() + { + var (layout, slots, _) = FakeToolbar(); + var repo = new ClientObjectTable(); + + var ctrl = ToolbarController.Bind(layout, repo, + () => Array.Empty(), + 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); + } + + /// OnDragOver accepts a real item (ObjId != 0). Eligibility (IsShortcutEligible) + /// is Stream B.2; the stub accepts any non-empty payload. + [Fact] + public void OnDragOver_acceptsRealItem() + { + var (layout, slots, _) = FakeToolbar(); + var ctrl = ToolbarController.Bind(layout, new ClientObjectTable(), + () => Array.Empty(), + 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)); + } + + /// HandleDropRelease is a logging stub (TS-33): it must not throw and must not + /// mutate the target slot (no wire / no store yet). + [Fact] + public void HandleDropRelease_isInertStub() + { + var (layout, slots, _) = FakeToolbar(); + var ctrl = ToolbarController.Bind(layout, new ClientObjectTable(), + () => Array.Empty(), + 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). + + /// + public bool OnDragOver(UiItemList targetList, UiItemSlot targetCell, ItemDragPayload payload) + => payload.ObjId != 0; + + /// + 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) " +``` + +--- + +## 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. +``` diff --git a/docs/superpowers/plans/2026-06-20-d2b-toolbar-shortcut-drag.md b/docs/superpowers/plans/2026-06-20-d2b-toolbar-shortcut-drag.md new file mode 100644 index 00000000..f0054329 --- /dev/null +++ b/docs/superpowers/plans/2026-06-20-d2b-toolbar-shortcut-drag.md @@ -0,0 +1,699 @@ +# Toolbar shortcut drag interactivity (Stream B.2) Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Make toolbar shortcut drag functional + retail-faithful — lift empties the source slot, the full-opacity icon follows the cursor with a green-cross indicator, dropping on another slot reorders (bumping the occupant to the source slot), dropping off-bar removes it, and all changes go to ACE via `AddShortcut 0x019C`/`RemoveShortcut 0x019D`. + +**Architecture:** Retail's **remove-on-lift / place-on-drop / no-restore** model. Three slices: (1) Core `ShortcutStore` + the wire builders/senders; (2) small spine extensions (a lift hook, a ghost snapshot, drop-on-hit-only); (3) `ToolbarController` as the live drag handler driving the store + wire. Spec: `docs/superpowers/specs/2026-06-20-d2b-toolbar-shortcut-drag-design.md`. + +**Tech Stack:** C# / .NET 10, xUnit. Core logic in `AcDream.Core`, wire in `AcDream.Core.Net`, UI toolkit in `AcDream.App/UI`. `InternalsVisibleTo("AcDream.App.Tests")` + `("AcDream.Core.Tests")` are set. + +--- + +## File Structure + +**Create:** +- `src/AcDream.Core/Items/ShortcutStore.cs` — mutable 18-slot shortcut model. +- `tests/AcDream.Core.Tests/Items/ShortcutStoreTests.cs` + +**Modify:** +- `src/AcDream.Core.Net/Messages/InventoryActions.cs` — `BuildAddShortcut` signature/field fix. +- `src/AcDream.Core.Net/WorldSession.cs` — `SendAddShortcut`/`SendRemoveShortcut`. +- `tests/AcDream.Core.Net.Tests/Messages/InventoryActionsTests.cs` — update `BuildAddShortcut_ThreeFields`. +- `src/AcDream.App/UI/IItemListDragHandler.cs` — add `OnDragLift`. +- `src/AcDream.App/UI/UiItemSlot.cs` — `DragBegin`→lift; `DropReleased` ungate. +- `src/AcDream.App/UI/UiRoot.cs` — `_dragGhost` snapshot; `GhostAlpha=1`; `FinishDrag` deliver-on-hit-only; test accessor. +- `tests/AcDream.App.Tests/UI/DragDropSpineTests.cs` — update B.1 tests + add lift/ghost/off-bar. +- `src/AcDream.App/UI/Layout/ToolbarController.cs` — store + handler + green cross + wire actions. +- `src/AcDream.App/Rendering/GameWindow.cs` — inject the session sends at the toolbar Bind. +- `tests/AcDream.App.Tests/UI/Layout/ToolbarControllerTests.cs` — lift/drop/store tests. +- `docs/architecture/retail-divergence-register.md` — reword AP-47, delete TS-33. + +**Commands** (from repo root): +- Build all: `dotnet build AcDream.slnx -c Debug` +- Core tests: `dotnet test tests/AcDream.Core.Tests/AcDream.Core.Tests.csproj` +- Net tests: `dotnet test tests/AcDream.Core.Net.Tests/AcDream.Core.Net.Tests.csproj` +- App tests: `dotnet test tests/AcDream.App.Tests/AcDream.App.Tests.csproj` + +**Every commit ends with** `Co-Authored-By: Claude Opus 4.8 (1M context) `. + +--- + +## Task 1: Core — ShortcutStore + the AddShortcut/RemoveShortcut wire + +**Files:** +- Create: `src/AcDream.Core/Items/ShortcutStore.cs`, `tests/AcDream.Core.Tests/Items/ShortcutStoreTests.cs` +- Modify: `src/AcDream.Core.Net/Messages/InventoryActions.cs`, `src/AcDream.Core.Net/WorldSession.cs`, `tests/AcDream.Core.Net.Tests/Messages/InventoryActionsTests.cs` + +- [ ] **Step 1: Write the failing ShortcutStore test** + +Create `tests/AcDream.Core.Tests/Items/ShortcutStoreTests.cs`: + +```csharp +using System.Collections.Generic; +using AcDream.Core.Items; +using AcDream.Core.Net.Messages; +using Xunit; + +namespace AcDream.Core.Tests.Items; + +public class ShortcutStoreTests +{ + [Fact] + public void Load_mapsIndexToObjId_skipsEmptyAndOutOfRange() + { + var store = new ShortcutStore(); + var entries = new List + { + new(Index: 0, ObjectGuid: 0x5001u, SpellId: 0, Layer: 0), + new(Index: 3, ObjectGuid: 0x5002u, SpellId: 0, Layer: 0), + new(Index: 5, ObjectGuid: 0u, SpellId: 7, Layer: 1), // spell-only → skipped (item store) + new(Index: 99, ObjectGuid: 0x5003u, SpellId: 0, Layer: 0), // out of range → skipped + }; + store.Load(entries); + Assert.Equal(0x5001u, store.Get(0)); + Assert.Equal(0x5002u, store.Get(3)); + Assert.Equal(0u, store.Get(5)); + Assert.True(store.IsEmpty(1)); + } + + [Fact] + public void SetRemoveGet_roundtrip_andBoundsSafe() + { + var store = new ShortcutStore(); + store.Set(4, 0xABCDu); + Assert.Equal(0xABCDu, store.Get(4)); + Assert.False(store.IsEmpty(4)); + store.Remove(4); + Assert.True(store.IsEmpty(4)); + // bounds-safe: out-of-range Get/Set/Remove never throw + Assert.Equal(0u, store.Get(-1)); + Assert.Equal(0u, store.Get(18)); + store.Set(18, 1u); // no-op, no throw + store.Remove(-1); // no-op, no throw + } +} +``` + +- [ ] **Step 2: Run to verify it fails** + +Run: `dotnet test tests/AcDream.Core.Tests/AcDream.Core.Tests.csproj --filter "FullyQualifiedName~ShortcutStore"` +Expected: FAIL to compile — `ShortcutStore` not defined. + +- [ ] **Step 3: Create ShortcutStore** + +Create `src/AcDream.Core/Items/ShortcutStore.cs`: + +```csharp +using System.Collections.Generic; +using AcDream.Core.Net.Messages; + +namespace AcDream.Core.Items; + +/// +/// Mutable client-side model of the 18 toolbar shortcut slots — port of retail +/// ShortCutManager::shortCuts_[18] (acclient.h:36492). Holds the bound object guid per +/// slot (0 = empty). Loaded from the login PlayerDescription, then mutated by drag-drop +/// (lift removes, drop places); the server is notified via AddShortcut/RemoveShortcut so the +/// store stays the client's source of truth within a session (retail: client owns the array). +/// Item shortcuts only — spell shortcuts (ObjectGuid 0) are skipped on Load. +/// +public sealed class ShortcutStore +{ + public const int SlotCount = 18; + private readonly uint[] _objIds = new uint[SlotCount]; + + /// Replace all slots from the login PlayerDescription shortcut list (item entries only). + public void Load(IReadOnlyList entries) + { + System.Array.Clear(_objIds); + foreach (var e in entries) + if (e.Index < SlotCount && e.ObjectGuid != 0) _objIds[(int)e.Index] = e.ObjectGuid; + } + + /// Bound object guid at , or 0 (empty / out of range). + public uint Get(int slot) => (uint)slot < SlotCount ? _objIds[slot] : 0u; + public bool IsEmpty(int slot) => Get(slot) == 0u; + public void Set(int slot, uint objId) { if ((uint)slot < SlotCount) _objIds[slot] = objId; } + public void Remove(int slot) { if ((uint)slot < SlotCount) _objIds[slot] = 0u; } +} +``` + +(`PlayerDescriptionParser.ShortcutEntry` has `uint Index, uint ObjectGuid, ushort SpellId, ushort Layer`. Verify the field types when you open the file; if `Index` is `uint`, the `e.Index < SlotCount` compare and the `(int)e.Index` cast as written are correct.) + +- [ ] **Step 4: Run to verify it passes** + +Run: `dotnet test tests/AcDream.Core.Tests/AcDream.Core.Tests.csproj --filter "FullyQualifiedName~ShortcutStore"` +Expected: PASS (2 tests). + +- [ ] **Step 5: Write the failing wire test (new BuildAddShortcut signature)** + +Replace the existing `BuildAddShortcut_ThreeFields` test in `tests/AcDream.Core.Net.Tests/Messages/InventoryActionsTests.cs` with: + +```csharp + [Fact] + public void BuildAddShortcut_ItemShortcut_FieldLayout() + { + // ShortCutData = Index(u32), ObjectId(u32), SpellId(u16), Layer(u16). Item → spell/layer 0. + byte[] body = InventoryActions.BuildAddShortcut(seq: 1, index: 0, objectGuid: 0x3E1, spellId: 0, layer: 0); + Assert.Equal(24, body.Length); + Assert.Equal(InventoryActions.AddShortcutOpcode, + System.Buffers.Binary.BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(8))); + Assert.Equal(0u, System.Buffers.Binary.BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(12))); // index + Assert.Equal(0x3E1u, System.Buffers.Binary.BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(16))); // objectGuid + Assert.Equal((ushort)0, System.Buffers.Binary.BinaryPrimitives.ReadUInt16LittleEndian(body.AsSpan(20))); // spellId + Assert.Equal((ushort)0, System.Buffers.Binary.BinaryPrimitives.ReadUInt16LittleEndian(body.AsSpan(22))); // layer + } + + [Fact] + public void BuildAddShortcut_SpellShortcut_PacksSpellAndLayerAsU16s() + { + byte[] body = InventoryActions.BuildAddShortcut(seq: 1, index: 2, objectGuid: 0, spellId: 0x1234, layer: 3); + Assert.Equal(0x1234, System.Buffers.Binary.BinaryPrimitives.ReadUInt16LittleEndian(body.AsSpan(20))); + Assert.Equal(3, System.Buffers.Binary.BinaryPrimitives.ReadUInt16LittleEndian(body.AsSpan(22))); + } +``` + +(The file already has `using System.Buffers.Binary;` per the other tests — if so, use the short `BinaryPrimitives.` form to match; the fully-qualified form above is safe regardless.) + +- [ ] **Step 6: Run to verify it fails** + +Run: `dotnet test tests/AcDream.Core.Net.Tests/AcDream.Core.Net.Tests.csproj --filter "FullyQualifiedName~BuildAddShortcut"` +Expected: FAIL to compile — `BuildAddShortcut` has no `(seq, index, objectGuid, spellId, layer)` overload (old signature is `(seq, slotIndex, objectType, targetId)`). + +- [ ] **Step 7: Fix BuildAddShortcut signature + field packing** + +In `src/AcDream.Core.Net/Messages/InventoryActions.cs`, replace the `BuildAddShortcut` method (the `(uint seq, uint slotIndex, uint objectType, uint targetId)` one) with: + +```csharp + /// Pin an item/spell to a quickbar slot. ShortCutData = Index(u32), ObjectId(u32), + /// SpellId(u16), Layer(u16) — CONFIRMED across ACE/Chorizite/holtburger (action-bar deep-dive + /// §131-145). For an ITEM: objectGuid = item guid, spellId = layer = 0. + public static byte[] BuildAddShortcut( + uint seq, uint index, uint objectGuid, ushort spellId, ushort layer) + { + byte[] body = new byte[24]; + BinaryPrimitives.WriteUInt32LittleEndian(body, GameActionEnvelope); + BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(4), seq); + BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(8), AddShortcutOpcode); + BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(12), index); + BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(16), objectGuid); + BinaryPrimitives.WriteUInt16LittleEndian(body.AsSpan(20), spellId); + BinaryPrimitives.WriteUInt16LittleEndian(body.AsSpan(22), layer); + return body; + } +``` + +- [ ] **Step 8: Add the WorldSession send wrappers** + +In `src/AcDream.Core.Net/WorldSession.cs`, after `SendChangeCombatMode` (~line 1141), add: + +```csharp + /// Send AddShortcut (0x019C) — pin an item to toolbar slot . + /// Retail: CM_Character::Event_AddShortCut. Mirrors the SendChangeCombatMode pattern. + public void SendAddShortcut(uint index, uint objectGuid, ushort spellId = 0, ushort layer = 0) + { + uint seq = NextGameActionSequence(); + SendGameAction(InventoryActions.BuildAddShortcut(seq, index, objectGuid, spellId, layer)); + } + + /// Send RemoveShortcut (0x019D) — clear toolbar slot . + /// Retail: CM_Character::Event_RemoveShortCut. + public void SendRemoveShortcut(uint index) + { + uint seq = NextGameActionSequence(); + SendGameAction(InventoryActions.BuildRemoveShortcut(seq, index)); + } +``` + +(Confirm `InventoryActions` is in scope — `WorldSession` already calls other `*Actions.Build*` builders; add a `using AcDream.Core.Net.Messages;` only if not already present.) + +- [ ] **Step 9: Run to verify all Core/Net tests pass** + +Run: `dotnet test tests/AcDream.Core.Net.Tests/AcDream.Core.Net.Tests.csproj --filter "FullyQualifiedName~InventoryActions"` then `dotnet build src/AcDream.Core.Net/AcDream.Core.Net.csproj -c Debug` +Expected: PASS; build green (no stale callers of the old 4-arg `BuildAddShortcut` — there were none besides the test). + +- [ ] **Step 10: Commit** + +```bash +git add src/AcDream.Core/Items/ShortcutStore.cs tests/AcDream.Core.Tests/Items/ShortcutStoreTests.cs src/AcDream.Core.Net/Messages/InventoryActions.cs src/AcDream.Core.Net/WorldSession.cs tests/AcDream.Core.Net.Tests/Messages/InventoryActionsTests.cs +git commit -m "feat(core): D.5.3/B.2 — ShortcutStore + AddShortcut/RemoveShortcut wire + senders" -m "Co-Authored-By: Claude Opus 4.8 (1M context) " +``` + +--- + +## Task 2: Spine extensions — lift hook + ghost snapshot + drop-on-hit-only + +**Files:** +- Modify: `src/AcDream.App/UI/IItemListDragHandler.cs`, `src/AcDream.App/UI/UiItemSlot.cs`, `src/AcDream.App/UI/UiRoot.cs` +- Test: `tests/AcDream.App.Tests/UI/DragDropSpineTests.cs` + +- [ ] **Step 1: Write/adjust the failing tests** in `tests/AcDream.App.Tests/UI/DragDropSpineTests.cs` + +First, the `SpyHandler` (nested in `DragDropSpineTests`) needs to record `OnDragLift`. UPDATE the `SpyHandler` class to add: + +```csharp + public (UiItemList list, UiItemSlot cell, ItemDragPayload payload)? LastLift; + public void OnDragLift(UiItemList list, UiItemSlot cell, ItemDragPayload p) + { LastLift = (list, cell, p); } +``` + +Then REPLACE the existing `DropReleased_notAccepted_skipsDispatch` test (its premise — Data0==0 skips — is gone under the retail model) with: + +```csharp + [Fact] + public void DropReleased_dispatchesToHandler_regardlessOfData0() + { + // Retail model: reaching the cell means a real slot was hit (FinishDrag only delivers on a + // hit), so the handler is authoritative — it dispatches whether or not Data0 is set. + var (list, cell, h) = ListWithHandler(); + cell.OnEvent(new UiEvent(0u, cell, UiEventType.DropReleased, Data0: 0, Payload: SomePayload())); + Assert.NotNull(h.LastDrop); + } +``` + +Then ADD these new tests inside the class: + +```csharp + [Fact] + public void DragBegin_callsHandlerOnDragLift() + { + var (list, cell, h) = ListWithHandler(); + var p = SomePayload(); + cell.OnEvent(new UiEvent(0u, cell, UiEventType.DragBegin, Payload: p)); + Assert.NotNull(h.LastLift); + Assert.Same(list, h.LastLift!.Value.list); + Assert.Same(cell, h.LastLift.Value.cell); + Assert.Same(p, h.LastLift.Value.payload); + } + + [Fact] + public void Ghost_isSnapshottedAtBeginDrag_survivesSourceCellClearing() + { + // The lift empties the source cell; the ghost must persist (snapshot at BeginDrag), not + // re-read the now-empty cell. Drive the full UiRoot chain, then clear the source cell. + var (root, _, cell) = RootWithBoundSlot(0x5001u); // icon tex 0x99 + root.OnMouseDown(UiMouseButton.Left, 10, 10); + root.OnMouseMove(20, 10); // BeginDrag → snapshot ghost + cell.Clear(); // simulate the lift emptying the source + Assert.Equal((0x99u, 32, 32), root.DragGhostForTest); + } + + [Fact] + public void FinishDrag_overNothing_deliversNoDrop_butLiftStands() + { + // Drag from a slot with a handler, release over empty space → no HandleDropRelease (off-bar), + // but OnDragLift already fired at begin (the lift). Requires the slot inside a list w/ handler. + var root = new UiRoot { Width = 800, Height = 600 }; + var list = new UiItemList(_ => (1u, 1, 1)) { Left = 0, Top = 0, Width = 32, Height = 32 }; + list.Cell.Width = 32; list.Cell.Height = 32; + list.Cell.SetItem(0x5001u, 0x99u); + var h = new SpyHandler(); + list.RegisterDragHandler(h); + root.AddChild(list); + + root.OnMouseDown(UiMouseButton.Left, 10, 10); + root.OnMouseMove(20, 10); // BeginDrag → OnDragLift + root.OnMouseUp(UiMouseButton.Left, 600, 500); // release over empty space + Assert.NotNull(h.LastLift); // lift happened + Assert.Null(h.LastDrop); // no drop dispatched (off-bar) + Assert.Null(root.DragSource); // cleaned up + } +``` + +- [ ] **Step 2: Run to verify it fails** + +Run: `dotnet test tests/AcDream.App.Tests/AcDream.App.Tests.csproj --filter "FullyQualifiedName~DragDropSpine"` +Expected: FAIL to compile — `IItemListDragHandler.OnDragLift` not defined (SpyHandler doesn't satisfy the interface), `root.DragGhostForTest` not defined. + +- [ ] **Step 3: Add OnDragLift to the interface** + +In `src/AcDream.App/UI/IItemListDragHandler.cs`, add as the FIRST member of the interface: + +```csharp + /// The drag STARTED from a cell in this list — retail's RecvNotice_ItemListBeginDrag + /// → RemoveShortcut (decomp 0x004bd930/0x004bd450): the handler removes the lifted item from its + /// model + wire so the source slot empties immediately. The item is "in hand" until + /// HandleDropRelease (place) or the drag ends off-target (stays removed). No restore on cancel. + void OnDragLift(UiItemList sourceList, UiItemSlot sourceCell, ItemDragPayload payload); +``` + +- [ ] **Step 4: UiItemSlot — DragBegin fires the lift; DropReleased ungated** + +In `src/AcDream.App/UI/UiItemSlot.cs` `OnEvent`, change the `DragBegin` case from `return true;` to: + +```csharp + case UiEventType.DragBegin: + // Notify the source list's handler so it can lift (remove + wire) — retail + // RecvNotice_ItemListBeginDrag → RemoveShortcut. UiRoot snapshotted the ghost first. + if (FindList() is { DragHandler: { } lh } liftList && e.Payload is ItemDragPayload lp) + lh.OnDragLift(liftList, this, lp); + return true; +``` + +And change the `DropReleased` case — drop the `e.Data0 == 1` gate (reaching the cell = a real slot was hit; the handler is authoritative): + +```csharp + case UiEventType.DropReleased: + _dragAccept = DragAcceptState.None; + if (FindList() is { DragHandler: { } dh } dl && e.Payload is ItemDragPayload dp) + dh.HandleDropRelease(dl, this, dp); + return true; +``` + +- [ ] **Step 5: UiRoot — snapshot the ghost, full opacity, deliver-on-hit-only** + +In `src/AcDream.App/UI/UiRoot.cs`: + +(a) Add the snapshot field near `DragPayload` (~line 73): `private (uint tex, int w, int h)? _dragGhost;` and a test accessor near it: `internal (uint tex, int w, int h)? DragGhostForTest => _dragGhost;` + +(b) In `BeginDrag`, snapshot the ghost BEFORE firing `DragBegin`: + +```csharp + private void BeginDrag(UiElement source) + { + var payload = source.GetDragPayload(); + if (payload is null) { _dragCandidate = false; return; } + DragSource = source; + DragPayload = payload; + _dragGhost = source.GetDragGhost(); // snapshot NOW — the DragBegin handler may empty the source cell + var e = new UiEvent(source.EventId, source, UiEventType.DragBegin, Payload: payload); + source.OnEvent(in e); + } +``` + +(c) Change `GhostAlpha` to `1.0f` and make `DrawDragGhost` use the snapshot: + +```csharp + private const float GhostAlpha = 1.0f; // retail m_dragIcon is the full icon, no fade + + private void DrawDragGhost(UiRenderContext ctx) + { + if (_dragGhost is not { } g || g.tex == 0) return; + ctx.DrawSprite(g.tex, MouseX - g.w / 2f, MouseY - g.h / 2f, g.w, g.h, + 0f, 0f, 1f, 1f, new Vector4(1f, 1f, 1f, GhostAlpha)); + } +``` + +(d) Replace `FinishDrag` to deliver only on a real hit + clear the ghost: + +```csharp + private void FinishDrag(int x, int y) + { + var (t, lx, ly) = HitTestTopDown(x, y); + if (t is not null) + { + // Dropped on a real element — deliver DropReleased; the hit cell's handler places. + // A non-item target's OnEvent ignores it, so an off-bar drop leaves the lift's removal. + var e = new UiEvent(DragSource!.EventId, t, UiEventType.DropReleased, + Data1: (int)lx, Data2: (int)ly, Payload: DragPayload); + t.OnEvent(in e); + } + // else: dropped on nothing — no drop fires; the lift (drag-begin removal) stands (retail). + DragSource = null; + DragPayload = null; + _dragGhost = null; + _lastDragHoverTarget = null; + } +``` + +- [ ] **Step 6: Run to verify it passes** + +Run: `dotnet test tests/AcDream.App.Tests/AcDream.App.Tests.csproj --filter "FullyQualifiedName~DragDropSpine"` +Expected: PASS (the updated + new spine tests; the existing arming, use-vs-drag, overlay, and window-topology tests still pass — `CompletedDrag_doesNotFireUse` still holds because a completed drag takes the `FinishDrag` path before any Click). + +- [ ] **Step 7: Run the full app suite (no regressions)** + +Run: `dotnet test tests/AcDream.App.Tests/AcDream.App.Tests.csproj` +Expected: all pass (the ToolbarController tests still pass — `ToolbarController` already implements the interface from B.1; you'll add the `OnDragLift` body in Task 3, but a default-throwing stub isn't present, so confirm: if the build fails because `ToolbarController` doesn't implement the new `OnDragLift` member, add a temporary `public void OnDragLift(...) {}` no-op in `ToolbarController` now and flesh it out in Task 3. Prefer to just do Task 3's `OnDragLift` body — but to keep Task 2 self-contained + green, a one-line empty `OnDragLift` is acceptable here and gets its real body in Task 3.) + +- [ ] **Step 8: Commit** + +```bash +git add src/AcDream.App/UI/IItemListDragHandler.cs src/AcDream.App/UI/UiItemSlot.cs src/AcDream.App/UI/UiRoot.cs tests/AcDream.App.Tests/UI/DragDropSpineTests.cs src/AcDream.App/UI/Layout/ToolbarController.cs +git commit -m "feat(ui): D.5.3/B.2 — spine: drag-lift hook + ghost snapshot (full opacity) + drop-on-hit-only" -m "Co-Authored-By: Claude Opus 4.8 (1M context) " +``` + +--- + +## Task 3: ToolbarController — live handler (store + reorder/remove + wire + green cross) + +**Files:** +- Modify: `src/AcDream.App/UI/Layout/ToolbarController.cs`, `src/AcDream.App/Rendering/GameWindow.cs`, `docs/architecture/retail-divergence-register.md` +- Test: `tests/AcDream.App.Tests/UI/Layout/ToolbarControllerTests.cs` + +- [ ] **Step 1: Write the failing tests** — append inside `ToolbarControllerTests` class + +```csharp + // ── B.2: live drag handler (store + reorder/remove + wire) ─────────────── + private static (System.Collections.Generic.List<(uint i,uint g)> adds, + System.Collections.Generic.List removes) NewSpies(out System.Action add, out System.Action rem) + { + var adds = new System.Collections.Generic.List<(uint,uint)>(); + var removes = new System.Collections.Generic.List(); + add = (i, g) => adds.Add((i, g)); + rem = i => removes.Add(i); + return (adds, removes); + } + + [Fact] + public void OnDragLift_removesSourceSlot_sendsRemove_emptiesCell() + { + var (layout, slots, _) = FakeToolbar(); + var repo = new ClientObjectTable(); + repo.AddOrUpdate(new ClientObject { ObjectId = 0x5001u, WeenieClassId = 1u, IconId = 0x06001234u }); + var shortcuts = new System.Collections.Generic.List + { new(Index: 3, ObjectGuid: 0x5001u, SpellId: 0, Layer: 0) }; + var (adds, removes) = NewSpies(out var add, out var rem); + + var ctrl = ToolbarController.Bind(layout, repo, () => shortcuts, + iconIds: (_,_,_,_,_) => 0x77u, useItem: _ => { }, + sendAddShortcut: add, sendRemoveShortcut: rem); + Assert.Equal(0x5001u, slots[Row1[3]].Cell.ItemId); // bound at slot 3 + + var payload = new ItemDragPayload(0x5001u, ItemDragSource.ShortcutBar, 3, slots[Row1[3]].Cell); + ctrl.OnDragLift(slots[Row1[3]], slots[Row1[3]].Cell, payload); + + Assert.Contains(3u, removes); // RemoveShortcut(3) sent + Assert.Equal(0u, slots[Row1[3]].Cell.ItemId); // source slot now empty + } + + [Fact] + public void HandleDropRelease_ontoOccupied_swaps_andSendsRetailSequence() + { + var (layout, slots, _) = FakeToolbar(); + var repo = new ClientObjectTable(); + repo.AddOrUpdate(new ClientObject { ObjectId = 0x5001u, WeenieClassId = 1u, IconId = 0x06001234u }); // A + repo.AddOrUpdate(new ClientObject { ObjectId = 0x5002u, WeenieClassId = 1u, IconId = 0x06005678u }); // B + var shortcuts = new System.Collections.Generic.List + { new(Index: 3, ObjectGuid: 0x5001u, SpellId: 0, Layer: 0), + new(Index: 5, ObjectGuid: 0x5002u, SpellId: 0, Layer: 0) }; + var (adds, removes) = NewSpies(out var add, out var rem); + var ctrl = ToolbarController.Bind(layout, repo, () => shortcuts, + iconIds: (_,_,_,_,_) => 0x77u, useItem: _ => { }, + sendAddShortcut: add, sendRemoveShortcut: rem); + + // Lift A from slot 3, then drop on occupied slot 5 (holds B). + var payload = new ItemDragPayload(0x5001u, ItemDragSource.ShortcutBar, 3, slots[Row1[3]].Cell); + ctrl.OnDragLift(slots[Row1[3]], slots[Row1[3]].Cell, payload); + ctrl.HandleDropRelease(slots[Row1[5]], slots[Row1[5]].Cell, payload); + + // End state: A at slot 5, B bumped to the vacated source slot 3. + Assert.Equal(0x5001u, slots[Row1[5]].Cell.ItemId); + Assert.Equal(0x5002u, slots[Row1[3]].Cell.ItemId); + // Wire (retail sequence): RemoveShortcut(3)[lift], RemoveShortcut(5)[evict B], AddShortcut(5,A), AddShortcut(3,B) + Assert.Equal(new[] { 3u, 5u }, removes.ToArray()); + Assert.Contains((5u, 0x5001u), adds); + Assert.Contains((3u, 0x5002u), adds); + } + + [Fact] + public void HandleDropRelease_ontoEmpty_placesOnly_sourceStaysEmpty() + { + var (layout, slots, _) = FakeToolbar(); + var repo = new ClientObjectTable(); + repo.AddOrUpdate(new ClientObject { ObjectId = 0x5001u, WeenieClassId = 1u, IconId = 0x06001234u }); + var shortcuts = new System.Collections.Generic.List + { new(Index: 3, ObjectGuid: 0x5001u, SpellId: 0, Layer: 0) }; + var (adds, removes) = NewSpies(out var add, out var rem); + var ctrl = ToolbarController.Bind(layout, repo, () => shortcuts, + iconIds: (_,_,_,_,_) => 0x77u, useItem: _ => { }, + sendAddShortcut: add, sendRemoveShortcut: rem); + + var payload = new ItemDragPayload(0x5001u, ItemDragSource.ShortcutBar, 3, slots[Row1[3]].Cell); + ctrl.OnDragLift(slots[Row1[3]], slots[Row1[3]].Cell, payload); + ctrl.HandleDropRelease(slots[Row1[7]], slots[Row1[7]].Cell, payload); // slot 7 empty + + Assert.Equal(0x5001u, slots[Row1[7]].Cell.ItemId); // A placed at 7 + Assert.Equal(0u, slots[Row1[3]].Cell.ItemId); // source 3 stays empty + Assert.Contains((7u, 0x5001u), adds); + Assert.DoesNotContain(adds, a => a.i == 3u); // no bump (target was empty) + } + + [Fact] + public void ToolbarSlots_useGreenCrossAcceptSprite() + { + var (layout, slots, _) = FakeToolbar(); + ToolbarController.Bind(layout, new ClientObjectTable(), + () => System.Array.Empty(), + iconIds: (_,_,_,_,_) => 0u, useItem: _ => { }); + Assert.Equal(0x060011FAu, slots[Row1[0]].Cell.DragAcceptSprite); // green cross, not the ring F9 + } +``` + +- [ ] **Step 2: Run to verify it fails** + +Run: `dotnet test tests/AcDream.App.Tests/AcDream.App.Tests.csproj --filter "FullyQualifiedName~ToolbarController"` +Expected: FAIL to compile — `ToolbarController.Bind` has no `sendAddShortcut`/`sendRemoveShortcut` params; `OnDragLift` is a no-op stub (from Task 2); `DragAcceptSprite` not set to FA. + +- [ ] **Step 3: Add the store + wire fields + Bind params** + +In `src/AcDream.App/UI/Layout/ToolbarController.cs`: + +(a) Add fields near the other private fields: + +```csharp + private readonly AcDream.Core.Items.ShortcutStore _store = new(); + private bool _storeLoaded; + private readonly Action? _sendAddShortcut; // (index, objectGuid) + private readonly Action? _sendRemoveShortcut; // (index) +``` + +(b) Add the two params to the private ctor signature and the `Bind` factory (both, at the end, optional), and assign them in the ctor: + +```csharp + // ctor params (add after emptyDigits): + Action? sendAddShortcut = null, + Action? sendRemoveShortcut = null, + // ctor body (with the other assignments): + _sendAddShortcut = sendAddShortcut; + _sendRemoveShortcut = sendRemoveShortcut; +``` + +(Mirror the same two params on the `public static ToolbarController Bind(...)` signature and forward them into the `new ToolbarController(...)` call.) + +(c) In the ctor's slot loop (where B.1 already does `RegisterDragHandler(this)` + `SlotIndex` + `SourceKind`), add the green-cross sprite: + +```csharp + list.Cell.DragAcceptSprite = 0x060011FAu; // green cross (toolbar), not the ring 0x060011F9 (inventory) +``` + +- [ ] **Step 4: Switch Populate + IsShortcutGuid to the store; add the handler bodies** + +(a) Replace `Populate()` with the store-driven version (lazy-load once): + +```csharp + public void Populate() + { + // Lazy-load the store from the login shortcut list the first time it's present (PD arrives + // after Bind); thereafter the store is authoritative and drag ops mutate it directly. + if (!_storeLoaded && _shortcuts().Count > 0) { _store.Load(_shortcuts()); _storeLoaded = true; } + + foreach (var list in _slots) list?.Cell.Clear(); + + for (int slot = 0; slot < _slots.Length; slot++) + { + uint guid = _store.Get(slot); + if (guid == 0) continue; + var list = _slots[slot]; + if (list is null) continue; + var item = _repo.Get(guid); + if (item is null) continue; // deferred: ObjectAdded re-calls Populate + uint tex = _iconIds(item.Type, item.IconId, item.IconUnderlayId, item.IconOverlayId, item.Effects); + list.Cell.SetItem(guid, tex); + } + RestampShortcutNumbers(); + } +``` + +(b) Replace `IsShortcutGuid` to read the store: + +```csharp + private bool IsShortcutGuid(uint guid) + { + for (int s = 0; s < AcDream.Core.Items.ShortcutStore.SlotCount; s++) + if (_store.Get(s) == guid) return true; + return false; + } +``` + +(c) Replace the Task-2 stub `OnDragLift` (and the B.1 `HandleDropRelease` logging stub) with the real bodies: + +```csharp + /// + public void OnDragLift(UiItemList sourceList, UiItemSlot sourceCell, ItemDragPayload payload) + { + // Retail RecvNotice_ItemListBeginDrag → RemoveShortcut (0x004bd930/0x004bd450): the lifted + // shortcut leaves the bar (+ wire) the instant the drag starts; it's re-placed only on a + // drop onto a slot. Off-bar release leaves it removed. + _store.Remove(payload.SourceSlot); + _sendRemoveShortcut?.Invoke((uint)payload.SourceSlot); + Populate(); + } + + /// + public void HandleDropRelease(UiItemList targetList, UiItemSlot targetCell, ItemDragPayload payload) + { + // Retail gmToolbarUI::HandleDropRelease (0x004be7c0) within-bar reorder branch (deep-dive §5): + // evict the target's occupant, place the dragged item there, and bump the evicted item into + // the (now-vacated) source slot if it's free. + int target = targetCell.SlotIndex; + uint evicted = _store.Get(target); // RemoveShortcutInSlotNum(target) + if (evicted != 0) { _store.Remove(target); _sendRemoveShortcut?.Invoke((uint)target); } + _store.Set(target, payload.ObjId); // AddShortcut(dragged, target) + _sendAddShortcut?.Invoke((uint)target, payload.ObjId); + if (evicted != 0 && evicted != payload.ObjId && _store.IsEmpty(payload.SourceSlot)) + { // displaced → vacated source slot + _store.Set(payload.SourceSlot, evicted); + _sendAddShortcut?.Invoke((uint)payload.SourceSlot, evicted); + } + Populate(); + } +``` + +(Delete the old logging `Console.WriteLine` `HandleDropRelease` body and the `OnDragOver` stays as-is.) + +- [ ] **Step 5: Run to verify the controller tests pass** + +Run: `dotnet test tests/AcDream.App.Tests/AcDream.App.Tests.csproj --filter "FullyQualifiedName~ToolbarController"` +Expected: PASS (new B.2 tests + the existing B.1 controller tests — `Bind_registersDragHandler_andStampsSlots`, `Populate_bindsShortcutToCorrectSlot` etc. still hold; note `Populate_bindsShortcutToCorrectSlot` exercises the lazy-load path now and should still bind 0x5001 to slot 0). + +- [ ] **Step 6: Inject the session sends at the GameWindow toolbar Bind** + +In `src/AcDream.App/Rendering/GameWindow.cs`, at the `ToolbarController.Bind(...)` call (~line 2013), add the two send actions (mirror the `sendQueryHealth: g => _liveSession?.SendQueryHealth(g)` style used by `SelectedObjectController.Bind` just below it): + +```csharp + sendAddShortcut: (i, g) => _liveSession?.SendAddShortcut(i, g), + sendRemoveShortcut: i => _liveSession?.SendRemoveShortcut(i), +``` + +(Add them as the last arguments to the `ToolbarController.Bind(...)` call, after `emptyDigits:`.) + +- [ ] **Step 7: Update the divergence register** + +In `docs/architecture/retail-divergence-register.md`: +- **Reword AP-47** (the ghost row) — remove the reduced-alpha claim (the ghost is now full opacity, matching retail). New text for the Divergence cell: + `Cursor drag ghost reuses the full composited m_pIcon (incl. type-default underlay) instead of retail's dedicated underlay-less m_pDragIcon. Opacity now matches retail (full).` + and the Risk cell: `The ghost carries the opaque type-default underlay backing rather than retail's underlay-less copy — a subtle look difference while dragging, no functional effect.` (Where/oracle columns unchanged.) +- **Delete the TS-33 row** entirely (the logging stub is replaced by the real store + wire) and change the TS section header count `## 4. Temporary stopgap (TS) — 32 rows` → `— 31 rows`. + +- [ ] **Step 8: Full build + test (no regressions)** + +Run: `dotnet build AcDream.slnx -c Debug` then `dotnet test tests/AcDream.App.Tests/AcDream.App.Tests.csproj` and `dotnet test tests/AcDream.Core.Tests/AcDream.Core.Tests.csproj` +Expected: build green; all pass. + +- [ ] **Step 9: Commit** + +```bash +git add src/AcDream.App/UI/Layout/ToolbarController.cs src/AcDream.App/Rendering/GameWindow.cs tests/AcDream.App.Tests/UI/Layout/ToolbarControllerTests.cs docs/architecture/retail-divergence-register.md +git commit -m "feat(ui): D.5.3/B.2 — toolbar reorder/remove via ShortcutStore + wire + green-cross overlay" -m "Co-Authored-By: Claude Opus 4.8 (1M context) " +``` + +--- + +## Final verification + +- [ ] **Full solution:** `dotnet build AcDream.slnx -c Debug`; `dotnet test tests/AcDream.Core.Tests/...`, `tests/AcDream.Core.Net.Tests/...`, `tests/AcDream.App.Tests/...` — all green. +- [ ] **Spec acceptance §9 self-check:** UiRoot/UiItemSlot stay item-agnostic; AP-47 reworded; TS-33 deleted; wire builder renamed with no stale callers. +- [ ] **Hand to visual verification** (user, `ACDREAM_RETAIL_UI=1`, in-world): lift a hotbar item → source slot empties immediately; full-opacity icon follows the cursor; hovered slots show a green CROSS; drop on another slot → moves there (occupant bumps to source); drop off-bar → removed; single click still uses; changes persist after relog. +- [ ] **Update memory** (`project_d2b_retail_ui.md`) with the B.2 landing + the retail remove-on-lift model; refresh #141 / roadmap as needed. diff --git a/docs/superpowers/specs/2026-06-20-d2b-drag-drop-spine-design.md b/docs/superpowers/specs/2026-06-20-d2b-drag-drop-spine-design.md new file mode 100644 index 00000000..56cb25d6 --- /dev/null +++ b/docs/superpowers/specs/2026-06-20-d2b-drag-drop-spine-design.md @@ -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.1–5.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; + +/// 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. +public enum ItemDragSource { Inventory, ShortcutBar, Equipment, Ground } + +/// 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). +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 +/// 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. +public virtual object? GetDragPayload() => null; + +/// 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. +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 1–9 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 +/// 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). +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.) diff --git a/docs/superpowers/specs/2026-06-20-d2b-toolbar-collapse-design.md b/docs/superpowers/specs/2026-06-20-d2b-toolbar-collapse-design.md new file mode 100644 index 00000000..92cd7f25 --- /dev/null +++ b/docs/superpowers/specs/2026-06-20-d2b-toolbar-collapse-design.md @@ -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 1–9) or +expanded to show both rows (slots 1–18), 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 +/// Maximum height enforced while resizing (default unbounded). Pairs with MinHeight. +public float MaxHeight { get; set; } = float.MaxValue; + +/// 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). +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 resolveChrome) : base(resolveChrome) { } + + public float CollapsedHeight { get; set; } + public float ExpandedHeight { get; set; } + /// Elements shown only when expanded (the row-2 slot lists). Hidden when collapsed. + public IReadOnlyList SecondRow { get; set; } = System.Array.Empty(); + + /// True when the frame is currently at (or nearer) the expanded stop. + 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 = `. (`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. diff --git a/docs/superpowers/specs/2026-06-20-d2b-toolbar-shortcut-drag-design.md b/docs/superpowers/specs/2026-06-20-d2b-toolbar-shortcut-drag-design.md new file mode 100644 index 00000000..994f05c7 --- /dev/null +++ b/docs/superpowers/specs/2026-06-20-d2b-toolbar-shortcut-drag-design.md @@ -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 +{ + /// 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). + 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 + + /// Replace all slots from the login PlayerDescription shortcut list. + public void Load(IReadOnlyList 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 +/// Pin an item/spell to a quickbar slot. ShortCutData = Index(u32),ObjectId(u32), +/// SpellId(u16),Layer(u16). For an item: objectGuid + spellId=layer=0. +public static byte[] BuildAddShortcut(uint seq, uint index, uint objectGuid, ushort spellId, ushort layer) +{ + byte[] body = new byte[24]; + BinaryPrimitives.WriteUInt32LittleEndian(body, GameActionEnvelope); + BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(4), seq); + BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(8), AddShortcutOpcode); // 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 sendAdd` = `(index, objId) => session.SendAddShortcut(index, objId)`, + `Action 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.) diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs index 95191d49..bba630e7 100644 --- a/src/AcDream.App/Rendering/GameWindow.cs +++ b/src/AcDream.App/Rendering/GameWindow.cs @@ -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(); + 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)."); diff --git a/src/AcDream.App/UI/IItemListDragHandler.cs b/src/AcDream.App/UI/IItemListDragHandler.cs new file mode 100644 index 00000000..2c8f1f96 --- /dev/null +++ b/src/AcDream.App/UI/IItemListDragHandler.cs @@ -0,0 +1,25 @@ +namespace AcDream.App.UI; + +/// +/// A panel controller implements this and registers itself on each of its +/// s. Port of retail's m_dragHandler vtable +/// (RegisterItemListDragHandler, decomp 230461; confirmed acclient +/// 0x004a539e + the gmToolbarUI block 0x004bdd89). +/// decides the accept/reject OVERLAY only (advisory). +/// is authoritative — it performs the action, or +/// no-ops to reject. +/// +public interface IItemListDragHandler +{ + /// The drag STARTED from a cell in this list — retail's RecvNotice_ItemListBeginDrag + /// → RemoveShortcut (decomp 0x004bd930/0x004bd450): the handler removes the lifted item from its + /// model + wire so the source slot empties immediately. The item is "in hand" until + /// HandleDropRelease (place) or the drag ends off-target (stays removed). No restore on cancel. + void OnDragLift(UiItemList sourceList, UiItemSlot sourceCell, ItemDragPayload payload); + + /// True ⇒ the target cell shows the accept (green) frame; false ⇒ reject (red). + bool OnDragOver(UiItemList targetList, UiItemSlot targetCell, ItemDragPayload payload); + + /// Perform the drop (issue the per-panel wire action), or no-op to reject. + void HandleDropRelease(UiItemList targetList, UiItemSlot targetCell, ItemDragPayload payload); +} diff --git a/src/AcDream.App/UI/ItemDragPayload.cs b/src/AcDream.App/UI/ItemDragPayload.cs new file mode 100644 index 00000000..b06728d4 --- /dev/null +++ b/src/AcDream.App/UI/ItemDragPayload.cs @@ -0,0 +1,24 @@ +namespace AcDream.App.UI; + +/// +/// 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 anchors: gmToolbarUI 0x004bd162 / 0x004bd1af; InqDropIconInfo 230533. +/// +public enum ItemDragSource { Inventory, ShortcutBar, Equipment, Ground } + +/// +/// 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 intentionally NOT stored: the handler resolves the +/// LIVE container via ClientObjectTable.Get(ObjId).ContainerId at drop — the +/// same container id retail reads off the dragged element, single source of truth. +/// +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 diff --git a/src/AcDream.App/UI/Layout/ToolbarController.cs b/src/AcDream.App/UI/Layout/ToolbarController.cs index 1279328a..e4fd00c4 100644 --- a/src/AcDream.App/UI/Layout/ToolbarController.cs +++ b/src/AcDream.App/UI/Layout/ToolbarController.cs @@ -23,7 +23,7 @@ namespace AcDream.App.UI.Layout; /// CreateObject resolves a formerly-unknown guid. /// /// -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> _shortcuts; private readonly Func _iconIds; // (itemType, icon, underlay, overlay, effects) → GL tex private readonly Action _useItem; // guid → fire UseObject + private readonly AcDream.Core.Items.ShortcutStore _store = new(); + private bool _storeLoaded; + private readonly Action? _sendAddShortcut; // (index, objectGuid) + private readonly Action? _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? sendAddShortcut = null, + Action? 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 } /// - /// Returns true if is one of the currently-active shortcut guids. + /// Returns true if is one of the currently-active shortcut guids + /// (i.e., present in the live ). /// Used to gate repo-event subscriptions so we don't re-populate on every creature spawn. + /// Falls back to scanning _shortcuts() before the store is loaded (pre-PD window). /// 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? sendAddShortcut = null, + Action? 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 /// ObjectAdded event re-fires this method when the item arrives /// (matching retail's SetDelayedShortcutNum deferred-rebind path). + /// As of B.2: the is the + /// authoritative in-session slot map; _shortcuts() seeds it on first call. /// 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 + + /// + public void OnDragLift(UiItemList sourceList, UiItemSlot sourceCell, ItemDragPayload payload) + { + // Retail RecvNotice_ItemListBeginDrag → RemoveShortcut (0x004bd930/0x004bd450): the lifted + // shortcut leaves the bar (+ wire) the instant the drag starts; it's re-placed only on a + // drop onto a slot. Off-bar release leaves it removed. + _store.Remove(payload.SourceSlot); + _sendRemoveShortcut?.Invoke((uint)payload.SourceSlot); + Populate(); + } + + /// + public bool OnDragOver(UiItemList targetList, UiItemSlot targetCell, ItemDragPayload payload) + => payload.ObjId != 0; + + /// + 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(); + } } diff --git a/src/AcDream.App/UI/UiCollapsibleFrame.cs b/src/AcDream.App/UI/UiCollapsibleFrame.cs new file mode 100644 index 00000000..7f1fc340 --- /dev/null +++ b/src/AcDream.App/UI/UiCollapsibleFrame.cs @@ -0,0 +1,36 @@ +using System; +using System.Collections.Generic; + +namespace AcDream.App.UI; + +/// +/// 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 = 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. +/// +public sealed class UiCollapsibleFrame : UiNineSlicePanel +{ + public UiCollapsibleFrame(Func resolve) : base(resolve) { } + + public float CollapsedHeight { get; set; } + public float ExpandedHeight { get; set; } + /// Elements shown only when expanded (the row-2 slot lists). Hidden when collapsed. + public IReadOnlyList SecondRow { get; set; } = Array.Empty(); + + /// True when the frame is at (or nearer) the expanded stop. + 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; + } + + /// Test hook — OnTick is protected. Drives one snap+visibility reconcile. + internal void TickForTest(double dt) => OnTick(dt); +} diff --git a/src/AcDream.App/UI/UiElement.cs b/src/AcDream.App/UI/UiElement.cs index b22da24e..72a49d63 100644 --- a/src/AcDream.App/UI/UiElement.cs +++ b/src/AcDream.App/UI/UiElement.cs @@ -113,15 +113,31 @@ public abstract class UiElement /// drag-drop candidacy is suppressed in favour of the element's own handling. public bool CapturesPointerDrag { get; set; } + /// If true, a left-press-and-move on this element starts a DRAG-DROP + /// ( 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 (a self-driven + /// interior drag like text selection, which does NOT promote to BeginDrag). Default + /// false; overridden by drag sources (e.g. an occupied ). + public virtual bool IsDragSource => false; + /// Minimum size enforced while resizing. public float MinWidth { get; set; } = 40f; public float MinHeight { get; set; } = 40f; + /// Maximum height enforced while resizing (default unbounded). Pairs with MinHeight. + public float MaxHeight { get; set; } = float.MaxValue; + /// Allow horizontal (width) resize. Ignored unless . public bool ResizeX { get; set; } = true; /// Allow vertical (height) resize. Ignored unless . public bool ResizeY { get; set; } = true; + /// Which edges may start a resize, beyond the ResizeX/ResizeY axis gates. Default: all. + /// Set to e.g. to allow only a bottom-edge drag (the collapse toolbar). + public ResizeEdges ResizableEdges { get; set; } = + ResizeEdges.Left | ResizeEdges.Right | ResizeEdges.Top | ResizeEdges.Bottom; + /// Edges this element anchors to in its parent. Default Left|Top /// (pinned top-left, fixed size — no reflow). Left|Right stretches width. public AnchorEdges Anchors { get; set; } = AnchorEdges.Left | AnchorEdges.Top; @@ -203,6 +219,16 @@ public abstract class UiElement /// public virtual bool OnEvent(in UiEvent e) => false; + /// The data this element carries when a drag begins. + /// pulls this on drag-promote; a NULL return CANCELS the drag (retail: + /// ItemList_BeginDrag only arms an occupied cell). Default null = not draggable. + public virtual object? GetDragPayload() => null; + + /// The texture paints at the cursor while this element + /// is the drag source: (GL handle, width, height). Null = no ghost. Keeps + /// item-agnostic. Retail analog: m_dragIcon (decomp 229738). + public virtual (uint tex, int w, int h)? GetDragGhost() => null; + /// /// Tooltip text for this widget. Retail fires event 0x07 after /// ~1000ms hover, then queries the widget's virtual "GetString" diff --git a/src/AcDream.App/UI/UiItemList.cs b/src/AcDream.App/UI/UiItemList.cs index 761d4db3..6c4ce9bf 100644 --- a/src/AcDream.App/UI/UiItemList.cs +++ b/src/AcDream.App/UI/UiItemList.cs @@ -24,6 +24,14 @@ public sealed class UiItemList : UiElement public Func? SpriteResolve { get; set; } + /// The drag handler this list routes drops to (a panel controller). + /// Null = the list accepts no drops. Retail: UIElement_ItemList::m_dragHandler. + public IItemListDragHandler? DragHandler { get; private set; } + + /// Register the panel's drag handler on this list. Retail: + /// UIElement_ItemList::RegisterItemListDragHandler (decomp 230461). + public void RegisterDragHandler(IItemListDragHandler handler) => DragHandler = handler; + /// Convenience for single-cell slots (the toolbar): the first cell. /// Valid only while the list has at least one cell; after /// (the inventory-phase rebuild path) the list is empty until diff --git a/src/AcDream.App/UI/UiItemSlot.cs b/src/AcDream.App/UI/UiItemSlot.cs index d3ff3b7d..35973c09 100644 --- a/src/AcDream.App/UI/UiItemSlot.cs +++ b/src/AcDream.App/UI/UiItemSlot.cs @@ -21,6 +21,29 @@ public sealed class UiItemSlot : UiElement /// Pre-composited icon GL texture for the bound item (0 = none). public uint IconTexture { get; private set; } + /// This cell's own index within its panel (0..17 toolbar; container slot + /// for inventory). Distinct from (the 1–9 label, -1 on the + /// bottom row). Set by the controller; used as the drag payload's SourceSlot and to + /// identify the drop TARGET slot. + public int SlotIndex { get; set; } = -1; + + /// What kind of slot this is, for the drag payload (retail InqDropIconInfo + /// flags). Controller overrides; default Inventory. + public ItemDragSource SourceKind { get; set; } = ItemDragSource.Inventory; + + /// Drag-rollover accept frame (retail ItemSlot_DragOver_Accept 0x060011F9, + /// state id 0x10000041). Configurable; guard id != 0 before resolving. + public uint DragAcceptSprite { get; set; } = 0x060011F9u; + /// Drag-rollover reject frame (retail ItemSlot_DragOver_Reject 0x060011F8, + /// state id 0x10000040). + public uint DragRejectSprite { get; set; } = 0x060011F8u; + + /// Accept/reject overlay state while a drag hovers this cell. + public enum DragAcceptState { None, Accept, Reject } + private DragAcceptState _dragAccept = DragAcceptState.None; + /// Current overlay state — internal so unit tests can assert it (InternalsVisibleTo). + internal DragAcceptState DragAcceptVisual => _dragAccept; + /// 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. @@ -37,6 +60,30 @@ public sealed class UiItemSlot : UiElement public void Clear() { ItemId = 0; IconTexture = 0; } + /// + public override object? GetDragPayload() + => ItemId != 0 ? new ItemDragPayload(ItemId, SourceKind, SlotIndex, this) : null; + + /// + public override (uint tex, int w, int h)? GetDragGhost() + => ItemId != 0 && IconTexture != 0 ? (IconTexture, (int)Width, (int)Height) : null; + + /// 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 '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). + public override bool IsDragSource => ItemId != 0; + + /// Walk up to the containing (the drop handler owner). + 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 /// 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); + } + } } } diff --git a/src/AcDream.App/UI/UiNineSlicePanel.cs b/src/AcDream.App/UI/UiNineSlicePanel.cs index f407f07b..1c4c207b 100644 --- a/src/AcDream.App/UI/UiNineSlicePanel.cs +++ b/src/AcDream.App/UI/UiNineSlicePanel.cs @@ -10,7 +10,7 @@ namespace AcDream.App.UI; /// the widget is testable without GL. In production: /// id => { var t = cache.GetOrUploadRenderSurface(id, out var w, out var h); return (t, w, h); }. /// -public sealed class UiNineSlicePanel : UiPanel +public class UiNineSlicePanel : UiPanel { /// A placed chrome piece: destination rect in local pixel space. public readonly record struct Rect(float X, float Y, float W, float H); diff --git a/src/AcDream.App/UI/UiRoot.cs b/src/AcDream.App/UI/UiRoot.cs index 91fd219d..b43201dc 100644 --- a/src/AcDream.App/UI/UiRoot.cs +++ b/src/AcDream.App/UI/UiRoot.cs @@ -71,6 +71,9 @@ public sealed class UiRoot : UiElement /// Current drag source (set between drag-begin and drop/cancel). public UiElement? DragSource { get; private set; } public object? DragPayload { get; private set; } + private (uint tex, int w, int h)? _dragGhost; + /// Snapshotted drag-ghost (tex,w,h), exposed for tests. See BeginDrag. + 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 + + /// Paint the drag ghost at the cursor. The texture comes from the snapshotted + /// ghost captured in 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. + 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; } /// Compute a resized rect from a start rect + drag delta + which edges, - /// clamping to (,). Left/Top edges - /// move the origin so the opposite edge stays put. + /// clamping to (,) and + /// (,). Left/Top edges move the + /// origin so the opposite edge stays put. 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); } diff --git a/src/AcDream.Core.Net/Messages/InventoryActions.cs b/src/AcDream.Core.Net/Messages/InventoryActions.cs index f46bd5b6..72fe566c 100644 --- a/src/AcDream.Core.Net/Messages/InventoryActions.cs +++ b/src/AcDream.Core.Net/Messages/InventoryActions.cs @@ -95,17 +95,20 @@ public static class InventoryActions return body; } - /// Pin an item / spell to a quickbar slot. + /// Pin an item/spell to a quickbar slot. ShortCutData = Index(u32), ObjectId(u32), + /// SpellId(u16), Layer(u16) — CONFIRMED across ACE/Chorizite/holtburger (action-bar deep-dive + /// §131-145). For an ITEM: objectGuid = item guid, spellId = layer = 0. public static byte[] BuildAddShortcut( - uint seq, uint 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; } diff --git a/src/AcDream.Core.Net/WorldSession.cs b/src/AcDream.Core.Net/WorldSession.cs index 6508084d..3f6dbbe2 100644 --- a/src/AcDream.Core.Net/WorldSession.cs +++ b/src/AcDream.Core.Net/WorldSession.cs @@ -1140,6 +1140,22 @@ public sealed class WorldSession : IDisposable SendGameAction(body); } + /// Send AddShortcut (0x019C) — pin an item to toolbar slot . + /// Retail: CM_Character::Event_AddShortCut. Mirrors the SendChangeCombatMode pattern. + public void SendAddShortcut(uint index, uint objectGuid, ushort spellId = 0, ushort layer = 0) + { + uint seq = NextGameActionSequence(); + SendGameAction(InventoryActions.BuildAddShortcut(seq, index, objectGuid, spellId, layer)); + } + + /// Send RemoveShortcut (0x019D) — clear toolbar slot . + /// Retail: CM_Character::Event_RemoveShortCut. + public void SendRemoveShortcut(uint index) + { + uint seq = NextGameActionSequence(); + SendGameAction(InventoryActions.BuildRemoveShortcut(seq, index)); + } + /// Send retail QueryHealth (0x01BF). Server replies UpdateHealth (0x01C0). /// /// Retail anchor: CM_Combat::Event_QueryHealth / gmToolbarUI::HandleSelectionChanged:198635 diff --git a/src/AcDream.Core/Items/ShortcutStore.cs b/src/AcDream.Core/Items/ShortcutStore.cs new file mode 100644 index 00000000..0f581a2d --- /dev/null +++ b/src/AcDream.Core/Items/ShortcutStore.cs @@ -0,0 +1,34 @@ +using System; +using System.Collections.Generic; + +namespace AcDream.Core.Items; + +/// +/// Mutable client-side model of the 18 toolbar shortcut slots — port of retail +/// ShortCutManager::shortCuts_[18] (acclient.h:36492). Holds the bound object guid per +/// slot (0 = empty). Loaded from the login 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. +/// +public sealed class ShortcutStore +{ + public const int SlotCount = 18; + private readonly uint[] _objIds = new uint[SlotCount]; + + /// Replace all slots from a (slot, objectGuid) sequence (item entries only; + /// ObjGuid 0 and out-of-range slots are skipped). + 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; + } + + /// Bound object guid at , or 0 (empty / out of range). + public uint Get(int slot) => (uint)slot < SlotCount ? _objIds[slot] : 0u; + public bool IsEmpty(int slot) => Get(slot) == 0u; + public void Set(int slot, uint objId) { if ((uint)slot < SlotCount) _objIds[slot] = objId; } + public void Remove(int slot) { if ((uint)slot < SlotCount) _objIds[slot] = 0u; } +} diff --git a/tests/AcDream.App.Tests/UI/DragDropSpineTests.cs b/tests/AcDream.App.Tests/UI/DragDropSpineTests.cs new file mode 100644 index 00000000..6448256e --- /dev/null +++ b/tests/AcDream.App.Tests/UI/DragDropSpineTests.cs @@ -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(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(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) + } +} diff --git a/tests/AcDream.App.Tests/UI/Layout/ToolbarControllerTests.cs b/tests/AcDream.App.Tests/UI/Layout/ToolbarControllerTests.cs index 9668a586..6f590738 100644 --- a/tests/AcDream.App.Tests/UI/Layout/ToolbarControllerTests.cs +++ b/tests/AcDream.App.Tests/UI/Layout/ToolbarControllerTests.cs @@ -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 ────────────────────────────────────────── + + /// Bind registers the controller as each slot list's drag handler and + /// stamps every cell's SlotIndex + SourceKind=ShortcutBar. + [Fact] + public void Bind_registersDragHandler_andStampsSlots() + { + var (layout, slots, _) = FakeToolbar(); + var repo = new ClientObjectTable(); + + var ctrl = ToolbarController.Bind(layout, repo, + () => Array.Empty(), + 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); + } + } + + /// OnDragOver accepts a real item (ObjId != 0). Eligibility (IsShortcutEligible) + /// is Stream B.2; the stub accepts any non-empty payload. + [Fact] + public void OnDragOver_acceptsRealItem() + { + var (layout, slots, _) = FakeToolbar(); + var ctrl = ToolbarController.Bind(layout, new ClientObjectTable(), + () => Array.Empty(), + 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)); + } + + /// OnDragOver rejects a ghost payload with ObjId 0 (the guard against an + /// empty cell that somehow produced a payload). Complements OnDragOver_acceptsRealItem. + [Fact] + public void OnDragOver_rejectsZeroObjId() + { + var (layout, slots, _) = FakeToolbar(); + var ctrl = ToolbarController.Bind(layout, new ClientObjectTable(), + () => Array.Empty(), + 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 removes) NewSpies(out System.Action add, out System.Action rem) + { + var adds = new System.Collections.Generic.List<(uint,uint)>(); + var removes = new System.Collections.Generic.List(); + add = (i, g) => adds.Add((i, g)); + rem = i => removes.Add(i); + return (adds, removes); + } + + [Fact] + public void OnDragLift_removesSourceSlot_sendsRemove_emptiesCell() + { + var (layout, slots, _) = FakeToolbar(); + var repo = new ClientObjectTable(); + repo.AddOrUpdate(new ClientObject { ObjectId = 0x5001u, WeenieClassId = 1u, IconId = 0x06001234u }); + var shortcuts = new System.Collections.Generic.List + { new(Index: 3, ObjectGuid: 0x5001u, SpellId: 0, Layer: 0) }; + var (adds, removes) = NewSpies(out var add, out var rem); + + var ctrl = ToolbarController.Bind(layout, repo, () => shortcuts, + iconIds: (_,_,_,_,_) => 0x77u, useItem: _ => { }, + sendAddShortcut: add, sendRemoveShortcut: rem); + Assert.Equal(0x5001u, slots[Row1[3]].Cell.ItemId); + + 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 + { 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 + { 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 + { 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(), + iconIds: (_,_,_,_,_) => 0u, useItem: _ => { }); + Assert.Equal(0x060011FAu, slots[Row1[0]].Cell.DragAcceptSprite); // green cross, not the ring F9 + } } diff --git a/tests/AcDream.App.Tests/UI/UiCollapsibleFrameTests.cs b/tests/AcDream.App.Tests/UI/UiCollapsibleFrameTests.cs new file mode 100644 index 00000000..bc7abf5b --- /dev/null +++ b/tests/AcDream.App.Tests/UI/UiCollapsibleFrameTests.cs @@ -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)); + } +} diff --git a/tests/AcDream.App.Tests/UI/UiRootInputTests.cs b/tests/AcDream.App.Tests/UI/UiRootInputTests.cs index c3160e66..9f326919 100644 --- a/tests/AcDream.App.Tests/UI/UiRootInputTests.cs +++ b/tests/AcDream.App.Tests/UI/UiRootInputTests.cs @@ -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() { diff --git a/tests/AcDream.Core.Net.Tests/Messages/InventoryActionsTests.cs b/tests/AcDream.Core.Net.Tests/Messages/InventoryActionsTests.cs index 5bfec696..298448ae 100644 --- a/tests/AcDream.Core.Net.Tests/Messages/InventoryActionsTests.cs +++ b/tests/AcDream.Core.Net.Tests/Messages/InventoryActionsTests.cs @@ -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] diff --git a/tests/AcDream.Core.Tests/Items/ShortcutStoreTests.cs b/tests/AcDream.Core.Tests/Items/ShortcutStoreTests.cs new file mode 100644 index 00000000..a58dabd9 --- /dev/null +++ b/tests/AcDream.Core.Tests/Items/ShortcutStoreTests.cs @@ -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 + } +}