Merge branch 'claude/hopeful-maxwell-214a12'
# Conflicts: # docs/ISSUES.md
This commit is contained in:
commit
abbd97bc7d
26 changed files with 3489 additions and 62 deletions
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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 |
|
||||
|---|---|---|---|---|---|
|
||||
|
|
|
|||
752
docs/superpowers/plans/2026-06-20-d2b-drag-drop-spine.md
Normal file
752
docs/superpowers/plans/2026-06-20-d2b-drag-drop-spine.md
Normal file
|
|
@ -0,0 +1,752 @@
|
|||
# Drag-drop spine (Stream B.1) Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Complete the widget-level drag-drop machine so an item icon can be picked up, dragged with a cursor-following ghost, hovered slots flip accept/reject, and the drop is dispatched to the owning panel's handler — with a visible toolbar stub proving the chain this session.
|
||||
|
||||
**Architecture:** `UiRoot` already holds the device-level drag state machine. We add (1) a typed `ItemDragPayload` + `IItemListDragHandler`, (2) two `UiElement` virtuals (`GetDragPayload`/`GetDragGhost`) so `UiRoot` stays item-agnostic, (3) drag/drop event handling + an accept/reject overlay on the cell (`UiItemSlot`), which delegates the accept decision + dispatch UP to its parent `UiItemList`'s registered handler, and (4) a logging toolbar stub handler (wire deferred to B.2). Spec: `docs/superpowers/specs/2026-06-20-d2b-drag-drop-spine-design.md`.
|
||||
|
||||
**Tech Stack:** C# / .NET 10, xUnit (`[Fact]`, `Assert.*`), the `src/AcDream.App/UI/` retained-mode toolkit. `InternalsVisibleTo("AcDream.App.Tests")` is set — internal members are test-visible. Tests are pure-logic (no GL); the cursor ghost render is visual-only (no unit test), confirmed by the user at the end.
|
||||
|
||||
---
|
||||
|
||||
## File Structure
|
||||
|
||||
**Create:**
|
||||
- `src/AcDream.App/UI/ItemDragPayload.cs` — `ItemDragSource` enum + `ItemDragPayload` record (the drag snapshot).
|
||||
- `src/AcDream.App/UI/IItemListDragHandler.cs` — the panel-handler interface.
|
||||
- `tests/AcDream.App.Tests/UI/DragDropSpineTests.cs` — toolkit-level conformance.
|
||||
|
||||
**Modify:**
|
||||
- `src/AcDream.App/UI/UiItemList.cs` — `DragHandler` property + `RegisterDragHandler`.
|
||||
- `src/AcDream.App/UI/UiElement.cs` — `GetDragPayload()` + `GetDragGhost()` virtuals.
|
||||
- `src/AcDream.App/UI/UiItemSlot.cs` — `SlotIndex`, `SourceKind`, accept/reject overlay, the two overrides, drag/drop `OnEvent` cases, `FindList`, MouseDown→Click use move.
|
||||
- `src/AcDream.App/UI/UiRoot.cs` — `BeginDrag` payload pull + cancel; cursor ghost in `Draw`.
|
||||
- `src/AcDream.App/UI/Layout/ToolbarController.cs` — implement `IItemListDragHandler`, register on each slot list, set `SlotIndex`/`SourceKind`, stub `HandleDropRelease`.
|
||||
- `tests/AcDream.App.Tests/UI/Layout/ToolbarControllerTests.cs` — fix `Click_emitsUseForBoundItem` (send Click not MouseDown), add registration tests.
|
||||
- `docs/architecture/retail-divergence-register.md` — add **AP-47** (Task 3) + **TS-33** (Task 4).
|
||||
|
||||
**Build/test commands** (run from repo root):
|
||||
- Build: `dotnet build src/AcDream.App/AcDream.App.csproj -c Debug`
|
||||
- Test a class: `dotnet test tests/AcDream.App.Tests/AcDream.App.Tests.csproj --filter "FullyQualifiedName~DragDropSpine"`
|
||||
- Full app tests: `dotnet test tests/AcDream.App.Tests/AcDream.App.Tests.csproj`
|
||||
|
||||
**Every commit ends with the trailer** `Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>` (per CLAUDE.md).
|
||||
|
||||
---
|
||||
|
||||
## Task 1: Payload types + handler interface + UiItemList registration
|
||||
|
||||
**Files:**
|
||||
- Create: `src/AcDream.App/UI/ItemDragPayload.cs`
|
||||
- Create: `src/AcDream.App/UI/IItemListDragHandler.cs`
|
||||
- Modify: `src/AcDream.App/UI/UiItemList.cs`
|
||||
- Test: `tests/AcDream.App.Tests/UI/DragDropSpineTests.cs`
|
||||
|
||||
- [ ] **Step 1: Write the failing test**
|
||||
|
||||
Create `tests/AcDream.App.Tests/UI/DragDropSpineTests.cs`:
|
||||
|
||||
```csharp
|
||||
using AcDream.App.UI;
|
||||
using Xunit;
|
||||
|
||||
namespace AcDream.App.Tests.UI;
|
||||
|
||||
public class DragDropSpineTests
|
||||
{
|
||||
// A spy handler used across the spine tests.
|
||||
private sealed class SpyHandler : IItemListDragHandler
|
||||
{
|
||||
public bool AcceptResult = true;
|
||||
public (UiItemList list, UiItemSlot cell, ItemDragPayload payload)? LastOver;
|
||||
public (UiItemList list, UiItemSlot cell, ItemDragPayload payload)? LastDrop;
|
||||
|
||||
public bool OnDragOver(UiItemList list, UiItemSlot cell, ItemDragPayload p)
|
||||
{ LastOver = (list, cell, p); return AcceptResult; }
|
||||
|
||||
public void HandleDropRelease(UiItemList list, UiItemSlot cell, ItemDragPayload p)
|
||||
{ LastDrop = (list, cell, p); }
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Payload_holdsAllFields()
|
||||
{
|
||||
var src = new UiItemSlot();
|
||||
var p = new ItemDragPayload(0x5001u, ItemDragSource.ShortcutBar, 3, src);
|
||||
Assert.Equal(0x5001u, p.ObjId);
|
||||
Assert.Equal(ItemDragSource.ShortcutBar, p.SourceKind);
|
||||
Assert.Equal(3, p.SourceSlot);
|
||||
Assert.Same(src, p.SourceCell);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void UiItemList_registerDragHandler_roundtrips()
|
||||
{
|
||||
var list = new UiItemList(_ => (0u, 0, 0));
|
||||
Assert.Null(list.DragHandler);
|
||||
var h = new SpyHandler();
|
||||
list.RegisterDragHandler(h);
|
||||
Assert.Same(h, list.DragHandler);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run test to verify it fails**
|
||||
|
||||
Run: `dotnet test tests/AcDream.App.Tests/AcDream.App.Tests.csproj --filter "FullyQualifiedName~DragDropSpine"`
|
||||
Expected: FAIL to compile — `ItemDragPayload`, `ItemDragSource`, `IItemListDragHandler`, `UiItemList.DragHandler`, `RegisterDragHandler` not defined.
|
||||
|
||||
- [ ] **Step 3: Create the payload + enum**
|
||||
|
||||
Create `src/AcDream.App/UI/ItemDragPayload.cs`:
|
||||
|
||||
```csharp
|
||||
namespace AcDream.App.UI;
|
||||
|
||||
/// <summary>
|
||||
/// Where a dragged item came from — the retail <c>InqDropIconInfo</c> flag
|
||||
/// distinction (<c>flags & 0xE == 0</c> fresh-from-inventory vs
|
||||
/// <c>flags & 4</c> within-list reorder) expressed as a typed enum. The drop
|
||||
/// handler maps SourceKind + target back to the fresh-vs-reorder decision.
|
||||
/// Decomp anchors: gmToolbarUI 0x004bd162 / 0x004bd1af; InqDropIconInfo 230533.
|
||||
/// </summary>
|
||||
public enum ItemDragSource { Inventory, ShortcutBar, Equipment, Ground }
|
||||
|
||||
/// <summary>
|
||||
/// Snapshot of a drag-in-progress, taken at drag-begin (so a server move arriving
|
||||
/// mid-drag can't mutate it under us). Port of retail's <c>m_dragElement</c> +
|
||||
/// <c>InqDropIconInfo</c> out-params (objId/container/flags, decomp 230533).
|
||||
/// <para><c>SourceContainer</c> is intentionally NOT stored: the handler resolves the
|
||||
/// LIVE container via <c>ClientObjectTable.Get(ObjId).ContainerId</c> at drop — the
|
||||
/// same container id retail reads off the dragged element, single source of truth.</para>
|
||||
/// </summary>
|
||||
public sealed record ItemDragPayload(
|
||||
uint ObjId, // dragged weenie guid (retail itemID, +0x5FC)
|
||||
ItemDragSource SourceKind, // what kind of slot it left
|
||||
int SourceSlot, // the source cell's SlotIndex (retail m_lastShortcutNumDragged)
|
||||
UiItemSlot SourceCell); // back-ref: reorder-restore / clear source state / ghost
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Create the handler interface**
|
||||
|
||||
Create `src/AcDream.App/UI/IItemListDragHandler.cs`:
|
||||
|
||||
```csharp
|
||||
namespace AcDream.App.UI;
|
||||
|
||||
/// <summary>
|
||||
/// A panel controller implements this and registers itself on each of its
|
||||
/// <see cref="UiItemList"/>s. Port of retail's <c>m_dragHandler</c> vtable
|
||||
/// (<c>RegisterItemListDragHandler</c>, decomp 230461; confirmed acclient
|
||||
/// 0x004a539e + the gmToolbarUI block 0x004bdd89).
|
||||
/// <para><see cref="OnDragOver"/> decides the accept/reject OVERLAY only (advisory).
|
||||
/// <see cref="HandleDropRelease"/> is authoritative — it performs the action, or
|
||||
/// no-ops to reject.</para>
|
||||
/// </summary>
|
||||
public interface IItemListDragHandler
|
||||
{
|
||||
/// <summary>True ⇒ the target cell shows the accept (green) frame; false ⇒ reject (red).</summary>
|
||||
bool OnDragOver(UiItemList targetList, UiItemSlot targetCell, ItemDragPayload payload);
|
||||
|
||||
/// <summary>Perform the drop (issue the per-panel wire action), or no-op to reject.</summary>
|
||||
void HandleDropRelease(UiItemList targetList, UiItemSlot targetCell, ItemDragPayload payload);
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Add registration to UiItemList**
|
||||
|
||||
In `src/AcDream.App/UI/UiItemList.cs`, add inside the class (e.g. just after the `SpriteResolve` property at ~line 25):
|
||||
|
||||
```csharp
|
||||
/// <summary>The drag handler this list routes drops to (a panel controller).
|
||||
/// Null = the list accepts no drops. Retail: UIElement_ItemList::m_dragHandler.</summary>
|
||||
public IItemListDragHandler? DragHandler { get; private set; }
|
||||
|
||||
/// <summary>Register the panel's drag handler on this list. Retail:
|
||||
/// UIElement_ItemList::RegisterItemListDragHandler (decomp 230461).</summary>
|
||||
public void RegisterDragHandler(IItemListDragHandler handler) => DragHandler = handler;
|
||||
```
|
||||
|
||||
- [ ] **Step 6: Run test to verify it passes**
|
||||
|
||||
Run: `dotnet test tests/AcDream.App.Tests/AcDream.App.Tests.csproj --filter "FullyQualifiedName~DragDropSpine"`
|
||||
Expected: PASS (2 tests).
|
||||
|
||||
- [ ] **Step 7: Commit**
|
||||
|
||||
```bash
|
||||
git add src/AcDream.App/UI/ItemDragPayload.cs src/AcDream.App/UI/IItemListDragHandler.cs src/AcDream.App/UI/UiItemList.cs tests/AcDream.App.Tests/UI/DragDropSpineTests.cs
|
||||
git commit -m "feat(ui): D.5.3/B.1 — drag payload + handler interface + UiItemList registration" -m "Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 2: UiElement drag hooks + UiItemSlot drag/drop + overlay
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/AcDream.App/UI/UiElement.cs`
|
||||
- Modify: `src/AcDream.App/UI/UiItemSlot.cs`
|
||||
- Modify: `tests/AcDream.App.Tests/UI/Layout/ToolbarControllerTests.cs` (fix the one MouseDown→Click test)
|
||||
- Test: `tests/AcDream.App.Tests/UI/DragDropSpineTests.cs` (add cases)
|
||||
|
||||
- [ ] **Step 1: Write the failing tests** (append to `DragDropSpineTests.cs`, inside the class)
|
||||
|
||||
```csharp
|
||||
// ── UiItemSlot drag-source payload/ghost ────────────────────────────────
|
||||
[Fact]
|
||||
public void GetDragPayload_emptyCell_isNull()
|
||||
=> Assert.Null(new UiItemSlot().GetDragPayload());
|
||||
|
||||
[Fact]
|
||||
public void GetDragPayload_boundCell_snapshotsFields()
|
||||
{
|
||||
var cell = new UiItemSlot { SlotIndex = 4, SourceKind = ItemDragSource.ShortcutBar };
|
||||
cell.SetItem(0x5001u, 0x99u);
|
||||
var p = Assert.IsType<ItemDragPayload>(cell.GetDragPayload());
|
||||
Assert.Equal(0x5001u, p.ObjId);
|
||||
Assert.Equal(ItemDragSource.ShortcutBar, p.SourceKind);
|
||||
Assert.Equal(4, p.SourceSlot);
|
||||
Assert.Same(cell, p.SourceCell);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetDragGhost_emptyCell_isNull()
|
||||
=> Assert.Null(new UiItemSlot().GetDragGhost());
|
||||
|
||||
[Fact]
|
||||
public void GetDragGhost_boundCell_returnsIconTuple()
|
||||
{
|
||||
var cell = new UiItemSlot { Width = 32, Height = 32 };
|
||||
cell.SetItem(0x5001u, 0x99u);
|
||||
var g = cell.GetDragGhost();
|
||||
Assert.NotNull(g);
|
||||
Assert.Equal(0x99u, g!.Value.tex);
|
||||
Assert.Equal(32, g.Value.w);
|
||||
Assert.Equal(32, g.Value.h);
|
||||
}
|
||||
|
||||
// ── cell drop-target: DragEnter overlay + DropReleased dispatch ──────────
|
||||
private static (UiItemList list, UiItemSlot cell, SpyHandler h) ListWithHandler()
|
||||
{
|
||||
var list = new UiItemList(_ => (1u, 1, 1)); // non-zero resolve so overlay draw is harmless
|
||||
var h = new SpyHandler();
|
||||
list.RegisterDragHandler(h);
|
||||
return (list, list.Cell, h);
|
||||
}
|
||||
|
||||
private static ItemDragPayload SomePayload()
|
||||
=> new(0x5001u, ItemDragSource.ShortcutBar, 0, new UiItemSlot());
|
||||
|
||||
[Fact]
|
||||
public void DragEnter_setsAcceptOverlay_whenHandlerAccepts()
|
||||
{
|
||||
var (_, cell, h) = ListWithHandler();
|
||||
h.AcceptResult = true;
|
||||
cell.OnEvent(new UiEvent(0u, cell, UiEventType.DragEnter, Payload: SomePayload()));
|
||||
Assert.Equal(UiItemSlot.DragAcceptState.Accept, cell.DragAcceptVisual);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DragEnter_setsRejectOverlay_whenHandlerRejects()
|
||||
{
|
||||
var (_, cell, h) = ListWithHandler();
|
||||
h.AcceptResult = false;
|
||||
cell.OnEvent(new UiEvent(0u, cell, UiEventType.DragEnter, Payload: SomePayload()));
|
||||
Assert.Equal(UiItemSlot.DragAcceptState.Reject, cell.DragAcceptVisual);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DragOver_resetsOverlayToNeutral()
|
||||
{
|
||||
var (_, cell, h) = ListWithHandler();
|
||||
cell.OnEvent(new UiEvent(0u, cell, UiEventType.DragEnter, Payload: SomePayload()));
|
||||
cell.OnEvent(new UiEvent(0u, cell, UiEventType.DragOver, Payload: SomePayload()));
|
||||
Assert.Equal(UiItemSlot.DragAcceptState.None, cell.DragAcceptVisual);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DropReleased_accepted_dispatchesToHandler()
|
||||
{
|
||||
var (list, cell, h) = ListWithHandler();
|
||||
var p = SomePayload();
|
||||
cell.OnEvent(new UiEvent(0u, cell, UiEventType.DropReleased, Data0: 1, Payload: p));
|
||||
Assert.NotNull(h.LastDrop);
|
||||
Assert.Same(list, h.LastDrop!.Value.list);
|
||||
Assert.Same(cell, h.LastDrop.Value.cell);
|
||||
Assert.Same(p, h.LastDrop.Value.payload);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DropReleased_notAccepted_skipsDispatch()
|
||||
{
|
||||
var (_, cell, h) = ListWithHandler();
|
||||
cell.OnEvent(new UiEvent(0u, cell, UiEventType.DropReleased, Data0: 0, Payload: SomePayload()));
|
||||
Assert.Null(h.LastDrop);
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run to verify it fails**
|
||||
|
||||
Run: `dotnet test tests/AcDream.App.Tests/AcDream.App.Tests.csproj --filter "FullyQualifiedName~DragDropSpine"`
|
||||
Expected: FAIL to compile — `GetDragPayload`/`GetDragGhost` not on `UiItemSlot`, `SlotIndex`/`SourceKind`/`DragAcceptVisual`/`DragAcceptState` not defined.
|
||||
|
||||
- [ ] **Step 3: Add the two virtuals to UiElement**
|
||||
|
||||
In `src/AcDream.App/UI/UiElement.cs`, add to the "Virtual overrides" region (e.g. after `OnEvent` at ~line 204):
|
||||
|
||||
```csharp
|
||||
/// <summary>The data this element carries when a drag begins. <see cref="UiRoot"/>
|
||||
/// pulls this on drag-promote; a NULL return CANCELS the drag (retail:
|
||||
/// ItemList_BeginDrag only arms an occupied cell). Default null = not draggable.</summary>
|
||||
public virtual object? GetDragPayload() => null;
|
||||
|
||||
/// <summary>The texture <see cref="UiRoot"/> paints at the cursor while this element
|
||||
/// is the drag source: (GL handle, width, height). Null = no ghost. Keeps
|
||||
/// <see cref="UiRoot"/> item-agnostic. Retail analog: m_dragIcon (decomp 229738).</summary>
|
||||
public virtual (uint tex, int w, int h)? GetDragGhost() => null;
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Add drag state + overrides + event handling to UiItemSlot**
|
||||
|
||||
In `src/AcDream.App/UI/UiItemSlot.cs`:
|
||||
|
||||
(a) Add fields/props after `SourceKind` area — insert after the `IconTexture` property (~line 22):
|
||||
|
||||
```csharp
|
||||
/// <summary>This cell's own index within its panel (0..17 toolbar; container slot
|
||||
/// for inventory). Distinct from <see cref="ShortcutNum"/> (the 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.</summary>
|
||||
public int SlotIndex { get; set; } = -1;
|
||||
|
||||
/// <summary>What kind of slot this is, for the drag payload (retail InqDropIconInfo
|
||||
/// flags). Controller overrides; default Inventory.</summary>
|
||||
public ItemDragSource SourceKind { get; set; } = ItemDragSource.Inventory;
|
||||
|
||||
/// <summary>Drag-rollover accept frame (retail ItemSlot_DragOver_Accept 0x060011F9,
|
||||
/// state id 0x10000041). Configurable; guard id != 0 before resolving.</summary>
|
||||
public uint DragAcceptSprite { get; set; } = 0x060011F9u;
|
||||
/// <summary>Drag-rollover reject frame (retail ItemSlot_DragOver_Reject 0x060011F8,
|
||||
/// state id 0x10000040).</summary>
|
||||
public uint DragRejectSprite { get; set; } = 0x060011F8u;
|
||||
|
||||
/// <summary>Accept/reject overlay state while a drag hovers this cell.</summary>
|
||||
public enum DragAcceptState { None, Accept, Reject }
|
||||
private DragAcceptState _dragAccept = DragAcceptState.None;
|
||||
/// <summary>Current overlay state — internal so unit tests can assert it (InternalsVisibleTo).</summary>
|
||||
internal DragAcceptState DragAcceptVisual => _dragAccept;
|
||||
```
|
||||
|
||||
(b) Add the two overrides (anywhere in the class, e.g. after `Clear()`):
|
||||
|
||||
```csharp
|
||||
/// <inheritdoc/>
|
||||
public override object? GetDragPayload()
|
||||
=> ItemId != 0 ? new ItemDragPayload(ItemId, SourceKind, SlotIndex, this) : null;
|
||||
|
||||
/// <inheritdoc/>
|
||||
public override (uint tex, int w, int h)? GetDragGhost()
|
||||
=> ItemId != 0 && IconTexture != 0 ? (IconTexture, (int)Width, (int)Height) : null;
|
||||
|
||||
/// <summary>Walk up to the containing <see cref="UiItemList"/> (the drop handler owner).</summary>
|
||||
private UiItemList? FindList()
|
||||
{
|
||||
UiElement? e = Parent;
|
||||
while (e is not null) { if (e is UiItemList l) return l; e = e.Parent; }
|
||||
return null;
|
||||
}
|
||||
```
|
||||
|
||||
(c) Replace the `OnEvent` method (currently MouseDown→Clicked) with:
|
||||
|
||||
```csharp
|
||||
/// <inheritdoc/>
|
||||
public override bool OnEvent(in UiEvent e)
|
||||
{
|
||||
switch (e.Type)
|
||||
{
|
||||
// Use fires on CLICK (mouse-up over the same cell), not MouseDown — so a
|
||||
// drag (press + >3px move) does NOT also use the item. UiRoot suppresses the
|
||||
// post-drag Click (UiRoot.cs FinishDrag returns before the Click emit).
|
||||
case UiEventType.MouseDown:
|
||||
return true; // consume the press; no use here
|
||||
case UiEventType.Click:
|
||||
Clicked?.Invoke();
|
||||
return true;
|
||||
|
||||
case UiEventType.DragBegin:
|
||||
return true; // we're the source; payload already pulled by UiRoot
|
||||
|
||||
case UiEventType.DragEnter: // pointer entered me mid-drag → ask the list's handler
|
||||
_dragAccept = (FindList() is { DragHandler: { } h } list
|
||||
&& e.Payload is ItemDragPayload p && h.OnDragOver(list, this, p))
|
||||
? DragAcceptState.Accept : DragAcceptState.Reject;
|
||||
return true;
|
||||
|
||||
case UiEventType.DragOver: // UiRoot fires this on LEAVE → neutral
|
||||
_dragAccept = DragAcceptState.None;
|
||||
return true;
|
||||
|
||||
case UiEventType.DropReleased:
|
||||
_dragAccept = DragAcceptState.None;
|
||||
if (e.Data0 == 1 // accepted = dropped on a DIFFERENT element (skips self/empty)
|
||||
&& FindList() is { DragHandler: { } dh } dl
|
||||
&& e.Payload is ItemDragPayload dp)
|
||||
dh.HandleDropRelease(dl, this, dp);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
```
|
||||
|
||||
(d) In `OnDraw`, add the accept/reject overlay AFTER the digit-overlay block (at the very end of `OnDraw`):
|
||||
|
||||
```csharp
|
||||
// Drag-rollover accept/reject frame (retail SetDragAcceptState 0x10000041/40).
|
||||
// Guard id != 0 BEFORE resolving — resolve(0) returns the 1×1 magenta placeholder
|
||||
// with a non-zero GL handle (feedback_ui_resolve_zero_magenta).
|
||||
if (_dragAccept != DragAcceptState.None && SpriteResolve is not null)
|
||||
{
|
||||
uint id = _dragAccept == DragAcceptState.Accept ? DragAcceptSprite : DragRejectSprite;
|
||||
if (id != 0)
|
||||
{
|
||||
var (tex, _, _) = SpriteResolve(id);
|
||||
if (tex != 0)
|
||||
ctx.DrawSprite(tex, 0f, 0f, Width, Height, 0f, 0f, 1f, 1f, Vector4.One);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Fix the existing controller test that sent MouseDown**
|
||||
|
||||
In `tests/AcDream.App.Tests/UI/Layout/ToolbarControllerTests.cs`, in `Click_emitsUseForBoundItem`, change the event from `MouseDown` to `Click` (the test name already says "Click"):
|
||||
|
||||
```csharp
|
||||
// Use now fires on Click (mouse-up), not MouseDown — drag/click disambiguation.
|
||||
slots[Row1[0]].Cell.OnEvent(new UiEvent(0u, null, UiEventType.Click));
|
||||
```
|
||||
|
||||
- [ ] **Step 6: Run to verify it passes**
|
||||
|
||||
Run: `dotnet test tests/AcDream.App.Tests/AcDream.App.Tests.csproj --filter "FullyQualifiedName~DragDropSpine|FullyQualifiedName~ToolbarController|FullyQualifiedName~UiItemSlot"`
|
||||
Expected: PASS (the new DragDropSpine cases, the fixed ToolbarController test, and all UiItemSlot tests).
|
||||
|
||||
- [ ] **Step 7: Commit**
|
||||
|
||||
```bash
|
||||
git add src/AcDream.App/UI/UiElement.cs src/AcDream.App/UI/UiItemSlot.cs tests/AcDream.App.Tests/UI/DragDropSpineTests.cs tests/AcDream.App.Tests/UI/Layout/ToolbarControllerTests.cs
|
||||
git commit -m "feat(ui): D.5.3/B.1 — UiItemSlot drag source + drop target + accept/reject overlay" -m "Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 3: UiRoot payload injection + cursor ghost (+ AP-47)
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/AcDream.App/UI/UiRoot.cs`
|
||||
- Modify: `docs/architecture/retail-divergence-register.md`
|
||||
- Test: `tests/AcDream.App.Tests/UI/DragDropSpineTests.cs` (add full-chain cases)
|
||||
|
||||
- [ ] **Step 1: Write the failing tests** (append to `DragDropSpineTests.cs`, inside the class)
|
||||
|
||||
```csharp
|
||||
// ── Full UiRoot chain: arming + use-vs-drag ─────────────────────────────
|
||||
// A bound, hit-testable slot inside a list, sized for the hit-test.
|
||||
private static (UiRoot root, UiItemList list, UiItemSlot cell) RootWithBoundSlot(uint itemId)
|
||||
{
|
||||
var root = new UiRoot { Width = 800, Height = 600 };
|
||||
var list = new UiItemList(_ => (1u, 1, 1)) { Left = 0, Top = 0, Width = 32, Height = 32 };
|
||||
// Tests don't run OnDraw (which sizes the cell), so size the cell explicitly.
|
||||
list.Cell.Width = 32; list.Cell.Height = 32;
|
||||
if (itemId != 0) list.Cell.SetItem(itemId, 0x99u);
|
||||
root.AddChild(list);
|
||||
return (root, list, list.Cell);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BeginDrag_arms_whenPayloadNonNull()
|
||||
{
|
||||
var (root, _, cell) = RootWithBoundSlot(0x5001u);
|
||||
root.OnMouseDown(UiMouseButton.Left, 10, 10);
|
||||
root.OnMouseMove(20, 10); // >3px → promote to drag
|
||||
Assert.Same(cell, root.DragSource);
|
||||
Assert.IsType<ItemDragPayload>(root.DragPayload);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BeginDrag_doesNotArm_whenPayloadNull_emptySlot()
|
||||
{
|
||||
var (root, _, _) = RootWithBoundSlot(0u); // empty cell → GetDragPayload null
|
||||
root.OnMouseDown(UiMouseButton.Left, 10, 10);
|
||||
root.OnMouseMove(20, 10);
|
||||
Assert.Null(root.DragSource); // never armed
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Click_withoutDrag_firesUse()
|
||||
{
|
||||
var (root, _, cell) = RootWithBoundSlot(0x5001u);
|
||||
bool used = false;
|
||||
cell.Clicked = () => used = true;
|
||||
root.OnMouseDown(UiMouseButton.Left, 10, 10);
|
||||
root.OnMouseUp(UiMouseButton.Left, 10, 10); // no move → Click emitted
|
||||
Assert.True(used);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CompletedDrag_doesNotFireUse()
|
||||
{
|
||||
var (root, _, cell) = RootWithBoundSlot(0x5001u);
|
||||
bool used = false;
|
||||
cell.Clicked = () => used = true;
|
||||
root.OnMouseDown(UiMouseButton.Left, 10, 10);
|
||||
root.OnMouseMove(20, 10); // promote to drag
|
||||
root.OnMouseUp(UiMouseButton.Left, 20, 10); // FinishDrag, NOT Click
|
||||
Assert.False(used);
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run to verify it fails**
|
||||
|
||||
Run: `dotnet test tests/AcDream.App.Tests/AcDream.App.Tests.csproj --filter "FullyQualifiedName~DragDropSpine"`
|
||||
Expected: FAIL — `BeginDrag_doesNotArm_whenPayloadNull_emptySlot` fails (current `BeginDrag` arms unconditionally with `payload: null`, so `DragSource` is non-null), and `CompletedDrag_doesNotFireUse` may behave wrong because a null-payload drag would still arm.
|
||||
|
||||
- [ ] **Step 3: Change BeginDrag to pull the payload + cancel when null**
|
||||
|
||||
In `src/AcDream.App/UI/UiRoot.cs`, replace the `BeginDrag` method (~line 450):
|
||||
|
||||
```csharp
|
||||
private void BeginDrag(UiElement source)
|
||||
{
|
||||
// Pull the payload from the source; a null payload (e.g. an empty item cell)
|
||||
// CANCELS the drag — retail's ItemList_BeginDrag only arms an occupied cell.
|
||||
var payload = source.GetDragPayload();
|
||||
if (payload is null) { _dragCandidate = false; return; }
|
||||
|
||||
DragSource = source;
|
||||
DragPayload = payload;
|
||||
var e = new UiEvent(source.EventId, source, UiEventType.DragBegin, Payload: payload);
|
||||
source.OnEvent(in e);
|
||||
}
|
||||
```
|
||||
|
||||
Update the single call site (~line 188 in `OnMouseMove`) from `BeginDrag(Captured, payload: null);` to:
|
||||
|
||||
```csharp
|
||||
BeginDrag(Captured);
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Add the cursor ghost to Draw**
|
||||
|
||||
In `src/AcDream.App/UI/UiRoot.cs`, replace the `Draw` method (~line 133) so it paints the ghost in the overlay layer, and add the helper + constant:
|
||||
|
||||
```csharp
|
||||
public void Draw(UiRenderContext ctx)
|
||||
{
|
||||
DrawSelfAndChildren(ctx);
|
||||
// Open popups/menus + the drag ghost draw ON TOP of the whole tree (overlay layer).
|
||||
ctx.BeginOverlayLayer();
|
||||
DrawOverlays(ctx);
|
||||
DrawDragGhost(ctx);
|
||||
ctx.EndOverlayLayer();
|
||||
}
|
||||
|
||||
/// <summary>Translucency of the cursor-following drag ghost. AP-47: we reuse the
|
||||
/// item's full composited icon at this alpha rather than retail's dedicated
|
||||
/// underlay-less m_pDragIcon.</summary>
|
||||
private const float GhostAlpha = 0.6f;
|
||||
|
||||
/// <summary>Paint the drag ghost at the cursor. The texture comes from the source
|
||||
/// element's <see cref="UiElement.GetDragGhost"/> so UiRoot stays item-agnostic; the
|
||||
/// ghost is NOT a tree element, so it never intercepts hit-tests.</summary>
|
||||
private void DrawDragGhost(UiRenderContext ctx)
|
||||
{
|
||||
if (DragSource?.GetDragGhost() is not { } g || g.tex == 0) return;
|
||||
ctx.DrawSprite(g.tex, MouseX - g.w / 2f, MouseY - g.h / 2f, g.w, g.h,
|
||||
0f, 0f, 1f, 1f, new Vector4(1f, 1f, 1f, GhostAlpha));
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Run to verify it passes**
|
||||
|
||||
Run: `dotnet test tests/AcDream.App.Tests/AcDream.App.Tests.csproj --filter "FullyQualifiedName~DragDropSpine"`
|
||||
Expected: PASS (all DragDropSpine tests).
|
||||
|
||||
- [ ] **Step 6: Add the AP-47 divergence row**
|
||||
|
||||
In `docs/architecture/retail-divergence-register.md`: bump the AP section header count `## 3. Documented approximation (AP) — 42 rows` → `— 43 rows`, and append this row at the END of the AP table (after the `AP-46` row, before the section's `---`):
|
||||
|
||||
```markdown
|
||||
| AP-47 | Cursor drag ghost reuses the full composited `m_pIcon` at reduced alpha (`GhostAlpha=0.6`) instead of retail's dedicated `m_pDragIcon` (base + custom-overlay, NO type-default underlay) | `src/AcDream.App/UI/UiRoot.cs` (`DrawDragGhost`/`GhostAlpha`) → `src/AcDream.App/UI/UiItemSlot.cs` (`GetDragGhost`) | Cosmetic only — the dragged item is still unambiguously identifiable; building the second underlay-less composite is deferred polish; the ghost is item-agnostic in UiRoot via `GetDragGhost()` | The ghost shows the opaque type-default underlay backing rather than retail's underlay-less translucent copy — a subtle look difference while dragging, no functional effect | `IconData::RenderIcons` acclient_2013_pseudo_c.txt:407594-407625 (m_pDragIcon path); deep-dive §3.2/§5.5 |
|
||||
```
|
||||
|
||||
- [ ] **Step 7: Commit**
|
||||
|
||||
```bash
|
||||
git add src/AcDream.App/UI/UiRoot.cs docs/architecture/retail-divergence-register.md tests/AcDream.App.Tests/UI/DragDropSpineTests.cs
|
||||
git commit -m "feat(ui): D.5.3/B.1 — UiRoot payload injection + cursor drag ghost (AP-47)" -m "Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 4: Toolbar stub handler + wiring (+ TS-33 + registration tests)
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/AcDream.App/UI/Layout/ToolbarController.cs`
|
||||
- Modify: `tests/AcDream.App.Tests/UI/Layout/ToolbarControllerTests.cs`
|
||||
- Modify: `docs/architecture/retail-divergence-register.md`
|
||||
|
||||
- [ ] **Step 1: Write the failing tests** (append to `ToolbarControllerTests.cs`, inside the class)
|
||||
|
||||
```csharp
|
||||
// ── B.1: drag-drop spine wiring ──────────────────────────────────────────
|
||||
|
||||
/// <summary>Bind registers the controller as each slot list's drag handler and
|
||||
/// stamps every cell's SlotIndex + SourceKind=ShortcutBar.</summary>
|
||||
[Fact]
|
||||
public void Bind_registersDragHandler_andStampsSlots()
|
||||
{
|
||||
var (layout, slots, _) = FakeToolbar();
|
||||
var repo = new ClientObjectTable();
|
||||
|
||||
var ctrl = ToolbarController.Bind(layout, repo,
|
||||
() => Array.Empty<PlayerDescriptionParser.ShortcutEntry>(),
|
||||
iconIds: (_,_,_,_,_) => 0u, useItem: _ => { });
|
||||
|
||||
for (int i = 0; i < Row1.Length; i++)
|
||||
{
|
||||
Assert.Same(ctrl, slots[Row1[i]].DragHandler);
|
||||
Assert.Equal(i, slots[Row1[i]].Cell.SlotIndex);
|
||||
Assert.Equal(ItemDragSource.ShortcutBar, slots[Row1[i]].Cell.SourceKind);
|
||||
}
|
||||
// Bottom row slots are indices 9..17.
|
||||
for (int j = 0; j < Row2.Length; j++)
|
||||
Assert.Equal(9 + j, slots[Row2[j]].Cell.SlotIndex);
|
||||
}
|
||||
|
||||
/// <summary>OnDragOver accepts a real item (ObjId != 0). Eligibility (IsShortcutEligible)
|
||||
/// is Stream B.2; the stub accepts any non-empty payload.</summary>
|
||||
[Fact]
|
||||
public void OnDragOver_acceptsRealItem()
|
||||
{
|
||||
var (layout, slots, _) = FakeToolbar();
|
||||
var ctrl = ToolbarController.Bind(layout, new ClientObjectTable(),
|
||||
() => Array.Empty<PlayerDescriptionParser.ShortcutEntry>(),
|
||||
iconIds: (_,_,_,_,_) => 0u, useItem: _ => { });
|
||||
|
||||
var list = slots[Row1[0]];
|
||||
var payload = new ItemDragPayload(0x5001u, ItemDragSource.Inventory, 0, new UiItemSlot());
|
||||
Assert.True(ctrl.OnDragOver(list, list.Cell, payload));
|
||||
}
|
||||
|
||||
/// <summary>HandleDropRelease is a logging stub (TS-33): it must not throw and must not
|
||||
/// mutate the target slot (no wire / no store yet).</summary>
|
||||
[Fact]
|
||||
public void HandleDropRelease_isInertStub()
|
||||
{
|
||||
var (layout, slots, _) = FakeToolbar();
|
||||
var ctrl = ToolbarController.Bind(layout, new ClientObjectTable(),
|
||||
() => Array.Empty<PlayerDescriptionParser.ShortcutEntry>(),
|
||||
iconIds: (_,_,_,_,_) => 0u, useItem: _ => { });
|
||||
|
||||
var list = slots[Row1[2]];
|
||||
var payload = new ItemDragPayload(0x5001u, ItemDragSource.ShortcutBar, 0, new UiItemSlot());
|
||||
var ex = Record.Exception(() => ctrl.HandleDropRelease(list, list.Cell, payload));
|
||||
Assert.Null(ex);
|
||||
Assert.Equal(0u, list.Cell.ItemId); // unchanged — inert stub
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run to verify it fails**
|
||||
|
||||
Run: `dotnet test tests/AcDream.App.Tests/AcDream.App.Tests.csproj --filter "FullyQualifiedName~ToolbarController"`
|
||||
Expected: FAIL to compile — `ToolbarController` doesn't implement `IItemListDragHandler` (`OnDragOver`/`HandleDropRelease`), `DragHandler` not registered.
|
||||
|
||||
- [ ] **Step 3: Make ToolbarController implement the handler + wire registration**
|
||||
|
||||
In `src/AcDream.App/UI/Layout/ToolbarController.cs`:
|
||||
|
||||
(a) Change the class declaration:
|
||||
|
||||
```csharp
|
||||
public sealed class ToolbarController : IItemListDragHandler
|
||||
```
|
||||
|
||||
(b) In the constructor, extend the slot loop (currently `_slots[i] = layout.FindElement(SlotIds[i]) as UiItemList; if (_slots[i] is { } list) WireClick(list);`) to also register the handler + stamp the cell:
|
||||
|
||||
```csharp
|
||||
for (int i = 0; i < SlotIds.Length; i++)
|
||||
{
|
||||
_slots[i] = layout.FindElement(SlotIds[i]) as UiItemList;
|
||||
if (_slots[i] is { } list)
|
||||
{
|
||||
WireClick(list);
|
||||
// B.1 drag-drop spine: this controller is the drop handler for every
|
||||
// toolbar slot list; each cell knows its slot index + that it's a
|
||||
// shortcut-bar source (retail UIElement_ItemList::RegisterItemListDragHandler).
|
||||
list.RegisterDragHandler(this);
|
||||
list.Cell.SlotIndex = i;
|
||||
list.Cell.SourceKind = AcDream.App.UI.ItemDragSource.ShortcutBar;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
(c) Add the interface implementation (e.g. at the end of the class):
|
||||
|
||||
```csharp
|
||||
// ── IItemListDragHandler (B.1 spine) ─────────────────────────────────────
|
||||
// Retail: gmToolbarUI is the m_dragHandler for every shortcut slot list.
|
||||
// The accept/reject gate (IsShortcutEligible) and the AddShortcut/RemoveShortcut
|
||||
// wire are Stream B.2; this stub accepts any real item and LOGS the drop (TS-33).
|
||||
|
||||
/// <inheritdoc/>
|
||||
public bool OnDragOver(UiItemList targetList, UiItemSlot targetCell, ItemDragPayload payload)
|
||||
=> payload.ObjId != 0;
|
||||
|
||||
/// <inheritdoc/>
|
||||
public void HandleDropRelease(UiItemList targetList, UiItemSlot targetCell, ItemDragPayload payload)
|
||||
{
|
||||
// TS-33: no wire yet. Stream B.2 replaces this with the ShortcutStore mutation +
|
||||
// AddShortcut 0x019C / RemoveShortcut 0x019D sends (gmToolbarUI::HandleDropRelease :197971).
|
||||
Console.WriteLine($"[B.2 TODO] drop obj 0x{payload.ObjId:X8} from {payload.SourceKind} " +
|
||||
$"slot {payload.SourceSlot} → toolbar slot {targetCell.SlotIndex}");
|
||||
}
|
||||
```
|
||||
|
||||
(d) Confirm `using AcDream.App.UI;` is present at the top (it is). The `ItemDragSource`/`ItemDragPayload`/`UiItemSlot`/`UiItemList`/`IItemListDragHandler` types are all in `AcDream.App.UI`.
|
||||
|
||||
- [ ] **Step 4: Run to verify it passes**
|
||||
|
||||
Run: `dotnet test tests/AcDream.App.Tests/AcDream.App.Tests.csproj --filter "FullyQualifiedName~ToolbarController"`
|
||||
Expected: PASS (the 3 new B.1 tests + all existing ToolbarController tests, including the fixed Click test).
|
||||
|
||||
- [ ] **Step 5: Add the TS-33 divergence row**
|
||||
|
||||
In `docs/architecture/retail-divergence-register.md`: bump the TS section header count `## 4. Temporary stopgap (TS) — 31 rows` → `— 32 rows`, and append this row at the END of the TS table (after the `TS-32` row, before the section's `---`):
|
||||
|
||||
```markdown
|
||||
| TS-33 | Toolbar `IItemListDragHandler.HandleDropRelease` is a logging stub — no `AddShortcut 0x019C` / `RemoveShortcut 0x019D` wire, no mutable `ShortcutStore`; a drop onto the bar logs and does nothing | `src/AcDream.App/UI/Layout/ToolbarController.cs` (`HandleDropRelease`) | The B.1 spine ships the drag machine (payload/ghost/overlay/dispatch) independent of the wire; the shortcut store + add/remove/reorder sends are Stream B.2, which needs the inventory window as a drag source too | A player dragging onto/within the hotbar sees the ghost + accept overlay but the drop is inert until B.2 — add/remove/reorder don't persist | `gmToolbarUI::HandleDropRelease` acclient_2013_pseudo_c.txt:197971; `AddShortcut`/`RemoveShortcut` 0x019C/0x019D |
|
||||
```
|
||||
|
||||
- [ ] **Step 6: Run the FULL app test suite (no regressions)**
|
||||
|
||||
Run: `dotnet build src/AcDream.App/AcDream.App.csproj -c Debug` then `dotnet test tests/AcDream.App.Tests/AcDream.App.Tests.csproj`
|
||||
Expected: build green; all tests pass.
|
||||
|
||||
- [ ] **Step 7: Commit**
|
||||
|
||||
```bash
|
||||
git add src/AcDream.App/UI/Layout/ToolbarController.cs tests/AcDream.App.Tests/UI/Layout/ToolbarControllerTests.cs docs/architecture/retail-divergence-register.md
|
||||
git commit -m "feat(ui): D.5.3/B.1 — toolbar drag handler stub + spine wiring (TS-33)" -m "Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Final verification
|
||||
|
||||
- [ ] **Full solution build + test:** `dotnet build AcDream.slnx -c Debug` and `dotnet test tests/AcDream.App.Tests/AcDream.App.Tests.csproj` — both green.
|
||||
- [ ] **Self-check the acceptance criteria** in the spec §9 (UiRoot item-agnostic; AP-47 + TS-33 added; counts bumped).
|
||||
- [ ] **Hand back for visual verification** (the user runs the client): with `ACDREAM_RETAIL_UI=1`, in-world — grab a hotbar item and drag: a translucent ghost follows the cursor; the hovered slot shows the accept (green) frame; on release over another slot the log prints `[B.2 TODO] drop obj … → toolbar slot N`; the source item stays put; a plain click still USES the item. (Launch per CLAUDE.md "Running the client" — build green first.)
|
||||
- [ ] **Update memory** if a durable lesson emerged; note in `docs/ISSUES.md` / roadmap only if scope shifted.
|
||||
```
|
||||
699
docs/superpowers/plans/2026-06-20-d2b-toolbar-shortcut-drag.md
Normal file
699
docs/superpowers/plans/2026-06-20-d2b-toolbar-shortcut-drag.md
Normal file
|
|
@ -0,0 +1,699 @@
|
|||
# Toolbar shortcut drag interactivity (Stream B.2) Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Make toolbar shortcut drag functional + retail-faithful — lift empties the source slot, the full-opacity icon follows the cursor with a green-cross indicator, dropping on another slot reorders (bumping the occupant to the source slot), dropping off-bar removes it, and all changes go to ACE via `AddShortcut 0x019C`/`RemoveShortcut 0x019D`.
|
||||
|
||||
**Architecture:** Retail's **remove-on-lift / place-on-drop / no-restore** model. Three slices: (1) Core `ShortcutStore` + the wire builders/senders; (2) small spine extensions (a lift hook, a ghost snapshot, drop-on-hit-only); (3) `ToolbarController` as the live drag handler driving the store + wire. Spec: `docs/superpowers/specs/2026-06-20-d2b-toolbar-shortcut-drag-design.md`.
|
||||
|
||||
**Tech Stack:** C# / .NET 10, xUnit. Core logic in `AcDream.Core`, wire in `AcDream.Core.Net`, UI toolkit in `AcDream.App/UI`. `InternalsVisibleTo("AcDream.App.Tests")` + `("AcDream.Core.Tests")` are set.
|
||||
|
||||
---
|
||||
|
||||
## File Structure
|
||||
|
||||
**Create:**
|
||||
- `src/AcDream.Core/Items/ShortcutStore.cs` — mutable 18-slot shortcut model.
|
||||
- `tests/AcDream.Core.Tests/Items/ShortcutStoreTests.cs`
|
||||
|
||||
**Modify:**
|
||||
- `src/AcDream.Core.Net/Messages/InventoryActions.cs` — `BuildAddShortcut` signature/field fix.
|
||||
- `src/AcDream.Core.Net/WorldSession.cs` — `SendAddShortcut`/`SendRemoveShortcut`.
|
||||
- `tests/AcDream.Core.Net.Tests/Messages/InventoryActionsTests.cs` — update `BuildAddShortcut_ThreeFields`.
|
||||
- `src/AcDream.App/UI/IItemListDragHandler.cs` — add `OnDragLift`.
|
||||
- `src/AcDream.App/UI/UiItemSlot.cs` — `DragBegin`→lift; `DropReleased` ungate.
|
||||
- `src/AcDream.App/UI/UiRoot.cs` — `_dragGhost` snapshot; `GhostAlpha=1`; `FinishDrag` deliver-on-hit-only; test accessor.
|
||||
- `tests/AcDream.App.Tests/UI/DragDropSpineTests.cs` — update B.1 tests + add lift/ghost/off-bar.
|
||||
- `src/AcDream.App/UI/Layout/ToolbarController.cs` — store + handler + green cross + wire actions.
|
||||
- `src/AcDream.App/Rendering/GameWindow.cs` — inject the session sends at the toolbar Bind.
|
||||
- `tests/AcDream.App.Tests/UI/Layout/ToolbarControllerTests.cs` — lift/drop/store tests.
|
||||
- `docs/architecture/retail-divergence-register.md` — reword AP-47, delete TS-33.
|
||||
|
||||
**Commands** (from repo root):
|
||||
- Build all: `dotnet build AcDream.slnx -c Debug`
|
||||
- Core tests: `dotnet test tests/AcDream.Core.Tests/AcDream.Core.Tests.csproj`
|
||||
- Net tests: `dotnet test tests/AcDream.Core.Net.Tests/AcDream.Core.Net.Tests.csproj`
|
||||
- App tests: `dotnet test tests/AcDream.App.Tests/AcDream.App.Tests.csproj`
|
||||
|
||||
**Every commit ends with** `Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>`.
|
||||
|
||||
---
|
||||
|
||||
## Task 1: Core — ShortcutStore + the AddShortcut/RemoveShortcut wire
|
||||
|
||||
**Files:**
|
||||
- Create: `src/AcDream.Core/Items/ShortcutStore.cs`, `tests/AcDream.Core.Tests/Items/ShortcutStoreTests.cs`
|
||||
- Modify: `src/AcDream.Core.Net/Messages/InventoryActions.cs`, `src/AcDream.Core.Net/WorldSession.cs`, `tests/AcDream.Core.Net.Tests/Messages/InventoryActionsTests.cs`
|
||||
|
||||
- [ ] **Step 1: Write the failing ShortcutStore test**
|
||||
|
||||
Create `tests/AcDream.Core.Tests/Items/ShortcutStoreTests.cs`:
|
||||
|
||||
```csharp
|
||||
using System.Collections.Generic;
|
||||
using AcDream.Core.Items;
|
||||
using AcDream.Core.Net.Messages;
|
||||
using Xunit;
|
||||
|
||||
namespace AcDream.Core.Tests.Items;
|
||||
|
||||
public class ShortcutStoreTests
|
||||
{
|
||||
[Fact]
|
||||
public void Load_mapsIndexToObjId_skipsEmptyAndOutOfRange()
|
||||
{
|
||||
var store = new ShortcutStore();
|
||||
var entries = new List<PlayerDescriptionParser.ShortcutEntry>
|
||||
{
|
||||
new(Index: 0, ObjectGuid: 0x5001u, SpellId: 0, Layer: 0),
|
||||
new(Index: 3, ObjectGuid: 0x5002u, SpellId: 0, Layer: 0),
|
||||
new(Index: 5, ObjectGuid: 0u, SpellId: 7, Layer: 1), // spell-only → skipped (item store)
|
||||
new(Index: 99, ObjectGuid: 0x5003u, SpellId: 0, Layer: 0), // out of range → skipped
|
||||
};
|
||||
store.Load(entries);
|
||||
Assert.Equal(0x5001u, store.Get(0));
|
||||
Assert.Equal(0x5002u, store.Get(3));
|
||||
Assert.Equal(0u, store.Get(5));
|
||||
Assert.True(store.IsEmpty(1));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SetRemoveGet_roundtrip_andBoundsSafe()
|
||||
{
|
||||
var store = new ShortcutStore();
|
||||
store.Set(4, 0xABCDu);
|
||||
Assert.Equal(0xABCDu, store.Get(4));
|
||||
Assert.False(store.IsEmpty(4));
|
||||
store.Remove(4);
|
||||
Assert.True(store.IsEmpty(4));
|
||||
// bounds-safe: out-of-range Get/Set/Remove never throw
|
||||
Assert.Equal(0u, store.Get(-1));
|
||||
Assert.Equal(0u, store.Get(18));
|
||||
store.Set(18, 1u); // no-op, no throw
|
||||
store.Remove(-1); // no-op, no throw
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run to verify it fails**
|
||||
|
||||
Run: `dotnet test tests/AcDream.Core.Tests/AcDream.Core.Tests.csproj --filter "FullyQualifiedName~ShortcutStore"`
|
||||
Expected: FAIL to compile — `ShortcutStore` not defined.
|
||||
|
||||
- [ ] **Step 3: Create ShortcutStore**
|
||||
|
||||
Create `src/AcDream.Core/Items/ShortcutStore.cs`:
|
||||
|
||||
```csharp
|
||||
using System.Collections.Generic;
|
||||
using AcDream.Core.Net.Messages;
|
||||
|
||||
namespace AcDream.Core.Items;
|
||||
|
||||
/// <summary>
|
||||
/// Mutable client-side model of the 18 toolbar shortcut slots — port of retail
|
||||
/// <c>ShortCutManager::shortCuts_[18]</c> (acclient.h:36492). Holds the bound object guid per
|
||||
/// slot (0 = empty). Loaded from the login PlayerDescription, then mutated by drag-drop
|
||||
/// (lift removes, drop places); the server is notified via AddShortcut/RemoveShortcut so the
|
||||
/// store stays the client's source of truth within a session (retail: client owns the array).
|
||||
/// Item shortcuts only — spell shortcuts (ObjectGuid 0) are skipped on Load.
|
||||
/// </summary>
|
||||
public sealed class ShortcutStore
|
||||
{
|
||||
public const int SlotCount = 18;
|
||||
private readonly uint[] _objIds = new uint[SlotCount];
|
||||
|
||||
/// <summary>Replace all slots from the login PlayerDescription shortcut list (item entries only).</summary>
|
||||
public void Load(IReadOnlyList<PlayerDescriptionParser.ShortcutEntry> entries)
|
||||
{
|
||||
System.Array.Clear(_objIds);
|
||||
foreach (var e in entries)
|
||||
if (e.Index < SlotCount && e.ObjectGuid != 0) _objIds[(int)e.Index] = e.ObjectGuid;
|
||||
}
|
||||
|
||||
/// <summary>Bound object guid at <paramref name="slot"/>, or 0 (empty / out of range).</summary>
|
||||
public uint Get(int slot) => (uint)slot < SlotCount ? _objIds[slot] : 0u;
|
||||
public bool IsEmpty(int slot) => Get(slot) == 0u;
|
||||
public void Set(int slot, uint objId) { if ((uint)slot < SlotCount) _objIds[slot] = objId; }
|
||||
public void Remove(int slot) { if ((uint)slot < SlotCount) _objIds[slot] = 0u; }
|
||||
}
|
||||
```
|
||||
|
||||
(`PlayerDescriptionParser.ShortcutEntry` has `uint Index, uint ObjectGuid, ushort SpellId, ushort Layer`. Verify the field types when you open the file; if `Index` is `uint`, the `e.Index < SlotCount` compare and the `(int)e.Index` cast as written are correct.)
|
||||
|
||||
- [ ] **Step 4: Run to verify it passes**
|
||||
|
||||
Run: `dotnet test tests/AcDream.Core.Tests/AcDream.Core.Tests.csproj --filter "FullyQualifiedName~ShortcutStore"`
|
||||
Expected: PASS (2 tests).
|
||||
|
||||
- [ ] **Step 5: Write the failing wire test (new BuildAddShortcut signature)**
|
||||
|
||||
Replace the existing `BuildAddShortcut_ThreeFields` test in `tests/AcDream.Core.Net.Tests/Messages/InventoryActionsTests.cs` with:
|
||||
|
||||
```csharp
|
||||
[Fact]
|
||||
public void BuildAddShortcut_ItemShortcut_FieldLayout()
|
||||
{
|
||||
// ShortCutData = Index(u32), ObjectId(u32), SpellId(u16), Layer(u16). Item → spell/layer 0.
|
||||
byte[] body = InventoryActions.BuildAddShortcut(seq: 1, index: 0, objectGuid: 0x3E1, spellId: 0, layer: 0);
|
||||
Assert.Equal(24, body.Length);
|
||||
Assert.Equal(InventoryActions.AddShortcutOpcode,
|
||||
System.Buffers.Binary.BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(8)));
|
||||
Assert.Equal(0u, System.Buffers.Binary.BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(12))); // index
|
||||
Assert.Equal(0x3E1u, System.Buffers.Binary.BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(16))); // objectGuid
|
||||
Assert.Equal((ushort)0, System.Buffers.Binary.BinaryPrimitives.ReadUInt16LittleEndian(body.AsSpan(20))); // spellId
|
||||
Assert.Equal((ushort)0, System.Buffers.Binary.BinaryPrimitives.ReadUInt16LittleEndian(body.AsSpan(22))); // layer
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildAddShortcut_SpellShortcut_PacksSpellAndLayerAsU16s()
|
||||
{
|
||||
byte[] body = InventoryActions.BuildAddShortcut(seq: 1, index: 2, objectGuid: 0, spellId: 0x1234, layer: 3);
|
||||
Assert.Equal(0x1234, System.Buffers.Binary.BinaryPrimitives.ReadUInt16LittleEndian(body.AsSpan(20)));
|
||||
Assert.Equal(3, System.Buffers.Binary.BinaryPrimitives.ReadUInt16LittleEndian(body.AsSpan(22)));
|
||||
}
|
||||
```
|
||||
|
||||
(The file already has `using System.Buffers.Binary;` per the other tests — if so, use the short `BinaryPrimitives.` form to match; the fully-qualified form above is safe regardless.)
|
||||
|
||||
- [ ] **Step 6: Run to verify it fails**
|
||||
|
||||
Run: `dotnet test tests/AcDream.Core.Net.Tests/AcDream.Core.Net.Tests.csproj --filter "FullyQualifiedName~BuildAddShortcut"`
|
||||
Expected: FAIL to compile — `BuildAddShortcut` has no `(seq, index, objectGuid, spellId, layer)` overload (old signature is `(seq, slotIndex, objectType, targetId)`).
|
||||
|
||||
- [ ] **Step 7: Fix BuildAddShortcut signature + field packing**
|
||||
|
||||
In `src/AcDream.Core.Net/Messages/InventoryActions.cs`, replace the `BuildAddShortcut` method (the `(uint seq, uint slotIndex, uint objectType, uint targetId)` one) with:
|
||||
|
||||
```csharp
|
||||
/// <summary>Pin an item/spell to a quickbar slot. ShortCutData = Index(u32), ObjectId(u32),
|
||||
/// SpellId(u16), Layer(u16) — CONFIRMED across ACE/Chorizite/holtburger (action-bar deep-dive
|
||||
/// §131-145). For an ITEM: objectGuid = item guid, spellId = layer = 0.</summary>
|
||||
public static byte[] BuildAddShortcut(
|
||||
uint seq, uint index, uint objectGuid, ushort spellId, ushort layer)
|
||||
{
|
||||
byte[] body = new byte[24];
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(body, GameActionEnvelope);
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(4), seq);
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(8), AddShortcutOpcode);
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(12), index);
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(16), objectGuid);
|
||||
BinaryPrimitives.WriteUInt16LittleEndian(body.AsSpan(20), spellId);
|
||||
BinaryPrimitives.WriteUInt16LittleEndian(body.AsSpan(22), layer);
|
||||
return body;
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 8: Add the WorldSession send wrappers**
|
||||
|
||||
In `src/AcDream.Core.Net/WorldSession.cs`, after `SendChangeCombatMode` (~line 1141), add:
|
||||
|
||||
```csharp
|
||||
/// <summary>Send AddShortcut (0x019C) — pin an item to toolbar slot <paramref name="index"/>.
|
||||
/// Retail: CM_Character::Event_AddShortCut. Mirrors the SendChangeCombatMode pattern.</summary>
|
||||
public void SendAddShortcut(uint index, uint objectGuid, ushort spellId = 0, ushort layer = 0)
|
||||
{
|
||||
uint seq = NextGameActionSequence();
|
||||
SendGameAction(InventoryActions.BuildAddShortcut(seq, index, objectGuid, spellId, layer));
|
||||
}
|
||||
|
||||
/// <summary>Send RemoveShortcut (0x019D) — clear toolbar slot <paramref name="index"/>.
|
||||
/// Retail: CM_Character::Event_RemoveShortCut.</summary>
|
||||
public void SendRemoveShortcut(uint index)
|
||||
{
|
||||
uint seq = NextGameActionSequence();
|
||||
SendGameAction(InventoryActions.BuildRemoveShortcut(seq, index));
|
||||
}
|
||||
```
|
||||
|
||||
(Confirm `InventoryActions` is in scope — `WorldSession` already calls other `*Actions.Build*` builders; add a `using AcDream.Core.Net.Messages;` only if not already present.)
|
||||
|
||||
- [ ] **Step 9: Run to verify all Core/Net tests pass**
|
||||
|
||||
Run: `dotnet test tests/AcDream.Core.Net.Tests/AcDream.Core.Net.Tests.csproj --filter "FullyQualifiedName~InventoryActions"` then `dotnet build src/AcDream.Core.Net/AcDream.Core.Net.csproj -c Debug`
|
||||
Expected: PASS; build green (no stale callers of the old 4-arg `BuildAddShortcut` — there were none besides the test).
|
||||
|
||||
- [ ] **Step 10: Commit**
|
||||
|
||||
```bash
|
||||
git add src/AcDream.Core/Items/ShortcutStore.cs tests/AcDream.Core.Tests/Items/ShortcutStoreTests.cs src/AcDream.Core.Net/Messages/InventoryActions.cs src/AcDream.Core.Net/WorldSession.cs tests/AcDream.Core.Net.Tests/Messages/InventoryActionsTests.cs
|
||||
git commit -m "feat(core): D.5.3/B.2 — ShortcutStore + AddShortcut/RemoveShortcut wire + senders" -m "Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 2: Spine extensions — lift hook + ghost snapshot + drop-on-hit-only
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/AcDream.App/UI/IItemListDragHandler.cs`, `src/AcDream.App/UI/UiItemSlot.cs`, `src/AcDream.App/UI/UiRoot.cs`
|
||||
- Test: `tests/AcDream.App.Tests/UI/DragDropSpineTests.cs`
|
||||
|
||||
- [ ] **Step 1: Write/adjust the failing tests** in `tests/AcDream.App.Tests/UI/DragDropSpineTests.cs`
|
||||
|
||||
First, the `SpyHandler` (nested in `DragDropSpineTests`) needs to record `OnDragLift`. UPDATE the `SpyHandler` class to add:
|
||||
|
||||
```csharp
|
||||
public (UiItemList list, UiItemSlot cell, ItemDragPayload payload)? LastLift;
|
||||
public void OnDragLift(UiItemList list, UiItemSlot cell, ItemDragPayload p)
|
||||
{ LastLift = (list, cell, p); }
|
||||
```
|
||||
|
||||
Then REPLACE the existing `DropReleased_notAccepted_skipsDispatch` test (its premise — Data0==0 skips — is gone under the retail model) with:
|
||||
|
||||
```csharp
|
||||
[Fact]
|
||||
public void DropReleased_dispatchesToHandler_regardlessOfData0()
|
||||
{
|
||||
// Retail model: reaching the cell means a real slot was hit (FinishDrag only delivers on a
|
||||
// hit), so the handler is authoritative — it dispatches whether or not Data0 is set.
|
||||
var (list, cell, h) = ListWithHandler();
|
||||
cell.OnEvent(new UiEvent(0u, cell, UiEventType.DropReleased, Data0: 0, Payload: SomePayload()));
|
||||
Assert.NotNull(h.LastDrop);
|
||||
}
|
||||
```
|
||||
|
||||
Then ADD these new tests inside the class:
|
||||
|
||||
```csharp
|
||||
[Fact]
|
||||
public void DragBegin_callsHandlerOnDragLift()
|
||||
{
|
||||
var (list, cell, h) = ListWithHandler();
|
||||
var p = SomePayload();
|
||||
cell.OnEvent(new UiEvent(0u, cell, UiEventType.DragBegin, Payload: p));
|
||||
Assert.NotNull(h.LastLift);
|
||||
Assert.Same(list, h.LastLift!.Value.list);
|
||||
Assert.Same(cell, h.LastLift.Value.cell);
|
||||
Assert.Same(p, h.LastLift.Value.payload);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Ghost_isSnapshottedAtBeginDrag_survivesSourceCellClearing()
|
||||
{
|
||||
// The lift empties the source cell; the ghost must persist (snapshot at BeginDrag), not
|
||||
// re-read the now-empty cell. Drive the full UiRoot chain, then clear the source cell.
|
||||
var (root, _, cell) = RootWithBoundSlot(0x5001u); // icon tex 0x99
|
||||
root.OnMouseDown(UiMouseButton.Left, 10, 10);
|
||||
root.OnMouseMove(20, 10); // BeginDrag → snapshot ghost
|
||||
cell.Clear(); // simulate the lift emptying the source
|
||||
Assert.Equal((0x99u, 32, 32), root.DragGhostForTest);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FinishDrag_overNothing_deliversNoDrop_butLiftStands()
|
||||
{
|
||||
// Drag from a slot with a handler, release over empty space → no HandleDropRelease (off-bar),
|
||||
// but OnDragLift already fired at begin (the lift). Requires the slot inside a list w/ handler.
|
||||
var root = new UiRoot { Width = 800, Height = 600 };
|
||||
var list = new UiItemList(_ => (1u, 1, 1)) { Left = 0, Top = 0, Width = 32, Height = 32 };
|
||||
list.Cell.Width = 32; list.Cell.Height = 32;
|
||||
list.Cell.SetItem(0x5001u, 0x99u);
|
||||
var h = new SpyHandler();
|
||||
list.RegisterDragHandler(h);
|
||||
root.AddChild(list);
|
||||
|
||||
root.OnMouseDown(UiMouseButton.Left, 10, 10);
|
||||
root.OnMouseMove(20, 10); // BeginDrag → OnDragLift
|
||||
root.OnMouseUp(UiMouseButton.Left, 600, 500); // release over empty space
|
||||
Assert.NotNull(h.LastLift); // lift happened
|
||||
Assert.Null(h.LastDrop); // no drop dispatched (off-bar)
|
||||
Assert.Null(root.DragSource); // cleaned up
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run to verify it fails**
|
||||
|
||||
Run: `dotnet test tests/AcDream.App.Tests/AcDream.App.Tests.csproj --filter "FullyQualifiedName~DragDropSpine"`
|
||||
Expected: FAIL to compile — `IItemListDragHandler.OnDragLift` not defined (SpyHandler doesn't satisfy the interface), `root.DragGhostForTest` not defined.
|
||||
|
||||
- [ ] **Step 3: Add OnDragLift to the interface**
|
||||
|
||||
In `src/AcDream.App/UI/IItemListDragHandler.cs`, add as the FIRST member of the interface:
|
||||
|
||||
```csharp
|
||||
/// <summary>The drag STARTED from a cell in this list — retail's RecvNotice_ItemListBeginDrag
|
||||
/// → RemoveShortcut (decomp 0x004bd930/0x004bd450): the handler removes the lifted item from its
|
||||
/// model + wire so the source slot empties immediately. The item is "in hand" until
|
||||
/// HandleDropRelease (place) or the drag ends off-target (stays removed). No restore on cancel.</summary>
|
||||
void OnDragLift(UiItemList sourceList, UiItemSlot sourceCell, ItemDragPayload payload);
|
||||
```
|
||||
|
||||
- [ ] **Step 4: UiItemSlot — DragBegin fires the lift; DropReleased ungated**
|
||||
|
||||
In `src/AcDream.App/UI/UiItemSlot.cs` `OnEvent`, change the `DragBegin` case from `return true;` to:
|
||||
|
||||
```csharp
|
||||
case UiEventType.DragBegin:
|
||||
// Notify the source list's handler so it can lift (remove + wire) — retail
|
||||
// RecvNotice_ItemListBeginDrag → RemoveShortcut. UiRoot snapshotted the ghost first.
|
||||
if (FindList() is { DragHandler: { } lh } liftList && e.Payload is ItemDragPayload lp)
|
||||
lh.OnDragLift(liftList, this, lp);
|
||||
return true;
|
||||
```
|
||||
|
||||
And change the `DropReleased` case — drop the `e.Data0 == 1` gate (reaching the cell = a real slot was hit; the handler is authoritative):
|
||||
|
||||
```csharp
|
||||
case UiEventType.DropReleased:
|
||||
_dragAccept = DragAcceptState.None;
|
||||
if (FindList() is { DragHandler: { } dh } dl && e.Payload is ItemDragPayload dp)
|
||||
dh.HandleDropRelease(dl, this, dp);
|
||||
return true;
|
||||
```
|
||||
|
||||
- [ ] **Step 5: UiRoot — snapshot the ghost, full opacity, deliver-on-hit-only**
|
||||
|
||||
In `src/AcDream.App/UI/UiRoot.cs`:
|
||||
|
||||
(a) Add the snapshot field near `DragPayload` (~line 73): `private (uint tex, int w, int h)? _dragGhost;` and a test accessor near it: `internal (uint tex, int w, int h)? DragGhostForTest => _dragGhost;`
|
||||
|
||||
(b) In `BeginDrag`, snapshot the ghost BEFORE firing `DragBegin`:
|
||||
|
||||
```csharp
|
||||
private void BeginDrag(UiElement source)
|
||||
{
|
||||
var payload = source.GetDragPayload();
|
||||
if (payload is null) { _dragCandidate = false; return; }
|
||||
DragSource = source;
|
||||
DragPayload = payload;
|
||||
_dragGhost = source.GetDragGhost(); // snapshot NOW — the DragBegin handler may empty the source cell
|
||||
var e = new UiEvent(source.EventId, source, UiEventType.DragBegin, Payload: payload);
|
||||
source.OnEvent(in e);
|
||||
}
|
||||
```
|
||||
|
||||
(c) Change `GhostAlpha` to `1.0f` and make `DrawDragGhost` use the snapshot:
|
||||
|
||||
```csharp
|
||||
private const float GhostAlpha = 1.0f; // retail m_dragIcon is the full icon, no fade
|
||||
|
||||
private void DrawDragGhost(UiRenderContext ctx)
|
||||
{
|
||||
if (_dragGhost is not { } g || g.tex == 0) return;
|
||||
ctx.DrawSprite(g.tex, MouseX - g.w / 2f, MouseY - g.h / 2f, g.w, g.h,
|
||||
0f, 0f, 1f, 1f, new Vector4(1f, 1f, 1f, GhostAlpha));
|
||||
}
|
||||
```
|
||||
|
||||
(d) Replace `FinishDrag` to deliver only on a real hit + clear the ghost:
|
||||
|
||||
```csharp
|
||||
private void FinishDrag(int x, int y)
|
||||
{
|
||||
var (t, lx, ly) = HitTestTopDown(x, y);
|
||||
if (t is not null)
|
||||
{
|
||||
// Dropped on a real element — deliver DropReleased; the hit cell's handler places.
|
||||
// A non-item target's OnEvent ignores it, so an off-bar drop leaves the lift's removal.
|
||||
var e = new UiEvent(DragSource!.EventId, t, UiEventType.DropReleased,
|
||||
Data1: (int)lx, Data2: (int)ly, Payload: DragPayload);
|
||||
t.OnEvent(in e);
|
||||
}
|
||||
// else: dropped on nothing — no drop fires; the lift (drag-begin removal) stands (retail).
|
||||
DragSource = null;
|
||||
DragPayload = null;
|
||||
_dragGhost = null;
|
||||
_lastDragHoverTarget = null;
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 6: Run to verify it passes**
|
||||
|
||||
Run: `dotnet test tests/AcDream.App.Tests/AcDream.App.Tests.csproj --filter "FullyQualifiedName~DragDropSpine"`
|
||||
Expected: PASS (the updated + new spine tests; the existing arming, use-vs-drag, overlay, and window-topology tests still pass — `CompletedDrag_doesNotFireUse` still holds because a completed drag takes the `FinishDrag` path before any Click).
|
||||
|
||||
- [ ] **Step 7: Run the full app suite (no regressions)**
|
||||
|
||||
Run: `dotnet test tests/AcDream.App.Tests/AcDream.App.Tests.csproj`
|
||||
Expected: all pass (the ToolbarController tests still pass — `ToolbarController` already implements the interface from B.1; you'll add the `OnDragLift` body in Task 3, but a default-throwing stub isn't present, so confirm: if the build fails because `ToolbarController` doesn't implement the new `OnDragLift` member, add a temporary `public void OnDragLift(...) {}` no-op in `ToolbarController` now and flesh it out in Task 3. Prefer to just do Task 3's `OnDragLift` body — but to keep Task 2 self-contained + green, a one-line empty `OnDragLift` is acceptable here and gets its real body in Task 3.)
|
||||
|
||||
- [ ] **Step 8: Commit**
|
||||
|
||||
```bash
|
||||
git add src/AcDream.App/UI/IItemListDragHandler.cs src/AcDream.App/UI/UiItemSlot.cs src/AcDream.App/UI/UiRoot.cs tests/AcDream.App.Tests/UI/DragDropSpineTests.cs src/AcDream.App/UI/Layout/ToolbarController.cs
|
||||
git commit -m "feat(ui): D.5.3/B.2 — spine: drag-lift hook + ghost snapshot (full opacity) + drop-on-hit-only" -m "Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 3: ToolbarController — live handler (store + reorder/remove + wire + green cross)
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/AcDream.App/UI/Layout/ToolbarController.cs`, `src/AcDream.App/Rendering/GameWindow.cs`, `docs/architecture/retail-divergence-register.md`
|
||||
- Test: `tests/AcDream.App.Tests/UI/Layout/ToolbarControllerTests.cs`
|
||||
|
||||
- [ ] **Step 1: Write the failing tests** — append inside `ToolbarControllerTests` class
|
||||
|
||||
```csharp
|
||||
// ── B.2: live drag handler (store + reorder/remove + wire) ───────────────
|
||||
private static (System.Collections.Generic.List<(uint i,uint g)> adds,
|
||||
System.Collections.Generic.List<uint> removes) NewSpies(out System.Action<uint,uint> add, out System.Action<uint> rem)
|
||||
{
|
||||
var adds = new System.Collections.Generic.List<(uint,uint)>();
|
||||
var removes = new System.Collections.Generic.List<uint>();
|
||||
add = (i, g) => adds.Add((i, g));
|
||||
rem = i => removes.Add(i);
|
||||
return (adds, removes);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void OnDragLift_removesSourceSlot_sendsRemove_emptiesCell()
|
||||
{
|
||||
var (layout, slots, _) = FakeToolbar();
|
||||
var repo = new ClientObjectTable();
|
||||
repo.AddOrUpdate(new ClientObject { ObjectId = 0x5001u, WeenieClassId = 1u, IconId = 0x06001234u });
|
||||
var shortcuts = new System.Collections.Generic.List<PlayerDescriptionParser.ShortcutEntry>
|
||||
{ new(Index: 3, ObjectGuid: 0x5001u, SpellId: 0, Layer: 0) };
|
||||
var (adds, removes) = NewSpies(out var add, out var rem);
|
||||
|
||||
var ctrl = ToolbarController.Bind(layout, repo, () => shortcuts,
|
||||
iconIds: (_,_,_,_,_) => 0x77u, useItem: _ => { },
|
||||
sendAddShortcut: add, sendRemoveShortcut: rem);
|
||||
Assert.Equal(0x5001u, slots[Row1[3]].Cell.ItemId); // bound at slot 3
|
||||
|
||||
var payload = new ItemDragPayload(0x5001u, ItemDragSource.ShortcutBar, 3, slots[Row1[3]].Cell);
|
||||
ctrl.OnDragLift(slots[Row1[3]], slots[Row1[3]].Cell, payload);
|
||||
|
||||
Assert.Contains(3u, removes); // RemoveShortcut(3) sent
|
||||
Assert.Equal(0u, slots[Row1[3]].Cell.ItemId); // source slot now empty
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void HandleDropRelease_ontoOccupied_swaps_andSendsRetailSequence()
|
||||
{
|
||||
var (layout, slots, _) = FakeToolbar();
|
||||
var repo = new ClientObjectTable();
|
||||
repo.AddOrUpdate(new ClientObject { ObjectId = 0x5001u, WeenieClassId = 1u, IconId = 0x06001234u }); // A
|
||||
repo.AddOrUpdate(new ClientObject { ObjectId = 0x5002u, WeenieClassId = 1u, IconId = 0x06005678u }); // B
|
||||
var shortcuts = new System.Collections.Generic.List<PlayerDescriptionParser.ShortcutEntry>
|
||||
{ new(Index: 3, ObjectGuid: 0x5001u, SpellId: 0, Layer: 0),
|
||||
new(Index: 5, ObjectGuid: 0x5002u, SpellId: 0, Layer: 0) };
|
||||
var (adds, removes) = NewSpies(out var add, out var rem);
|
||||
var ctrl = ToolbarController.Bind(layout, repo, () => shortcuts,
|
||||
iconIds: (_,_,_,_,_) => 0x77u, useItem: _ => { },
|
||||
sendAddShortcut: add, sendRemoveShortcut: rem);
|
||||
|
||||
// Lift A from slot 3, then drop on occupied slot 5 (holds B).
|
||||
var payload = new ItemDragPayload(0x5001u, ItemDragSource.ShortcutBar, 3, slots[Row1[3]].Cell);
|
||||
ctrl.OnDragLift(slots[Row1[3]], slots[Row1[3]].Cell, payload);
|
||||
ctrl.HandleDropRelease(slots[Row1[5]], slots[Row1[5]].Cell, payload);
|
||||
|
||||
// End state: A at slot 5, B bumped to the vacated source slot 3.
|
||||
Assert.Equal(0x5001u, slots[Row1[5]].Cell.ItemId);
|
||||
Assert.Equal(0x5002u, slots[Row1[3]].Cell.ItemId);
|
||||
// Wire (retail sequence): RemoveShortcut(3)[lift], RemoveShortcut(5)[evict B], AddShortcut(5,A), AddShortcut(3,B)
|
||||
Assert.Equal(new[] { 3u, 5u }, removes.ToArray());
|
||||
Assert.Contains((5u, 0x5001u), adds);
|
||||
Assert.Contains((3u, 0x5002u), adds);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void HandleDropRelease_ontoEmpty_placesOnly_sourceStaysEmpty()
|
||||
{
|
||||
var (layout, slots, _) = FakeToolbar();
|
||||
var repo = new ClientObjectTable();
|
||||
repo.AddOrUpdate(new ClientObject { ObjectId = 0x5001u, WeenieClassId = 1u, IconId = 0x06001234u });
|
||||
var shortcuts = new System.Collections.Generic.List<PlayerDescriptionParser.ShortcutEntry>
|
||||
{ new(Index: 3, ObjectGuid: 0x5001u, SpellId: 0, Layer: 0) };
|
||||
var (adds, removes) = NewSpies(out var add, out var rem);
|
||||
var ctrl = ToolbarController.Bind(layout, repo, () => shortcuts,
|
||||
iconIds: (_,_,_,_,_) => 0x77u, useItem: _ => { },
|
||||
sendAddShortcut: add, sendRemoveShortcut: rem);
|
||||
|
||||
var payload = new ItemDragPayload(0x5001u, ItemDragSource.ShortcutBar, 3, slots[Row1[3]].Cell);
|
||||
ctrl.OnDragLift(slots[Row1[3]], slots[Row1[3]].Cell, payload);
|
||||
ctrl.HandleDropRelease(slots[Row1[7]], slots[Row1[7]].Cell, payload); // slot 7 empty
|
||||
|
||||
Assert.Equal(0x5001u, slots[Row1[7]].Cell.ItemId); // A placed at 7
|
||||
Assert.Equal(0u, slots[Row1[3]].Cell.ItemId); // source 3 stays empty
|
||||
Assert.Contains((7u, 0x5001u), adds);
|
||||
Assert.DoesNotContain(adds, a => a.i == 3u); // no bump (target was empty)
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ToolbarSlots_useGreenCrossAcceptSprite()
|
||||
{
|
||||
var (layout, slots, _) = FakeToolbar();
|
||||
ToolbarController.Bind(layout, new ClientObjectTable(),
|
||||
() => System.Array.Empty<PlayerDescriptionParser.ShortcutEntry>(),
|
||||
iconIds: (_,_,_,_,_) => 0u, useItem: _ => { });
|
||||
Assert.Equal(0x060011FAu, slots[Row1[0]].Cell.DragAcceptSprite); // green cross, not the ring F9
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run to verify it fails**
|
||||
|
||||
Run: `dotnet test tests/AcDream.App.Tests/AcDream.App.Tests.csproj --filter "FullyQualifiedName~ToolbarController"`
|
||||
Expected: FAIL to compile — `ToolbarController.Bind` has no `sendAddShortcut`/`sendRemoveShortcut` params; `OnDragLift` is a no-op stub (from Task 2); `DragAcceptSprite` not set to FA.
|
||||
|
||||
- [ ] **Step 3: Add the store + wire fields + Bind params**
|
||||
|
||||
In `src/AcDream.App/UI/Layout/ToolbarController.cs`:
|
||||
|
||||
(a) Add fields near the other private fields:
|
||||
|
||||
```csharp
|
||||
private readonly AcDream.Core.Items.ShortcutStore _store = new();
|
||||
private bool _storeLoaded;
|
||||
private readonly Action<uint, uint>? _sendAddShortcut; // (index, objectGuid)
|
||||
private readonly Action<uint>? _sendRemoveShortcut; // (index)
|
||||
```
|
||||
|
||||
(b) Add the two params to the private ctor signature and the `Bind` factory (both, at the end, optional), and assign them in the ctor:
|
||||
|
||||
```csharp
|
||||
// ctor params (add after emptyDigits):
|
||||
Action<uint, uint>? sendAddShortcut = null,
|
||||
Action<uint>? sendRemoveShortcut = null,
|
||||
// ctor body (with the other assignments):
|
||||
_sendAddShortcut = sendAddShortcut;
|
||||
_sendRemoveShortcut = sendRemoveShortcut;
|
||||
```
|
||||
|
||||
(Mirror the same two params on the `public static ToolbarController Bind(...)` signature and forward them into the `new ToolbarController(...)` call.)
|
||||
|
||||
(c) In the ctor's slot loop (where B.1 already does `RegisterDragHandler(this)` + `SlotIndex` + `SourceKind`), add the green-cross sprite:
|
||||
|
||||
```csharp
|
||||
list.Cell.DragAcceptSprite = 0x060011FAu; // green cross (toolbar), not the ring 0x060011F9 (inventory)
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Switch Populate + IsShortcutGuid to the store; add the handler bodies**
|
||||
|
||||
(a) Replace `Populate()` with the store-driven version (lazy-load once):
|
||||
|
||||
```csharp
|
||||
public void Populate()
|
||||
{
|
||||
// Lazy-load the store from the login shortcut list the first time it's present (PD arrives
|
||||
// after Bind); thereafter the store is authoritative and drag ops mutate it directly.
|
||||
if (!_storeLoaded && _shortcuts().Count > 0) { _store.Load(_shortcuts()); _storeLoaded = true; }
|
||||
|
||||
foreach (var list in _slots) list?.Cell.Clear();
|
||||
|
||||
for (int slot = 0; slot < _slots.Length; slot++)
|
||||
{
|
||||
uint guid = _store.Get(slot);
|
||||
if (guid == 0) continue;
|
||||
var list = _slots[slot];
|
||||
if (list is null) continue;
|
||||
var item = _repo.Get(guid);
|
||||
if (item is null) continue; // deferred: ObjectAdded re-calls Populate
|
||||
uint tex = _iconIds(item.Type, item.IconId, item.IconUnderlayId, item.IconOverlayId, item.Effects);
|
||||
list.Cell.SetItem(guid, tex);
|
||||
}
|
||||
RestampShortcutNumbers();
|
||||
}
|
||||
```
|
||||
|
||||
(b) Replace `IsShortcutGuid` to read the store:
|
||||
|
||||
```csharp
|
||||
private bool IsShortcutGuid(uint guid)
|
||||
{
|
||||
for (int s = 0; s < AcDream.Core.Items.ShortcutStore.SlotCount; s++)
|
||||
if (_store.Get(s) == guid) return true;
|
||||
return false;
|
||||
}
|
||||
```
|
||||
|
||||
(c) Replace the Task-2 stub `OnDragLift` (and the B.1 `HandleDropRelease` logging stub) with the real bodies:
|
||||
|
||||
```csharp
|
||||
/// <inheritdoc/>
|
||||
public void OnDragLift(UiItemList sourceList, UiItemSlot sourceCell, ItemDragPayload payload)
|
||||
{
|
||||
// Retail RecvNotice_ItemListBeginDrag → RemoveShortcut (0x004bd930/0x004bd450): the lifted
|
||||
// shortcut leaves the bar (+ wire) the instant the drag starts; it's re-placed only on a
|
||||
// drop onto a slot. Off-bar release leaves it removed.
|
||||
_store.Remove(payload.SourceSlot);
|
||||
_sendRemoveShortcut?.Invoke((uint)payload.SourceSlot);
|
||||
Populate();
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public void HandleDropRelease(UiItemList targetList, UiItemSlot targetCell, ItemDragPayload payload)
|
||||
{
|
||||
// Retail gmToolbarUI::HandleDropRelease (0x004be7c0) within-bar reorder branch (deep-dive §5):
|
||||
// evict the target's occupant, place the dragged item there, and bump the evicted item into
|
||||
// the (now-vacated) source slot if it's free.
|
||||
int target = targetCell.SlotIndex;
|
||||
uint evicted = _store.Get(target); // RemoveShortcutInSlotNum(target)
|
||||
if (evicted != 0) { _store.Remove(target); _sendRemoveShortcut?.Invoke((uint)target); }
|
||||
_store.Set(target, payload.ObjId); // AddShortcut(dragged, target)
|
||||
_sendAddShortcut?.Invoke((uint)target, payload.ObjId);
|
||||
if (evicted != 0 && evicted != payload.ObjId && _store.IsEmpty(payload.SourceSlot))
|
||||
{ // displaced → vacated source slot
|
||||
_store.Set(payload.SourceSlot, evicted);
|
||||
_sendAddShortcut?.Invoke((uint)payload.SourceSlot, evicted);
|
||||
}
|
||||
Populate();
|
||||
}
|
||||
```
|
||||
|
||||
(Delete the old logging `Console.WriteLine` `HandleDropRelease` body and the `OnDragOver` stays as-is.)
|
||||
|
||||
- [ ] **Step 5: Run to verify the controller tests pass**
|
||||
|
||||
Run: `dotnet test tests/AcDream.App.Tests/AcDream.App.Tests.csproj --filter "FullyQualifiedName~ToolbarController"`
|
||||
Expected: PASS (new B.2 tests + the existing B.1 controller tests — `Bind_registersDragHandler_andStampsSlots`, `Populate_bindsShortcutToCorrectSlot` etc. still hold; note `Populate_bindsShortcutToCorrectSlot` exercises the lazy-load path now and should still bind 0x5001 to slot 0).
|
||||
|
||||
- [ ] **Step 6: Inject the session sends at the GameWindow toolbar Bind**
|
||||
|
||||
In `src/AcDream.App/Rendering/GameWindow.cs`, at the `ToolbarController.Bind(...)` call (~line 2013), add the two send actions (mirror the `sendQueryHealth: g => _liveSession?.SendQueryHealth(g)` style used by `SelectedObjectController.Bind` just below it):
|
||||
|
||||
```csharp
|
||||
sendAddShortcut: (i, g) => _liveSession?.SendAddShortcut(i, g),
|
||||
sendRemoveShortcut: i => _liveSession?.SendRemoveShortcut(i),
|
||||
```
|
||||
|
||||
(Add them as the last arguments to the `ToolbarController.Bind(...)` call, after `emptyDigits:`.)
|
||||
|
||||
- [ ] **Step 7: Update the divergence register**
|
||||
|
||||
In `docs/architecture/retail-divergence-register.md`:
|
||||
- **Reword AP-47** (the ghost row) — remove the reduced-alpha claim (the ghost is now full opacity, matching retail). New text for the Divergence cell:
|
||||
`Cursor drag ghost reuses the full composited m_pIcon (incl. type-default underlay) instead of retail's dedicated underlay-less m_pDragIcon. Opacity now matches retail (full).`
|
||||
and the Risk cell: `The ghost carries the opaque type-default underlay backing rather than retail's underlay-less copy — a subtle look difference while dragging, no functional effect.` (Where/oracle columns unchanged.)
|
||||
- **Delete the TS-33 row** entirely (the logging stub is replaced by the real store + wire) and change the TS section header count `## 4. Temporary stopgap (TS) — 32 rows` → `— 31 rows`.
|
||||
|
||||
- [ ] **Step 8: Full build + test (no regressions)**
|
||||
|
||||
Run: `dotnet build AcDream.slnx -c Debug` then `dotnet test tests/AcDream.App.Tests/AcDream.App.Tests.csproj` and `dotnet test tests/AcDream.Core.Tests/AcDream.Core.Tests.csproj`
|
||||
Expected: build green; all pass.
|
||||
|
||||
- [ ] **Step 9: Commit**
|
||||
|
||||
```bash
|
||||
git add src/AcDream.App/UI/Layout/ToolbarController.cs src/AcDream.App/Rendering/GameWindow.cs tests/AcDream.App.Tests/UI/Layout/ToolbarControllerTests.cs docs/architecture/retail-divergence-register.md
|
||||
git commit -m "feat(ui): D.5.3/B.2 — toolbar reorder/remove via ShortcutStore + wire + green-cross overlay" -m "Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Final verification
|
||||
|
||||
- [ ] **Full solution:** `dotnet build AcDream.slnx -c Debug`; `dotnet test tests/AcDream.Core.Tests/...`, `tests/AcDream.Core.Net.Tests/...`, `tests/AcDream.App.Tests/...` — all green.
|
||||
- [ ] **Spec acceptance §9 self-check:** UiRoot/UiItemSlot stay item-agnostic; AP-47 reworded; TS-33 deleted; wire builder renamed with no stale callers.
|
||||
- [ ] **Hand to visual verification** (user, `ACDREAM_RETAIL_UI=1`, in-world): lift a hotbar item → source slot empties immediately; full-opacity icon follows the cursor; hovered slots show a green CROSS; drop on another slot → moves there (occupant bumps to source); drop off-bar → removed; single click still uses; changes persist after relog.
|
||||
- [ ] **Update memory** (`project_d2b_retail_ui.md`) with the B.2 landing + the retail remove-on-lift model; refresh #141 / roadmap as needed.
|
||||
367
docs/superpowers/specs/2026-06-20-d2b-drag-drop-spine-design.md
Normal file
367
docs/superpowers/specs/2026-06-20-d2b-drag-drop-spine-design.md
Normal file
|
|
@ -0,0 +1,367 @@
|
|||
# D.2b drag-drop spine (Stream B.1) — design
|
||||
|
||||
**Date:** 2026-06-20
|
||||
**Phase:** D.2b retail-UI engine → D.5 core panels. Stream **B.1** of the
|
||||
2026-06-18 handoff (the shared drag-drop infra that BOTH the shortcut-drag stream
|
||||
(B) and the inventory window (C) sit on). Build it once, well — it is the
|
||||
critical-path lynchpin.
|
||||
**Branch:** `claude/hopeful-maxwell-214a12` (== main == `31d7ffd`).
|
||||
**Spec author:** lead-engineer brainstorm 2026-06-20 (4 design decisions confirmed
|
||||
by the user).
|
||||
|
||||
**Read alongside:**
|
||||
- `docs/research/2026-06-16-ui-item-slot-icon-dragdrop-spine-deep-dive.md` §5 / §5.7
|
||||
(the pseudocode IS the spec; sub-element + flag anchors).
|
||||
- `docs/research/2026-06-18-d53-bar-finish-and-inventory-handoff.md` §B.1
|
||||
(current-code readiness).
|
||||
- `claude-memory/project_d2b_retail_ui.md` (the toolkit), `feedback_ui_resolve_zero_magenta`
|
||||
(the 0-id → magenta footgun; guard on the id, not the GL handle).
|
||||
|
||||
---
|
||||
|
||||
## 1. Goal & non-goals
|
||||
|
||||
**Goal.** Complete the widget-level drag-drop machine so a player can pick up an
|
||||
item icon, see a translucent ghost track the cursor, watch hovered slots flip to
|
||||
**accept (green) / reject (red)**, and drop — at which point the drop is dispatched
|
||||
to the owning panel's handler with a fully-resolved payload. `UiRoot` already holds
|
||||
the *device-level* drag state machine (`BeginDrag`/`UpdateDragHover`/`FinishDrag`,
|
||||
promoted on >3 px move, live-wired to Silk.NET). This stream supplies the five
|
||||
missing pieces (handoff §B.1): payload injection, the cursor ghost, drop-target
|
||||
hooks on the cell, the accept/reject overlay, and the panel-handler interface.
|
||||
|
||||
**In scope (the spine — generic, shared):**
|
||||
1. Payload injection into `UiRoot.BeginDrag` (today passes `payload: null`).
|
||||
2. A cursor-following drag ghost, painted by `UiRoot`, item-agnostic.
|
||||
3. Drop-target hooks on `UiItemSlot` (DragEnter/Over/DropReleased → accept/reject
|
||||
overlay + dispatch).
|
||||
4. The `IItemListDragHandler` interface + registration on `UiItemList`.
|
||||
5. A typed `ItemDragPayload` describing what is dragged and where from.
|
||||
6. Moving the item-cell's use-trigger from MouseDown → Click (drag/click
|
||||
disambiguation; also more retail-faithful).
|
||||
7. A **visible toolbar stub handler** so the chain is verifiable this session.
|
||||
|
||||
**Out of scope (explicitly deferred — later streams):**
|
||||
- The `AddShortcut 0x019C` / `RemoveShortcut 0x019D` wire and the mutable
|
||||
`ShortcutStore` (Stream **B.2**).
|
||||
- The inventory window, `UiItemList` N-cell grid mode, window manager
|
||||
(Streams **C / window-mgr**).
|
||||
- Stack-split drag (the entry/slider `0x100001A3/A4`), give-to-NPC, drop-to-ground,
|
||||
wield wire — all per-panel **HandleDropRelease** opcode selection (the deep-dive
|
||||
§5.7 opcode table; lands with each consuming panel).
|
||||
- The faithful `m_pDragIcon` (base+overlay-no-underlay) drag composite — MVP reuses
|
||||
the existing full icon at reduced alpha (AP-47).
|
||||
- Esc-to-cancel-drag (retail has it; future polish).
|
||||
|
||||
---
|
||||
|
||||
## 2. Retail grounding (the four confirmed decisions)
|
||||
|
||||
The named decomp (`acclient_2013_pseudo_c.txt`) was greped to anchor each piece;
|
||||
the spine introduces **no new wire format** (opcodes are B.2/C), so the workflow's
|
||||
grep→cross-ref→pseudocode step applies to the *event-chain semantics* and
|
||||
`InqDropIconInfo`, both confirmed below.
|
||||
|
||||
### 2.1 The retail chain (deep-dive §5.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;
|
||||
|
||||
/// <summary>Where a dragged item came from — the retail InqDropIconInfo flag
|
||||
/// distinction (flags&0xE==0 fresh-from-inventory vs flags&4 within-list reorder)
|
||||
/// expressed as a typed enum. The drop handler maps SourceKind+target back to the
|
||||
/// fresh-vs-reorder decision. Decomp: gmToolbarUI 0x004bd162 / 0x004bd1af.</summary>
|
||||
public enum ItemDragSource { Inventory, ShortcutBar, Equipment, Ground }
|
||||
|
||||
/// <summary>Snapshot of a drag-in-progress, taken at drag-begin (so a server move
|
||||
/// arriving mid-drag can't mutate it under us). Port of retail's m_dragElement +
|
||||
/// InqDropIconInfo out-params (objId/container/flags, decomp 230533).
|
||||
/// SourceContainer is NOT stored — the handler resolves the LIVE container from
|
||||
/// ClientObjectTable.Get(ObjId).ContainerId at drop (single source of truth; same
|
||||
/// value retail reads off the element, see §7).</summary>
|
||||
public sealed record ItemDragPayload(
|
||||
uint ObjId, // dragged weenie guid (retail itemID, +0x5FC)
|
||||
ItemDragSource SourceKind, // what kind of slot it left
|
||||
int SourceSlot, // the source cell's SlotIndex (retail m_lastShortcutNumDragged)
|
||||
UiItemSlot SourceCell); // back-ref: reorder-restore / clear source state / ghost texture
|
||||
```
|
||||
|
||||
### 4.2 CHANGED — `UiElement` (two new virtuals; default null)
|
||||
```csharp
|
||||
/// <summary>The data this element carries when a drag begins. UiRoot.BeginDrag pulls
|
||||
/// this; a NULL return CANCELS the drag (retail: ItemList_BeginDrag only arms an
|
||||
/// occupied cell). Default null = not draggable.</summary>
|
||||
public virtual object? GetDragPayload() => null;
|
||||
|
||||
/// <summary>The texture UiRoot paints at the cursor while this element is the drag
|
||||
/// source: (GL handle, width, height). Null = no ghost. Keeps UiRoot item-agnostic.</summary>
|
||||
public virtual (uint tex, int w, int h)? GetDragGhost() => null;
|
||||
```
|
||||
|
||||
### 4.3 CHANGED — `UiRoot`
|
||||
- `BeginDrag(UiElement source)` (drop the `payload` param): pull
|
||||
`var payload = source.GetDragPayload();` → if `null`, set `_dragCandidate = false`
|
||||
and **return without arming** (no `DragSource`, no event). Else set
|
||||
`DragSource`/`DragPayload`, fire `DragBegin` with the payload. Call site (line 188)
|
||||
becomes `BeginDrag(Captured);`.
|
||||
- `Draw(ctx)`: after `DrawOverlays`, still inside the overlay layer, draw the ghost:
|
||||
```csharp
|
||||
if (DragSource?.GetDragGhost() is { tex: var t, w: var gw, h: var gh } && t != 0)
|
||||
ctx.DrawSprite(t, MouseX - gw/2f, MouseY - gh/2f, gw, gh, 0,0,1,1,
|
||||
new Vector4(1,1,1, GhostAlpha)); // GhostAlpha = 0.6f
|
||||
```
|
||||
Root transform is at origin during `Draw`, so cursor coords are absolute. The
|
||||
ghost is NOT a tree element → it never intercepts hit-tests.
|
||||
|
||||
### 4.4 CHANGED — `UiItemSlot`
|
||||
- Add `public int SlotIndex { get; set; } = -1;` (the cell's own index within its
|
||||
panel — 0..17 toolbar, container slot for inventory; distinct from `ShortcutNum`,
|
||||
the 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
|
||||
/// <summary>A panel controller implements this and registers itself on each of its
|
||||
/// UiItemLists. Port of retail's m_dragHandler vtable (RegisterItemListDragHandler,
|
||||
/// decomp 230461). OnDragOver decides the accept/reject OVERLAY only (advisory);
|
||||
/// HandleDropRelease is authoritative — it does the action (or no-ops to reject).</summary>
|
||||
public interface IItemListDragHandler
|
||||
{
|
||||
bool OnDragOver(UiItemList targetList, UiItemSlot targetCell, ItemDragPayload payload);
|
||||
void HandleDropRelease(UiItemList targetList, UiItemSlot targetCell, ItemDragPayload payload);
|
||||
}
|
||||
```
|
||||
|
||||
### 4.6 CHANGED — `UiItemList`
|
||||
- Add `public IItemListDragHandler? DragHandler { get; private set; }` +
|
||||
`public void RegisterDragHandler(IItemListDragHandler h) => DragHandler = h;`.
|
||||
|
||||
### 4.7 CHANGED — `ToolbarController` (the stub handler — TS-33)
|
||||
- `: IItemListDragHandler`. In the ctor, after caching slots, set each cell's
|
||||
`SlotIndex = i` and `SourceKind = ItemDragSource.ShortcutBar`, and
|
||||
`list.RegisterDragHandler(this)`.
|
||||
- `OnDragOver` → `payload.ObjId != 0` (accept any real item; retail's
|
||||
`IsShortcutEligible` gate is B.2).
|
||||
- `HandleDropRelease` → `Console.WriteLine($"[B.2 TODO] drop obj 0x{payload.ObjId:X8} " +
|
||||
$"from {payload.SourceKind} slot {payload.SourceSlot} → toolbar slot {targetCell.SlotIndex}");`
|
||||
No store mutation, no wire. (TS-33; replaced wholesale in B.2.)
|
||||
|
||||
---
|
||||
|
||||
## 5. Data flow — frame by frame
|
||||
|
||||
1. **Press** on an occupied toolbar slot → `UiRoot.OnMouseDown` sets `Captured = cell`,
|
||||
`_dragCandidate = true` (cell isn't `CapturesPointerDrag`, isn't a window-drag).
|
||||
2. **Move > 3 px** → `UiRoot.OnMouseMove` calls `BeginDrag(cell)` → pulls
|
||||
`cell.GetDragPayload()` = `ItemDragPayload{ObjId, ShortcutBar, srcSlot, cell}`
|
||||
(non-null since occupied) → arms `DragSource`/`DragPayload`, fires `DragBegin`.
|
||||
3. **Each move while dragging** → `UpdateDragHover(x,y)` hit-tests; on target CHANGE
|
||||
it fires `DragOver` (LEAVE) on the old cell → its overlay resets to neutral, and
|
||||
`DragEnter` on the new cell → it asks its list's handler `OnDragOver` → overlay
|
||||
flips accept/reject. Meanwhile `UiRoot.Draw` paints the ghost at the cursor.
|
||||
4. **Release** → `UiRoot.OnMouseUp`: `DragSource` set → `FinishDrag(x,y)` delivers
|
||||
`DropReleased` to the cell under the cursor (`Data0 = 1` if a different element).
|
||||
The cell → its list's `HandleDropRelease(list, cell, payload)` → (stub) logs.
|
||||
`DragSource`/`DragPayload` cleared → ghost stops. Capture released.
|
||||
5. **Empty-slot drag attempt** → step 2 `GetDragPayload()` returns null →
|
||||
`_dragCandidate=false`, nothing armed; the eventual mouse-up emits Click (no-op,
|
||||
`Clicked` guards `ItemId != 0`).
|
||||
|
||||
---
|
||||
|
||||
## 6. Edge cases & notes
|
||||
|
||||
- **Event-id namespace.** acdream's `UiEventType` (DragBegin 0x15, DragEnter 0x21,
|
||||
DragOver 0x1C, DropReleased 0x3E) is the contract the cell handles — these are
|
||||
`UiRoot`'s constants and differ from retail's raw per-cell `ListenToElementMessage`
|
||||
codes (0x21 begin / 0x3e over / 0x15 drop). Do NOT try to match the retail per-cell
|
||||
codes; the SEMANTICS match, the numbers are `UiRoot`'s. (IA-12 already covers "UI
|
||||
toolkit mirrors behavior, not byte-layout.")
|
||||
- **Hover re-eval cadence.** `UiRoot.UpdateDragHover` early-returns when the target
|
||||
is unchanged, so accept/reject is computed once per cell-enter, not every move.
|
||||
For item cells the decision is constant per (cell, payload), so this is
|
||||
behavior-equivalent to retail's per-move `MouseOverTop` — no register row.
|
||||
- **Self-drop / drop-on-nothing.** `FinishDrag` sets `Data0=0` when the target is the
|
||||
source or null; the cell gates `HandleDropRelease` on `Data0==1`, so both are clean
|
||||
no-ops (= "put it back").
|
||||
- **Magenta footgun.** `DragAcceptSprite`/`DragRejectSprite` default non-zero; the
|
||||
`OnDraw` overlay still guards `if (id != 0)` before `SpriteResolve`, never on the
|
||||
returned GL handle (`feedback_ui_resolve_zero_magenta`).
|
||||
- **No heavy diagnostics in the render loop.** The stub's `Console.WriteLine` fires
|
||||
only on a discrete drop event (not per frame), so it can't eat dt-based animations.
|
||||
|
||||
---
|
||||
|
||||
## 7. Divergence register rows (add in the IMPLEMENTATION commit)
|
||||
|
||||
- **AP-47** (Documented approximation): *Drag ghost reuses the full composited
|
||||
`m_pIcon` at reduced alpha (`GhostAlpha=0.6`) instead of retail's dedicated
|
||||
`m_pDragIcon` (base + custom-overlay, NO underlay).* Where: `UiRoot.Draw` +
|
||||
`UiItemSlot.GetDragGhost`. Why safe: cosmetic only — the dragged item is still
|
||||
unambiguously identifiable; building the second composite is deferred polish. Risk:
|
||||
the ghost shows the opaque type-default underlay backing rather than retail's
|
||||
underlay-less translucent copy. Oracle: `IconData::RenderIcons` 407594-407625
|
||||
(m_pDragIcon path); deep-dive §3.2/§5.5.
|
||||
- **TS-33** (Temporary stopgap): *Toolbar `IItemListDragHandler.HandleDropRelease`
|
||||
is a logging stub — no `AddShortcut 0x019C`/`RemoveShortcut 0x019D` wire, no
|
||||
`ShortcutStore` mutation.* Where: `ToolbarController.HandleDropRelease`. Awaiting:
|
||||
Stream B.2. Risk: a drop onto the bar visibly does nothing but log; reorder/add/
|
||||
remove are inert until B.2. Oracle: `gmToolbarUI::HandleDropRelease`
|
||||
acclient_2013_pseudo_c.txt:197971.
|
||||
|
||||
**Note (not a register row):** the payload omits `SourceContainer`; the handler
|
||||
resolves the live container via `ClientObjectTable.Get(ObjId).ContainerId` at drop.
|
||||
Same container id retail reads off the dragged element — single source of truth, no
|
||||
stale snapshot → no observable behavior deviation.
|
||||
|
||||
---
|
||||
|
||||
## 8. Testing (conformance)
|
||||
|
||||
App-layer tests in `tests/AcDream.App.Tests/UI/` (no GL — pure logic):
|
||||
1. `GetDragPayload` returns null for an empty cell, a correct `ItemDragPayload` for a
|
||||
bound cell (ObjId/SourceKind/SourceSlot/SourceCell).
|
||||
2. `BeginDrag` does NOT arm (`DragSource == null`) when the source's payload is null;
|
||||
arms + fires `DragBegin` when non-null.
|
||||
3. Drop dispatch: a fake `IItemListDragHandler` registered on a `UiItemList` receives
|
||||
`OnDragOver` on DragEnter and `HandleDropRelease` on DropReleased with the right
|
||||
`(list, cell, payload)`; NOT called on a `Data0==0` self/empty drop.
|
||||
4. Accept/reject overlay state flips to Accept when the handler returns true, Reject
|
||||
when false, None on leave.
|
||||
5. Use-vs-drag: a Click (no intervening drag) fires `Clicked`; a completed drag does
|
||||
not (drive `UiRoot` MouseDown→move>3px→MouseUp and assert `Clicked` didn't fire).
|
||||
6. `FindList()` resolves the parent `UiItemList` through the cell's `Parent` chain.
|
||||
|
||||
Drive these through `UiRoot`'s public `OnMouseDown/OnMouseMove/OnMouseUp` where
|
||||
possible (integration-level), so the device chain is exercised, not just the cell in
|
||||
isolation.
|
||||
|
||||
---
|
||||
|
||||
## 9. Acceptance criteria
|
||||
|
||||
- [ ] `dotnet build` green; `dotnet test` green (existing + the new §8 tests).
|
||||
- [ ] `UiRoot` has zero compile-time dependency on `UiItemSlot`/`ItemDragPayload`
|
||||
(item-agnostic; only the two `UiElement` virtuals bridge).
|
||||
- [ ] AP-47 + TS-33 rows added in the implementation commit; counts bumped.
|
||||
- [ ] **Visual (user):** with `ACDREAM_RETAIL_UI=1`, in-world, grab a hotbar item
|
||||
and drag it: a translucent ghost follows the cursor; the hovered slot shows the
|
||||
accept (green) frame, a slot that rejects shows red; on release over another
|
||||
slot the log prints `[B.2 TODO] drop obj … → toolbar slot N`; the source item
|
||||
stays put (no wire yet). A plain click still USES the item; a drag does not.
|
||||
- [ ] Memory updated if a durable lesson emerged; roadmap/ISSUES note if needed.
|
||||
|
||||
---
|
||||
|
||||
## 10. Subagent task slices (for the plan)
|
||||
|
||||
1. **Toolkit core** — `ItemDragPayload`/`ItemDragSource`, the two `UiElement`
|
||||
virtuals, `UiRoot.BeginDrag` payload pull + cancel, `UiRoot.Draw` ghost.
|
||||
2. **Cell hooks** — `UiItemSlot`: `SlotIndex`/`SourceKind`, `GetDragPayload`/
|
||||
`GetDragGhost`, MouseDown→Click move, the DragEnter/Over/DropReleased cases +
|
||||
accept/reject overlay; `IItemListDragHandler`; `UiItemList.DragHandler`.
|
||||
3. **Toolbar stub + wiring** — `ToolbarController : IItemListDragHandler`, register +
|
||||
set SlotIndex/SourceKind, the logging `HandleDropRelease`; register rows.
|
||||
4. **Tests** — the §8 suite.
|
||||
|
||||
(Slices 1→2 are sequential; 3 depends on 2; 4 can follow each.)
|
||||
181
docs/superpowers/specs/2026-06-20-d2b-toolbar-collapse-design.md
Normal file
181
docs/superpowers/specs/2026-06-20-d2b-toolbar-collapse-design.md
Normal file
|
|
@ -0,0 +1,181 @@
|
|||
# D.2b toolbar collapse-to-one-row — design
|
||||
|
||||
**Date:** 2026-06-20
|
||||
**Phase:** D.2b retail-UI, toolbar polish (follows D.5.3 B.1/B.2, both visually confirmed this session).
|
||||
**Branch:** `claude/hopeful-maxwell-214a12`.
|
||||
**Driver:** user request — the toolbar frame should resize vertically between **one row** (row 2 hidden,
|
||||
the minimum) and **two rows** (row 2 shown), **snapping** between the two stops.
|
||||
|
||||
---
|
||||
|
||||
## 1. Goal & non-goals
|
||||
|
||||
**Goal.** The toolbar window can be collapsed to show only the top quickslot row (slots 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
|
||||
/// <summary>Maximum height enforced while resizing (default unbounded). Pairs with MinHeight.</summary>
|
||||
public float MaxHeight { get; set; } = float.MaxValue;
|
||||
|
||||
/// <summary>Which edges may start a resize, beyond the ResizeX/ResizeY axis gates. Default: all.
|
||||
/// Set to e.g. ResizeEdges.Bottom to allow only a bottom-edge drag (the collapse toolbar).</summary>
|
||||
public ResizeEdges ResizableEdges { get; set; } =
|
||||
ResizeEdges.Left | ResizeEdges.Right | ResizeEdges.Top | ResizeEdges.Bottom;
|
||||
```
|
||||
(`MaxWidth` is YAGNI — only height needs it here.)
|
||||
|
||||
- **`UiRoot.HitEdges`** applies the mask at the end: `e &= w.ResizableEdges;` (after the existing
|
||||
`ResizeX`/`ResizeY` masking). So a toolbar with `ResizableEdges = Bottom` only grips its bottom edge;
|
||||
a press near the top edge falls through to window-move, not resize.
|
||||
- **`UiRoot.ResizeRect`** gains a `maxH` parameter and clamps the Bottom/Top height branches:
|
||||
`h = Math.Clamp(startH + dy, minH, maxH)` (Bottom) and the Top branch likewise. `OnMouseMove`'s resize
|
||||
call passes `_resizeTarget.MaxHeight` (and `float.MaxValue` for the width's maxW). **This changes
|
||||
`ResizeRect`'s signature — update its existing callers + the `UiRootInputTests.ResizeRect_*` tests to
|
||||
pass the new `maxW`/`maxH` args** (`float.MaxValue` where unbounded, preserving their current
|
||||
assertions).
|
||||
|
||||
### 3.2 `UiCollapsibleFrame : UiNineSlicePanel` (new) — `src/AcDream.App/UI/UiCollapsibleFrame.cs`
|
||||
A toolbar-frame variant that snaps between two heights and toggles a set of "second-row" elements.
|
||||
One clear responsibility: reconcile its height to a stop and the rows to that stop, every tick.
|
||||
```csharp
|
||||
public sealed class UiCollapsibleFrame : UiNineSlicePanel
|
||||
{
|
||||
public UiCollapsibleFrame(Func<uint,(uint,int,int)> resolveChrome) : base(resolveChrome) { }
|
||||
|
||||
public float CollapsedHeight { get; set; }
|
||||
public float ExpandedHeight { get; set; }
|
||||
/// <summary>Elements shown only when expanded (the row-2 slot lists). Hidden when collapsed.</summary>
|
||||
public IReadOnlyList<UiElement> SecondRow { get; set; } = System.Array.Empty<UiElement>();
|
||||
|
||||
/// <summary>True when the frame is currently at (or nearer) the expanded stop.</summary>
|
||||
public bool IsExpanded => Height >= (CollapsedHeight + ExpandedHeight) * 0.5f;
|
||||
|
||||
protected override void OnTick(double dt)
|
||||
{
|
||||
base.OnTick(dt);
|
||||
if (ExpandedHeight <= CollapsedHeight) return; // not configured yet
|
||||
// Snap to the nearer stop (the resize drag sets Height live; we resolve it to a stop so the
|
||||
// frame always rests collapsed or expanded — never a half-row).
|
||||
bool expanded = IsExpanded;
|
||||
Height = expanded ? ExpandedHeight : CollapsedHeight;
|
||||
// Row 2 is shown only when expanded. (No clipping needed — the dat content is top-anchored,
|
||||
// so row-2 slots simply stop drawing when hidden; row 1 never moves.)
|
||||
for (int i = 0; i < SecondRow.Count; i++) SecondRow[i].Visible = expanded;
|
||||
}
|
||||
}
|
||||
```
|
||||
Notes:
|
||||
- The snap runs in `OnTick` (after the frame's `MinHeight`/`MaxHeight`-clamped resize drag set `Height`
|
||||
that frame), so the rendered height is always a stop. With only two stops one row apart, this reads
|
||||
as: drag the bottom edge past the midpoint → it jumps to the other stop + row 2 appears/hides.
|
||||
- `IsExpanded`/the snap use the midpoint; `MinHeight`/`MaxHeight` (set by the mount) keep the drag
|
||||
within `[Collapsed, Expanded]` so the midpoint test is well-defined.
|
||||
|
||||
### 3.3 GameWindow toolbar mount — `src/AcDream.App/Rendering/GameWindow.cs` (~line 2045)
|
||||
- Build the frame as `UiCollapsibleFrame` instead of `UiNineSlicePanel` (same `ResolveChrome` ctor arg).
|
||||
- After the content (`toolbarRoot`) is sized: compute `expandedH = toolbarContentH + 2·border`,
|
||||
`collapsedH = minRow2Top + 2·border` where `minRow2Top` = `min` of the nine row-2 lists' `Top`
|
||||
(resolve each via `toolbarLayout.FindElement(0x100006B7..BF)`; reuse `ToolbarController`'s row-2 id
|
||||
list or inline the nine ids).
|
||||
- Set on the frame: `Resizable = true; ResizableEdges = ResizeEdges.Bottom` (bottom-edge only — top
|
||||
edge stays a move grip); `MinHeight = collapsedH; MaxHeight = expandedH; Height = expandedH` (default
|
||||
expanded); `CollapsedHeight = collapsedH; ExpandedHeight = expandedH; SecondRow = <the nine row-2
|
||||
elements>`. (`ResizeX`/`ResizeY` keep defaults; the `ResizableEdges = Bottom` mask is the operative
|
||||
restriction.)
|
||||
- Change `toolbarRoot.Anchors` from all-four-edges to **`Left | Top | Right`** (drop `Bottom`) so the
|
||||
dat content keeps its full height and row 1 never reflows when the frame collapses; row 2 hides via
|
||||
`Visible`. (Width is fixed — `ResizeX=false` — so the horizontal anchors are inert but harmless.)
|
||||
|
||||
---
|
||||
|
||||
## 4. Behavior walk-through
|
||||
|
||||
- **Launch:** frame at `ExpandedHeight`, both rows visible (unchanged from today).
|
||||
- **Collapse:** grab the bottom edge, drag up past the midpoint → `OnTick` snaps `Height` to
|
||||
`CollapsedHeight` and hides the nine row-2 slots. The frame is now a single-row bar; row 1 unchanged.
|
||||
- **Expand:** drag the bottom edge down past the midpoint → snaps to `ExpandedHeight`, row 2 reappears.
|
||||
- **Move:** unchanged — drag an empty cell / chrome to reposition (IA-12); occupied cells drag items
|
||||
(B.1/B.2).
|
||||
- **Edge cases:** `MinHeight`/`MaxHeight` clamp the drag to `[Collapsed, Expanded]`; the snap is
|
||||
idempotent when not dragging (Height already at a stop). The collapsed state is per-session (resets
|
||||
to expanded on relaunch).
|
||||
|
||||
---
|
||||
|
||||
## 5. Divergence register
|
||||
|
||||
**Amend IA-17** (toolbar window FRAME is toolkit-supplied): add that the frame also supports a
|
||||
toolkit-defined **collapse-to-one-row** (bottom-edge resize snapping between a row-1-only and a
|
||||
two-row height, row-2 visibility tied to the stop). Retail's real collapse mechanism is keystone.dll
|
||||
(no decomp) and the dat encodes no collapse (both rows always present) — so this is our toolkit UX from
|
||||
the user's retail observation, same justification class as the rest of IA-17. No new row; extend IA-17's
|
||||
text + cite this spec.
|
||||
|
||||
---
|
||||
|
||||
## 6. Testing
|
||||
|
||||
`tests/AcDream.App.Tests/UI/`:
|
||||
1. `UiCollapsibleFrame.OnTick` snap: set `CollapsedHeight=96`, `ExpandedHeight=128`; set `Height` just
|
||||
below the midpoint (e.g. 100) + tick → `Height == 96` and every `SecondRow` element `Visible==false`;
|
||||
set `Height` just above (e.g. 120) + tick → `Height == 128` and `SecondRow` `Visible==true`.
|
||||
2. `UiCollapsibleFrame` not-configured guard: `ExpandedHeight==CollapsedHeight==0` → `OnTick` is a
|
||||
no-op (no divide/no forced height).
|
||||
3. `UiRoot.ResizeRect` MaxHeight clamp: a Bottom-edge resize with `dy` huge clamps `h` to `maxH`;
|
||||
a Top-edge resize likewise; min still honored. (Drive via the existing `ResizeRect` static test
|
||||
pattern in `UiRootInputTests`; also update the two pre-existing `ResizeRect_*` tests to the new
|
||||
`maxW`/`maxH` signature — pass `float.MaxValue`, assertions unchanged.)
|
||||
4. `UiRoot.HitEdges` honors `ResizableEdges`: a panel with `ResizableEdges = ResizeEdges.Bottom` returns
|
||||
only `Bottom` when pressed near its bottom edge, and `None` near its top edge (which would otherwise
|
||||
be a grip with `ResizeY` true).
|
||||
|
||||
A `UiCollapsibleFrame` needs a chrome resolver in tests — pass `_ => (1u,1,1)` (the existing
|
||||
`UiNineSlicePanel` test pattern); `OnTick` doesn't draw, so no GL.
|
||||
|
||||
---
|
||||
|
||||
## 7. Acceptance
|
||||
|
||||
- [ ] `dotnet build` + `dotnet test` green.
|
||||
- [ ] IA-17 amended.
|
||||
- [ ] **Visual (user):** default shows both rows; dragging the toolbar's **bottom edge up** snaps it to
|
||||
a single row (row 2 gone); dragging **down** snaps back to two rows; row 1 never moves/squishes;
|
||||
the window still moves by dragging empty cells/chrome; item drag (B.1/B.2) still works.
|
||||
|
||||
---
|
||||
|
||||
## 8. Plan size
|
||||
|
||||
One small task (TDD): `UiElement.MaxHeight` + `ResizeRect` clamp → `UiCollapsibleFrame` + its tests →
|
||||
the GameWindow mount swap + IA-17 amend. ~3 files + 1 test file. Suitable for a single implementer pass.
|
||||
|
|
@ -0,0 +1,386 @@
|
|||
# D.2b toolbar shortcut drag interactivity (Stream B.2) — design
|
||||
|
||||
**Date:** 2026-06-20
|
||||
**Phase:** D.2b retail-UI → D.5 core panels. Stream **B.2** of the 2026-06-18 handoff — make the
|
||||
toolbar shortcut drag *functional* (reorder / remove) and *retail-faithful*, on top of the B.1
|
||||
spine (`9d48346..acdefc2`, shipped + visually confirmed this session).
|
||||
**Branch:** `claude/hopeful-maxwell-214a12`.
|
||||
**Driver:** user visual-gate feedback 2026-06-20 (6 points) + the retail research below.
|
||||
|
||||
**Read alongside:**
|
||||
- `docs/research/2026-06-16-action-bar-toolbar-deep-dive.md` §5 (HandleDropRelease/RemoveShortcut/wire).
|
||||
- `docs/research/2026-06-16-ui-item-slot-icon-dragdrop-spine-deep-dive.md` §5 (the cell drag chain).
|
||||
- `docs/superpowers/specs/2026-06-20-d2b-drag-drop-spine-design.md` (B.1 — what this extends).
|
||||
- `claude-memory/project_d2b_retail_ui.md`.
|
||||
|
||||
---
|
||||
|
||||
## 1. Goal & non-goals
|
||||
|
||||
**Goal.** A player drags a hotbar shortcut, sees the item icon lift off the slot (slot empties) and
|
||||
follow the cursor at full opacity with a green-cross drop indicator; dropping on another slot moves
|
||||
it there (reordering, bumping any occupant back to the source slot); dropping anywhere else removes
|
||||
it from the bar. All changes are sent to the server (`AddShortcut 0x019C` / `RemoveShortcut 0x019D`)
|
||||
so they persist. This realizes the user's 6 visual-gate points as retail's single **remove-on-lift /
|
||||
place-on-drop / no-restore** mechanism.
|
||||
|
||||
**In scope:**
|
||||
1. **Spine extensions** (small, to the B.1 toolkit): a drag-LIFT hook to the source's handler;
|
||||
full-opacity ghost snapshotted at drag-begin (survives the source emptying); `FinishDrag`
|
||||
delivers a drop only when released on a real element (off-bar = no drop fires).
|
||||
2. **`ShortcutStore`** — a mutable 18-slot model (port of retail `ShortCutManager::shortCuts_[18]`).
|
||||
3. **`ToolbarController` as the live drag handler** — `OnDragLift` removes; `HandleDropRelease`
|
||||
places + bumps displaced → source; both drive the store, the wire, and a re-`Populate()`.
|
||||
4. **The wire** — fix `BuildAddShortcut` param names; add `WorldSession.SendAddShortcut` /
|
||||
`SendRemoveShortcut`.
|
||||
5. **Green-cross accept overlay** (`0x060011FA`) on toolbar cells (verified against the dat).
|
||||
|
||||
**Out of scope (later streams):**
|
||||
- **Drag FROM inventory onto the bar** (the `flags & 0xE == 0` "fresh-from-inventory" branch) — needs
|
||||
the inventory window as a drag source (Stream C). This spec is reorder-within-bar + remove only
|
||||
(the `flags & 4` branch).
|
||||
- Spell shortcuts (the `SpellId|Layer` payload) — items only (`spellId=layer=0`); the store carries a
|
||||
spell field for forward-compat but the toolbar binds item guids.
|
||||
- Server-pushed shortcut mutations after login (the store is client-authoritative post-`Load`).
|
||||
- The selected-object mana meter / stack-split UI (issue #141 deferred bits).
|
||||
|
||||
---
|
||||
|
||||
## 2. Retail grounding (the model — CONFIRMED by research 2026-06-20)
|
||||
|
||||
Retail's toolbar drag is **remove-on-lift, place-on-drop, no-restore** (decomp trace, this session):
|
||||
|
||||
- **On drag-begin** (`UIElement_ItemList::ItemList_BeginDrag` 0x004e32d0 → broadcasts msg `0x21` →
|
||||
`gmToolbarUI::RecvNotice_ItemListBeginDrag` 0x004bd930): `PrepareDragIcon` makes the **full icon**
|
||||
the cursor ghost (m_dragIcon), then `gmToolbarUI::RemoveShortcut` (0x004bd450) **removes the
|
||||
shortcut**: `ItemList_Flush` → cell → empty state `0x1000001c`, `Event_RemoveShortCut` sends
|
||||
`0x019D`, and `m_lastShortcutNumDragged` = the source slot. The slot is now empty; the item is "in
|
||||
hand."
|
||||
- **On mouse-up over a slot** (`UIElementManager::StopDragandDrop` 0x00459810 → success →
|
||||
`gmToolbarUI::HandleDropRelease` 0x004be7c0): within-bar reorder branch (`flags & 4`):
|
||||
`RemoveShortcutInSlotNum(target)` (evict the occupant, get its objId) → `AddShortcut(draggedObjId,
|
||||
target)` (`0x019C`); if an item was displaced and the source slot is free, `AddShortcut(displaced,
|
||||
m_lastShortcutNumDragged)` — the bumped item lands in the vacated source slot. (deep-dive §5.)
|
||||
- **On mouse-up over nothing** (`m_pElementLastDragCursorOver == null` → success=0): no
|
||||
`HandleDropRelease`, no re-add — the shortcut stays gone (already removed + `0x019D` sent at lift).
|
||||
- **No cancel/restore path.** `gmToolbarUI` has no end-drag/restore vtable entry. Lifted-then-not-
|
||||
landed = permanently removed (server already told).
|
||||
- **Drop indicator** is a per-slot OVERLAY sprite on `m_elem_Icon_DragAccept` (`0x1000045A`), NOT a
|
||||
cursor change (`UpdateCursorState` is combat/target-only). The toolbar accept sprite is the **green
|
||||
cross `0x060011FA`** (verified by exporting the `0x060011F7..FA` family from `client_portal.dat`:
|
||||
F7=green move-arrow, F8=red ∅ reject, F9=green **ring** [inventory], FA=green **cross** [toolbar]);
|
||||
reject = `0x060011F8`.
|
||||
|
||||
**Wire (`ShortCutData`, CONFIRMED 3 refs — deep-dive §131-145):**
|
||||
- `AddShortCut 0x019C` body after the 12-byte GameAction envelope: `Index(u32), ObjectId(u32),
|
||||
SpellId(u16), Layer(u16)` (item → `ObjectId=guid, SpellId=Layer=0`).
|
||||
- `RemoveShortCut 0x019D` body: `Index(u32)`.
|
||||
- ACE handles the remove-then-add reorder pattern (`Player_Character.cs:254`).
|
||||
|
||||
---
|
||||
|
||||
## 3. Architecture — components & boundaries
|
||||
|
||||
```
|
||||
press+move on OCCUPIED slot ─► UiRoot.BeginDrag
|
||||
│ snapshot ghost (full icon) BEFORE the lift clears the source cell
|
||||
│ fire DragBegin on the source cell
|
||||
▼
|
||||
UiItemSlot.OnEvent(DragBegin) ─► FindList().DragHandler.OnDragLift(list, cell, payload)
|
||||
▼
|
||||
ToolbarController.OnDragLift ─► ShortcutStore.Remove(srcSlot); SendRemoveShortcut(srcSlot); Populate()
|
||||
(source slot now empty; ghost still shows the snapshot)
|
||||
drag-over target slot ─► UiItemSlot DragEnter ─► handler.OnDragOver → green-cross accept overlay (FA)
|
||||
release ─► UiRoot.FinishDrag:
|
||||
├─ over a slot ─► that cell.OnEvent(DropReleased) ─► handler.HandleDropRelease(target, payload)
|
||||
│ ShortcutStore place + bump displaced→srcSlot; SendAddShortcut×N; Populate()
|
||||
└─ over nothing ─► no DropReleased delivered → lift's removal stands (item gone)
|
||||
```
|
||||
|
||||
**Boundaries:** `UiRoot`/`UiElement`/`UiItemSlot`/`UiItemList` stay item-agnostic — they gain a lift
|
||||
*hook* and a ghost snapshot, nothing item-specific. `ShortcutStore` is pure logic in `AcDream.Core`
|
||||
(testable, no GL). The wire lives in `AcDream.Core.Net`. `ToolbarController` (App) orchestrates.
|
||||
Honors structure Rules 1/2/3/6.
|
||||
|
||||
---
|
||||
|
||||
## 4. New & changed types (precise)
|
||||
|
||||
### 4.1 Spine — `IItemListDragHandler` gains the lift hook (`UI/IItemListDragHandler.cs`)
|
||||
```csharp
|
||||
public interface IItemListDragHandler
|
||||
{
|
||||
/// <summary>The drag STARTED from a cell in this list — retail's
|
||||
/// RecvNotice_ItemListBeginDrag → RemoveShortcut (decomp 0x004bd930/0x004bd450): the handler
|
||||
/// removes the lifted item from its model + wire, so the source slot empties immediately. The
|
||||
/// item is "in hand" until HandleDropRelease (place) or the drag ends off-target (stays removed).</summary>
|
||||
void OnDragLift(UiItemList sourceList, UiItemSlot sourceCell, ItemDragPayload payload);
|
||||
|
||||
bool OnDragOver(UiItemList targetList, UiItemSlot targetCell, ItemDragPayload payload);
|
||||
void HandleDropRelease(UiItemList targetList, UiItemSlot targetCell, ItemDragPayload payload);
|
||||
}
|
||||
```
|
||||
|
||||
### 4.2 Spine — `UiItemSlot` (`UI/UiItemSlot.cs`)
|
||||
- `OnEvent` `DragBegin` case changes from `return true` (no-op) to: notify the list's handler so it
|
||||
can lift:
|
||||
```csharp
|
||||
case UiEventType.DragBegin:
|
||||
if (FindList() is { DragHandler: { } h } list && e.Payload is ItemDragPayload p)
|
||||
h.OnDragLift(list, this, p);
|
||||
return true;
|
||||
```
|
||||
- `DropReleased` case: drop the `e.Data0 == 1` gate — reaching the cell means a real slot was hit
|
||||
(`FinishDrag` only delivers on a hit). The handler is authoritative (place; drop-on-self re-adds):
|
||||
```csharp
|
||||
case UiEventType.DropReleased:
|
||||
_dragAccept = DragAcceptState.None;
|
||||
if (FindList() is { DragHandler: { } dh } dl && e.Payload is ItemDragPayload dp)
|
||||
dh.HandleDropRelease(dl, this, dp);
|
||||
return true;
|
||||
```
|
||||
(`DragEnter`/`DragOver` overlay handling unchanged from B.1.)
|
||||
|
||||
### 4.3 Spine — `UiRoot` (`UI/UiRoot.cs`)
|
||||
- Add `private (uint tex, int w, int h)? _dragGhost;`. In `BeginDrag`, **snapshot the ghost before
|
||||
firing `DragBegin`** (the lift will empty the source cell, so a live re-read would vanish):
|
||||
```csharp
|
||||
private void BeginDrag(UiElement source)
|
||||
{
|
||||
var payload = source.GetDragPayload();
|
||||
if (payload is null) { _dragCandidate = false; return; }
|
||||
DragSource = source;
|
||||
DragPayload = payload;
|
||||
_dragGhost = source.GetDragGhost(); // snapshot NOW — survives the source emptying
|
||||
var e = new UiEvent(source.EventId, source, UiEventType.DragBegin, Payload: payload);
|
||||
source.OnEvent(in e); // → cell → handler.OnDragLift (clears the source slot)
|
||||
}
|
||||
```
|
||||
- `DrawDragGhost` uses the snapshot, not a live read; `GhostAlpha` → **1.0** (full opacity, retail
|
||||
m_dragIcon):
|
||||
```csharp
|
||||
private const float GhostAlpha = 1.0f;
|
||||
private void DrawDragGhost(UiRenderContext ctx)
|
||||
{
|
||||
if (_dragGhost is not { } g || g.tex == 0) return;
|
||||
ctx.DrawSprite(g.tex, MouseX - g.w / 2f, MouseY - g.h / 2f, g.w, g.h,
|
||||
0f, 0f, 1f, 1f, new Vector4(1f, 1f, 1f, GhostAlpha));
|
||||
}
|
||||
```
|
||||
- `FinishDrag` delivers a drop ONLY to a real hit element; off-bar (null hit) fires nothing; clear
|
||||
the ghost:
|
||||
```csharp
|
||||
private void FinishDrag(int x, int y)
|
||||
{
|
||||
var (t, lx, ly) = HitTestTopDown(x, y);
|
||||
if (t is not null)
|
||||
{
|
||||
var e = new UiEvent(DragSource!.EventId, t, UiEventType.DropReleased,
|
||||
Data1: (int)lx, Data2: (int)ly, Payload: DragPayload);
|
||||
t.OnEvent(in e); // the hit cell's handler places; a non-item target ignores it → item stays removed
|
||||
}
|
||||
// else: dropped off any element — no drop fires; the lift's removal stands (retail).
|
||||
DragSource = null; DragPayload = null; _dragGhost = null; _lastDragHoverTarget = null;
|
||||
}
|
||||
```
|
||||
(`GetDragGhost()` on `UiElement`/`UiItemSlot` stays as-is — `BeginDrag` calls it once.)
|
||||
|
||||
### 4.4 NEW — `ShortcutStore` (`src/AcDream.Core/Items/ShortcutStore.cs`)
|
||||
Port of retail `ShortCutManager::shortCuts_[18]` (`acclient.h:36492`). Pure logic.
|
||||
```csharp
|
||||
public sealed class ShortcutStore
|
||||
{
|
||||
public const int SlotCount = 18;
|
||||
private readonly uint[] _objIds = new uint[SlotCount]; // 0 = empty
|
||||
|
||||
/// <summary>Replace all slots from the login PlayerDescription shortcut list.</summary>
|
||||
public void Load(IReadOnlyList<PlayerDescriptionParser.ShortcutEntry> entries)
|
||||
{
|
||||
System.Array.Clear(_objIds);
|
||||
foreach (var e in entries)
|
||||
if (e.Index < SlotCount && e.ObjectGuid != 0) _objIds[e.Index] = e.ObjectGuid;
|
||||
}
|
||||
|
||||
public uint Get(int slot) => (uint)slot < SlotCount ? _objIds[slot] : 0u;
|
||||
public bool IsEmpty(int slot) => Get(slot) == 0u;
|
||||
public void Set(int slot, uint objId) { if ((uint)slot < SlotCount) _objIds[slot] = objId; }
|
||||
public void Remove(int slot) { if ((uint)slot < SlotCount) _objIds[slot] = 0u; }
|
||||
}
|
||||
```
|
||||
|
||||
### 4.5 Wire — `InventoryActions.BuildAddShortcut` rename (`Core.Net/Messages/InventoryActions.cs`)
|
||||
Byte layout is already correct; FIX the misleading param names (handoff §B.2; deep-dive §131-145):
|
||||
```csharp
|
||||
/// <summary>Pin an item/spell to a quickbar slot. ShortCutData = Index(u32),ObjectId(u32),
|
||||
/// SpellId(u16),Layer(u16). For an item: objectGuid + spellId=layer=0.</summary>
|
||||
public static byte[] BuildAddShortcut(uint seq, uint index, uint objectGuid, ushort spellId, ushort layer)
|
||||
{
|
||||
byte[] body = new byte[24];
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(body, GameActionEnvelope);
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(4), seq);
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(8), AddShortcutOpcode); // 0x019C
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(12), index);
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(16), objectGuid);
|
||||
BinaryPrimitives.WriteUInt16LittleEndian(body.AsSpan(20), spellId);
|
||||
BinaryPrimitives.WriteUInt16LittleEndian(body.AsSpan(22), layer);
|
||||
return body;
|
||||
}
|
||||
```
|
||||
(`BuildRemoveShortcut(seq, slotIndex)` is correct — unchanged.) Any existing caller of the old
|
||||
4-arg `BuildAddShortcut` must be updated; grep first (likely none wired today).
|
||||
|
||||
### 4.6 Wire — `WorldSession` sends (`Core.Net/WorldSession.cs`, mirror `SendChangeCombatMode` :1134)
|
||||
```csharp
|
||||
public void SendAddShortcut(uint index, uint objectGuid, ushort spellId = 0, ushort layer = 0)
|
||||
{
|
||||
uint seq = NextGameActionSequence();
|
||||
SendGameAction(InventoryActions.BuildAddShortcut(seq, index, objectGuid, spellId, layer));
|
||||
}
|
||||
public void SendRemoveShortcut(uint index)
|
||||
{
|
||||
uint seq = NextGameActionSequence();
|
||||
SendGameAction(InventoryActions.BuildRemoveShortcut(seq, index));
|
||||
}
|
||||
```
|
||||
|
||||
### 4.7 `ToolbarController` — the live handler (`UI/Layout/ToolbarController.cs`)
|
||||
- Holds a `ShortcutStore _store`. `Populate()` reads `_store` (18 slots → `repo.Get(objId)` → icon)
|
||||
INSTEAD of the read-only `_shortcuts()` provider. **Lazy-load-once:** the PlayerDescription shortcut
|
||||
list arrives AFTER Bind (at login), so load the store the first time `_shortcuts()` is non-empty,
|
||||
then treat it as authoritative:
|
||||
```csharp
|
||||
// top of Populate(), before rendering:
|
||||
if (!_storeLoaded && _shortcuts().Count > 0) { _store.Load(_shortcuts()); _storeLoaded = true; }
|
||||
```
|
||||
Bind's initial `Populate` (pre-login) sees an empty list and skips the load; the existing
|
||||
`repo.ObjectAdded → Populate` path (shortcut items arriving via `CreateObject`, by which time the PD
|
||||
shortcut list is set) triggers the one-time load. After load, drag ops mutate `_store` directly and
|
||||
it is never reloaded within the session (a relog = a fresh process → `_storeLoaded` resets). No
|
||||
GameWindow change is needed for loading.
|
||||
- `IsShortcutGuid(guid)` (the repo-event gate) checks `_store` (any slot holds `guid`) rather than
|
||||
`_shortcuts()`, so an item dragged ONTO the bar (a new guid) still gets its later updates rendered.
|
||||
- Each toolbar cell: `DragAcceptSprite = 0x060011FAu` (green cross). (`DragRejectSprite` stays
|
||||
`0x060011F8`.)
|
||||
- Inject two `Action`s for the wire (so the controller stays testable without a live session):
|
||||
`Action<uint,uint> sendAdd` = `(index, objId) => session.SendAddShortcut(index, objId)`,
|
||||
`Action<uint> sendRemove` = `index => session.SendRemoveShortcut(index)`. Null in tests.
|
||||
- `OnDragLift(sourceList, sourceCell, payload)` — retail `RemoveShortcut`:
|
||||
```csharp
|
||||
_store.Remove(payload.SourceSlot);
|
||||
_sendRemove?.Invoke((uint)payload.SourceSlot);
|
||||
Populate();
|
||||
```
|
||||
- `HandleDropRelease(targetList, targetCell, payload)` — retail reorder branch (deep-dive §5):
|
||||
```csharp
|
||||
int target = targetCell.SlotIndex;
|
||||
uint evicted = _store.Get(target); // RemoveShortcutInSlotNum
|
||||
if (evicted != 0) { _store.Remove(target); _sendRemove?.Invoke((uint)target); }
|
||||
_store.Set(target, payload.ObjId); _sendAdd?.Invoke((uint)target, payload.ObjId); // AddShortcut
|
||||
if (evicted != 0 && evicted != payload.ObjId && _store.IsEmpty(payload.SourceSlot))
|
||||
{ // bump displaced → vacated source slot
|
||||
_store.Set(payload.SourceSlot, evicted);
|
||||
_sendAdd?.Invoke((uint)payload.SourceSlot, evicted);
|
||||
}
|
||||
Populate();
|
||||
```
|
||||
- `OnDragOver` unchanged from B.1 (accept any real item → green-cross overlay).
|
||||
|
||||
---
|
||||
|
||||
## 5. Data flow — frame by frame (reorder slot 3 → occupied slot 5)
|
||||
|
||||
1. Press+move on slot 3 (objId A). `BeginDrag`: snapshot ghost = A's icon; fire `DragBegin`.
|
||||
2. Cell 3 → `OnDragLift`: `_store.Remove(3)` + `SendRemoveShortcut(3)` (0x019D) + `Populate()` →
|
||||
slot 3 shows empty. Ghost (A's full icon) follows the cursor.
|
||||
3. Drag over slot 5 (objId B) → green-cross accept overlay on slot 5.
|
||||
4. Release on slot 5 → `FinishDrag` delivers `DropReleased` to cell 5 → `HandleDropRelease`:
|
||||
`evicted=B`; `Remove(5)`+`SendRemoveShortcut(5)`; `Set(5,A)`+`SendAddShortcut(5,A)`; source slot 3
|
||||
is empty → `Set(3,B)`+`SendAddShortcut(3,B)`; `Populate()` → slot 5 = A, slot 3 = B (swapped).
|
||||
5. (Off-bar variant) release over the 3D world → no `DropReleased` → A stays removed → slot 3 empty,
|
||||
A gone from the bar (server already told at step 2).
|
||||
|
||||
---
|
||||
|
||||
## 6. Edge cases
|
||||
|
||||
- **Drop on self (slot 3 → slot 3):** at lift slot 3 emptied; at drop `evicted=0`, `Set(3,A)` +
|
||||
`SendAddShortcut(3,A)` → A back at 3. Net no-op (2 wire msgs — retail does the same).
|
||||
- **Drop on a non-item element** (chrome / another window): `FinishDrag` delivers `DropReleased` to
|
||||
it; its `OnEvent` doesn't handle it → no place → A stays removed (off-bar). Correct.
|
||||
- **Ghost lifetime:** snapshotted at `BeginDrag`, cleared in `FinishDrag` — independent of the source
|
||||
cell emptying. A cancelled drag (mouse-up off-bar) still clears `_dragGhost`.
|
||||
- **Empty-slot lift:** an empty slot is not a drag source (`IsDragSource => ItemId != 0` from B.1) →
|
||||
no drag arms → no lift. (And empty slots still move the window — IA-12, from B.1.)
|
||||
- **Spell shortcuts in the loaded list** (`ObjectGuid == 0`, a spell): `ShortcutStore.Load` skips
|
||||
them (item-only this stream); they neither render nor drag. Forward-compat: the store could hold
|
||||
spell entries later.
|
||||
|
||||
---
|
||||
|
||||
## 7. Divergence register (update in the implementation commits)
|
||||
|
||||
- **AP-47 (amend):** the ghost is now **full opacity** (retail m_dragIcon) — retire the
|
||||
`GhostAlpha=0.6` reduced-alpha approximation. The row's REMAINING approximation: the ghost reuses
|
||||
the full composited `m_pIcon` (which includes the type-default underlay) rather than retail's
|
||||
dedicated underlay-less `m_pDragIcon`. Reword AP-47 to drop the alpha claim, keep the underlay note.
|
||||
- **TS-33 (retire):** the toolbar `HandleDropRelease` logging stub is replaced by the real
|
||||
store-mutation + `AddShortcut`/`RemoveShortcut` wire. Delete the row.
|
||||
- **No new row** for remove-on-lift / off-bar-remove — it is the faithful retail mechanism (it
|
||||
*reduces* divergence). The store being client-authoritative post-login matches retail's
|
||||
`ShortCutManager` (client owns the array; server is notified via the wire).
|
||||
|
||||
---
|
||||
|
||||
## 8. Testing (conformance)
|
||||
|
||||
Core tests (`tests/AcDream.Core.Tests/`):
|
||||
1. `ShortcutStore`: `Load` maps Index→ObjId (skips ObjectGuid==0 + out-of-range); `Set`/`Remove`/
|
||||
`Get`/`IsEmpty`; bounds-safe.
|
||||
2. `BuildAddShortcut`: 24-byte body, envelope+seq+`0x019C`+index+objectGuid+spellId(u16)+layer(u16) at
|
||||
the right offsets (golden bytes). `BuildRemoveShortcut`: 16 bytes, index at +12.
|
||||
|
||||
App tests (`tests/AcDream.App.Tests/`):
|
||||
3. Spine: `BeginDrag` snapshots the ghost so it survives the source cell being cleared mid-drag (clear
|
||||
the source cell after begin; assert the drawn ghost source is still the snapshot — assert via a
|
||||
testable `UiRoot` accessor for `_dragGhost`).
|
||||
4. Spine: `FinishDrag` over null delivers NO `DropReleased` (a spy cell/handler isn't called); over a
|
||||
real cell, delivers it (drop the Data0 gate — drop-on-self now dispatches).
|
||||
5. `ToolbarController.OnDragLift`: removes the source slot from the store, invokes `sendRemove(src)`,
|
||||
and the source cell empties after `Populate`.
|
||||
6. `ToolbarController.HandleDropRelease` reorder onto an OCCUPIED target: store ends with dragged@target
|
||||
+ displaced@source; `sendRemove`+`sendAdd` called with the retail sequence/args. Onto an EMPTY
|
||||
target: dragged@target, source stays empty, displaced-branch not taken. Drop-on-self: re-adds to
|
||||
source.
|
||||
7. Update the B.1 tests changed by §4.2/§4.3: `DropReleased_notAccepted_skipsDispatch` →
|
||||
`DropReleased_alwaysDispatchesToHandler` (the cell dispatches whenever it receives a DropReleased);
|
||||
the full-chain drag tests still pass (a completed reorder fires `HandleDropRelease`, not `Clicked`).
|
||||
|
||||
---
|
||||
|
||||
## 9. Acceptance criteria
|
||||
|
||||
- [ ] `dotnet build` + `dotnet test` green (Core + App).
|
||||
- [ ] AP-47 reworded (no alpha); TS-33 deleted; wire builder renamed; no stray old-signature callers.
|
||||
- [ ] `UiRoot`/`UiElement`/`UiItemSlot` remain item-agnostic (the lift hook is generic; only
|
||||
`ToolbarController` knows shortcuts).
|
||||
- [ ] **Visual (user), `ACDREAM_RETAIL_UI=1`, in-world:** lift a hotbar item → **slot empties
|
||||
immediately**, **full-opacity** icon follows the cursor with a **green cross** over hovered
|
||||
slots; drop on another slot → it **moves there** (occupant bumps to the source slot); drop
|
||||
off-bar → it's **removed**; a single click still **uses** the item. Changes **persist after
|
||||
relog** (the wire reached ACE).
|
||||
- [ ] Memory crib + roadmap/ISSUES updated; #141's note refreshed if relevant.
|
||||
|
||||
---
|
||||
|
||||
## 10. Subagent task slices
|
||||
|
||||
1. **Core: `ShortcutStore` + wire** — `ShortcutStore.cs` + `BuildAddShortcut` rename +
|
||||
`WorldSession.SendAddShortcut`/`SendRemoveShortcut` + Core tests (§8.1-2).
|
||||
2. **Spine extensions** — `IItemListDragHandler.OnDragLift`; `UiItemSlot` DragBegin→lift + DropReleased
|
||||
ungate; `UiRoot` ghost snapshot + full opacity + `FinishDrag` deliver-on-hit-only; update the B.1
|
||||
tests (§8.3-4, §8.7).
|
||||
3. **ToolbarController handler** — `_store` + `Load` + `Populate`-from-store; green-cross sprite;
|
||||
`OnDragLift`/`HandleDropRelease`; wire `Action`s; GameWindow injects the session sends; controller
|
||||
tests (§8.5-6). Update AP-47, delete TS-33.
|
||||
|
||||
(1 and 2 are independent; 3 depends on both.)
|
||||
|
|
@ -2018,7 +2018,9 @@ public sealed class GameWindow : IDisposable
|
|||
combatState: Combat,
|
||||
peaceDigits: toolbarPeaceDigits,
|
||||
warDigits: toolbarWarDigits,
|
||||
emptyDigits: toolbarEmptyDigits);
|
||||
emptyDigits: toolbarEmptyDigits,
|
||||
sendAddShortcut: (i, g) => _liveSession?.SendAddShortcut(i, g),
|
||||
sendRemoveShortcut: i => _liveSession?.SendRemoveShortcut(i));
|
||||
|
||||
// Phase D.5.3a — selected-object strip (name, overlay state, health meter).
|
||||
// Analogue of retail gmToolbarUI::HandleSelectionChanged
|
||||
|
|
@ -2042,7 +2044,7 @@ public sealed class GameWindow : IDisposable
|
|||
// thickness on every side, giving an outer window of 310×132.
|
||||
const int toolbarBorder = AcDream.App.UI.RetailChromeSprites.Border;
|
||||
float toolbarContentW = 300f, toolbarContentH = toolbarRoot.Height;
|
||||
var toolbarFrame = new AcDream.App.UI.UiNineSlicePanel(ResolveChrome)
|
||||
var toolbarFrame = new AcDream.App.UI.UiCollapsibleFrame(ResolveChrome)
|
||||
{
|
||||
Left = 10, Top = 300,
|
||||
Width = toolbarContentW + 2 * toolbarBorder,
|
||||
|
|
@ -2055,14 +2057,59 @@ public sealed class GameWindow : IDisposable
|
|||
toolbarRoot.Top = toolbarBorder;
|
||||
toolbarRoot.Width = toolbarContentW;
|
||||
toolbarRoot.Height = toolbarContentH;
|
||||
// Anchor content to all four edges so it reflows if the frame is resized.
|
||||
// Anchor content to Left|Top|Right only — drop Bottom so the dat content
|
||||
// keeps its full height when the frame collapses; row 2 hides via Visible,
|
||||
// not reflow. (Width is fixed so the horizontal anchors are inert but harmless.)
|
||||
toolbarRoot.Anchors = AcDream.App.UI.AnchorEdges.Left | AcDream.App.UI.AnchorEdges.Top
|
||||
| AcDream.App.UI.AnchorEdges.Right | AcDream.App.UI.AnchorEdges.Bottom;
|
||||
// The frame is the draggable window; the content itself is not.
|
||||
toolbarRoot.ClickThrough = false;
|
||||
| AcDream.App.UI.AnchorEdges.Right;
|
||||
// The frame is the draggable window; the content itself is not. ClickThrough so the
|
||||
// content panel never CLAIMS a hit — its behavioral children (slots, indicators) are
|
||||
// hit children-first, and its empty areas fall through to the frame (move). Critically,
|
||||
// when collapsed the row-2 band is hidden, so below the collapsed frame the content has
|
||||
// no visible/hit children and ClickThrough lets clicks fall through (no phantom
|
||||
// window-drag from where row 2 used to be). Same pattern as the chat content panel.
|
||||
toolbarRoot.ClickThrough = true;
|
||||
toolbarRoot.Draggable = false;
|
||||
toolbarRoot.Resizable = false;
|
||||
toolbarFrame.AddChild(toolbarRoot);
|
||||
|
||||
// Collapse-to-one-row: the frame's bottom edge snaps between a one-row (row 2 hidden)
|
||||
// and two-row height. CollapsedHeight is computed from the layout (just above row 2),
|
||||
// so there's no magic constant. Bottom-edge only; default expanded.
|
||||
// The full row-2 band (all at content-y 90, toolbar dump 0x21000016): a 6px left
|
||||
// edge-piece (0x100006B6) + the 9 slots (0x100006B7..BF) + an 8px right edge-piece
|
||||
// (0x100006C0). Hide ALL 11 when collapsed — hiding only the 9 slots leaves the two
|
||||
// edge-pieces drawing as black pillars below the bar.
|
||||
uint[] row2Ids =
|
||||
{
|
||||
0x100006B6u,
|
||||
0x100006B7u, 0x100006B8u, 0x100006B9u, 0x100006BAu, 0x100006BBu,
|
||||
0x100006BCu, 0x100006BDu, 0x100006BEu, 0x100006BFu,
|
||||
0x100006C0u,
|
||||
};
|
||||
var toolbarRow2 = new System.Collections.Generic.List<AcDream.App.UI.UiElement>();
|
||||
float minRow2Top = float.MaxValue;
|
||||
foreach (var id in row2Ids)
|
||||
if (toolbarLayout.FindElement(id) is { } e2)
|
||||
{
|
||||
toolbarRow2.Add(e2);
|
||||
if (e2.Top < minRow2Top) minRow2Top = e2.Top;
|
||||
}
|
||||
if (toolbarRow2.Count > 0)
|
||||
{
|
||||
float expandedH = toolbarContentH + 2 * toolbarBorder; // today's full height
|
||||
float collapsedH = minRow2Top + 2 * toolbarBorder; // just above row 2
|
||||
toolbarFrame.CollapsedHeight = collapsedH;
|
||||
toolbarFrame.ExpandedHeight = expandedH;
|
||||
toolbarFrame.SecondRow = toolbarRow2;
|
||||
toolbarFrame.Resizable = true;
|
||||
toolbarFrame.ResizableEdges = AcDream.App.UI.ResizeEdges.Bottom; // bottom edge only
|
||||
toolbarFrame.MinHeight = collapsedH;
|
||||
toolbarFrame.MaxHeight = expandedH;
|
||||
// Height stays expandedH (already set at construction) = default expanded.
|
||||
Console.WriteLine($"[D.2b] toolbar collapse: collapsed={collapsedH:0} expanded={expandedH:0} (row2Top={minRow2Top:0}).");
|
||||
}
|
||||
|
||||
_uiHost.Root.AddChild(toolbarFrame);
|
||||
|
||||
Console.WriteLine("[D.5.1] retail toolbar window from LayoutDesc importer (0x21000016).");
|
||||
|
|
|
|||
25
src/AcDream.App/UI/IItemListDragHandler.cs
Normal file
25
src/AcDream.App/UI/IItemListDragHandler.cs
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
namespace AcDream.App.UI;
|
||||
|
||||
/// <summary>
|
||||
/// A panel controller implements this and registers itself on each of its
|
||||
/// <see cref="UiItemList"/>s. Port of retail's <c>m_dragHandler</c> vtable
|
||||
/// (<c>RegisterItemListDragHandler</c>, decomp 230461; confirmed acclient
|
||||
/// 0x004a539e + the gmToolbarUI block 0x004bdd89).
|
||||
/// <para><see cref="OnDragOver"/> decides the accept/reject OVERLAY only (advisory).
|
||||
/// <see cref="HandleDropRelease"/> is authoritative — it performs the action, or
|
||||
/// no-ops to reject.</para>
|
||||
/// </summary>
|
||||
public interface IItemListDragHandler
|
||||
{
|
||||
/// <summary>The drag STARTED from a cell in this list — retail's RecvNotice_ItemListBeginDrag
|
||||
/// → RemoveShortcut (decomp 0x004bd930/0x004bd450): the handler removes the lifted item from its
|
||||
/// model + wire so the source slot empties immediately. The item is "in hand" until
|
||||
/// HandleDropRelease (place) or the drag ends off-target (stays removed). No restore on cancel.</summary>
|
||||
void OnDragLift(UiItemList sourceList, UiItemSlot sourceCell, ItemDragPayload payload);
|
||||
|
||||
/// <summary>True ⇒ the target cell shows the accept (green) frame; false ⇒ reject (red).</summary>
|
||||
bool OnDragOver(UiItemList targetList, UiItemSlot targetCell, ItemDragPayload payload);
|
||||
|
||||
/// <summary>Perform the drop (issue the per-panel wire action), or no-op to reject.</summary>
|
||||
void HandleDropRelease(UiItemList targetList, UiItemSlot targetCell, ItemDragPayload payload);
|
||||
}
|
||||
24
src/AcDream.App/UI/ItemDragPayload.cs
Normal file
24
src/AcDream.App/UI/ItemDragPayload.cs
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
namespace AcDream.App.UI;
|
||||
|
||||
/// <summary>
|
||||
/// Where a dragged item came from — the retail <c>InqDropIconInfo</c> flag
|
||||
/// distinction (<c>flags & 0xE == 0</c> fresh-from-inventory vs
|
||||
/// <c>flags & 4</c> within-list reorder) expressed as a typed enum. The drop
|
||||
/// handler maps SourceKind + target back to the fresh-vs-reorder decision.
|
||||
/// Decomp anchors: gmToolbarUI 0x004bd162 / 0x004bd1af; InqDropIconInfo 230533.
|
||||
/// </summary>
|
||||
public enum ItemDragSource { Inventory, ShortcutBar, Equipment, Ground }
|
||||
|
||||
/// <summary>
|
||||
/// Snapshot of a drag-in-progress, taken at drag-begin (so a server move arriving
|
||||
/// mid-drag can't mutate it under us). Port of retail's <c>m_dragElement</c> +
|
||||
/// <c>InqDropIconInfo</c> out-params (objId/container/flags, decomp 230533).
|
||||
/// <para><c>SourceContainer</c> is intentionally NOT stored: the handler resolves the
|
||||
/// LIVE container via <c>ClientObjectTable.Get(ObjId).ContainerId</c> at drop — the
|
||||
/// same container id retail reads off the dragged element, single source of truth.</para>
|
||||
/// </summary>
|
||||
public sealed record ItemDragPayload(
|
||||
uint ObjId, // dragged weenie guid (retail itemID, +0x5FC)
|
||||
ItemDragSource SourceKind, // what kind of slot it left
|
||||
int SourceSlot, // the source cell's SlotIndex (retail m_lastShortcutNumDragged)
|
||||
UiItemSlot SourceCell); // back-ref: reorder-restore / clear source state / ghost
|
||||
|
|
@ -23,7 +23,7 @@ namespace AcDream.App.UI.Layout;
|
|||
/// <c>CreateObject</c> resolves a formerly-unknown guid.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public sealed class ToolbarController
|
||||
public sealed class ToolbarController : IItemListDragHandler
|
||||
{
|
||||
// Slot element ids in slot-index order (toolbar LayoutDesc 0x21000016, pre-dump).
|
||||
// Row 1 = slots 0-8 (0x100001A7..0x100001AF), Row 2 = slots 9-17 (0x100006B7..0x100006BF).
|
||||
|
|
@ -60,6 +60,10 @@ public sealed class ToolbarController
|
|||
private readonly Func<IReadOnlyList<PlayerDescriptionParser.ShortcutEntry>> _shortcuts;
|
||||
private readonly Func<ItemType, uint, uint, uint, uint, uint> _iconIds; // (itemType, icon, underlay, overlay, effects) → GL tex
|
||||
private readonly Action<uint> _useItem; // guid → fire UseObject
|
||||
private readonly AcDream.Core.Items.ShortcutStore _store = new();
|
||||
private bool _storeLoaded;
|
||||
private readonly Action<uint, uint>? _sendAddShortcut; // (index, objectGuid)
|
||||
private readonly Action<uint>? _sendRemoveShortcut; // (index)
|
||||
|
||||
// Digit sprite DID arrays for slot labels (top row, numbers 1-9).
|
||||
// Read from LayoutDesc 0x21000037, element 0x1000034A under composite 0x10000346.
|
||||
|
|
@ -82,7 +86,9 @@ public sealed class ToolbarController
|
|||
CombatState? combatState,
|
||||
uint[]? peaceDigits,
|
||||
uint[]? warDigits,
|
||||
uint[]? emptyDigits)
|
||||
uint[]? emptyDigits,
|
||||
Action<uint, uint>? sendAddShortcut = null,
|
||||
Action<uint>? sendRemoveShortcut = null)
|
||||
{
|
||||
_repo = repo;
|
||||
_shortcuts = shortcuts;
|
||||
|
|
@ -91,12 +97,23 @@ public sealed class ToolbarController
|
|||
_peaceDigits = peaceDigits;
|
||||
_warDigits = warDigits;
|
||||
_emptyDigits = emptyDigits;
|
||||
_sendAddShortcut = sendAddShortcut;
|
||||
_sendRemoveShortcut = sendRemoveShortcut;
|
||||
|
||||
for (int i = 0; i < SlotIds.Length; i++)
|
||||
{
|
||||
_slots[i] = layout.FindElement(SlotIds[i]) as UiItemList;
|
||||
if (_slots[i] is { } list)
|
||||
{
|
||||
WireClick(list);
|
||||
// B.1 drag-drop spine: this controller is the drop handler for every
|
||||
// toolbar slot list; each cell knows its slot index + that it's a
|
||||
// shortcut-bar source (retail UIElement_ItemList::RegisterItemListDragHandler).
|
||||
list.RegisterDragHandler(this);
|
||||
list.Cell.SlotIndex = i;
|
||||
list.Cell.SourceKind = ItemDragSource.ShortcutBar;
|
||||
list.Cell.DragAcceptSprite = 0x060011FAu; // green cross (toolbar), not the ring 0x060011F9 (inventory)
|
||||
}
|
||||
}
|
||||
|
||||
// Cache the four mutually-exclusive combat-mode indicator elements.
|
||||
|
|
@ -125,11 +142,20 @@ public sealed class ToolbarController
|
|||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns true if <paramref name="guid"/> is one of the currently-active shortcut guids.
|
||||
/// Returns true if <paramref name="guid"/> is one of the currently-active shortcut guids
|
||||
/// (i.e., present in the live <see cref="AcDream.Core.Items.ShortcutStore"/>).
|
||||
/// Used to gate repo-event subscriptions so we don't re-populate on every creature spawn.
|
||||
/// Falls back to scanning <c>_shortcuts()</c> before the store is loaded (pre-PD window).
|
||||
/// </summary>
|
||||
private bool IsShortcutGuid(uint guid)
|
||||
{
|
||||
if (_storeLoaded)
|
||||
{
|
||||
for (int s = 0; s < AcDream.Core.Items.ShortcutStore.SlotCount; s++)
|
||||
if (_store.Get(s) == guid) return true;
|
||||
return false;
|
||||
}
|
||||
// Store not yet loaded — fall back to the shortcuts provider (pre-PD window).
|
||||
foreach (var sc in _shortcuts())
|
||||
if (sc.ObjectGuid == guid) return true;
|
||||
return false;
|
||||
|
|
@ -174,10 +200,13 @@ public sealed class ToolbarController
|
|||
CombatState? combatState = null,
|
||||
uint[]? peaceDigits = null,
|
||||
uint[]? warDigits = null,
|
||||
uint[]? emptyDigits = null)
|
||||
uint[]? emptyDigits = null,
|
||||
Action<uint, uint>? sendAddShortcut = null,
|
||||
Action<uint>? sendRemoveShortcut = null)
|
||||
{
|
||||
var c = new ToolbarController(layout, repo, shortcuts, iconIds, useItem, combatState,
|
||||
peaceDigits, warDigits, emptyDigits);
|
||||
peaceDigits, warDigits, emptyDigits,
|
||||
sendAddShortcut, sendRemoveShortcut);
|
||||
c.Populate();
|
||||
return c;
|
||||
}
|
||||
|
|
@ -188,24 +217,31 @@ public sealed class ToolbarController
|
|||
/// Entries whose item is not yet in the repo are silently skipped here; the
|
||||
/// <c>ObjectAdded</c> event re-fires this method when the item arrives
|
||||
/// (matching retail's <c>SetDelayedShortcutNum</c> deferred-rebind path).
|
||||
/// As of B.2: the <see cref="AcDream.Core.Items.ShortcutStore"/> is the
|
||||
/// authoritative in-session slot map; <c>_shortcuts()</c> seeds it on first call.
|
||||
/// </summary>
|
||||
public void Populate()
|
||||
{
|
||||
// Clear all slot cells first (flush).
|
||||
// Lazy-load the store from the login shortcut list the first time it's present (PD arrives
|
||||
// after Bind); thereafter the store is authoritative and drag ops mutate it directly.
|
||||
if (!_storeLoaded && _shortcuts().Count > 0)
|
||||
{
|
||||
_store.Load(System.Linq.Enumerable.Select(_shortcuts(), e => ((int)e.Index, e.ObjectGuid)));
|
||||
_storeLoaded = true;
|
||||
}
|
||||
|
||||
foreach (var list in _slots) list?.Cell.Clear();
|
||||
|
||||
foreach (var sc in _shortcuts())
|
||||
for (int slot = 0; slot < _slots.Length; slot++)
|
||||
{
|
||||
if (sc.ObjectGuid == 0) continue; // spell-only shortcut — inventory phase
|
||||
if (sc.Index >= (uint)_slots.Length) continue;
|
||||
var list = _slots[(int)sc.Index];
|
||||
uint guid = _store.Get(slot);
|
||||
if (guid == 0) continue;
|
||||
var list = _slots[slot];
|
||||
if (list is null) continue;
|
||||
|
||||
var item = _repo.Get(sc.ObjectGuid);
|
||||
if (item is null) continue; // deferred: ObjectAdded will re-call Populate
|
||||
|
||||
var item = _repo.Get(guid);
|
||||
if (item is null) continue; // deferred: ObjectAdded re-calls Populate
|
||||
uint tex = _iconIds(item.Type, item.IconId, item.IconUnderlayId, item.IconOverlayId, item.Effects);
|
||||
list.Cell.SetItem(sc.ObjectGuid, tex);
|
||||
list.Cell.SetItem(guid, tex);
|
||||
}
|
||||
|
||||
// Re-stamp slot number labels after any item change.
|
||||
|
|
@ -287,4 +323,46 @@ public sealed class ToolbarController
|
|||
_useItem(list.Cell.ItemId);
|
||||
};
|
||||
}
|
||||
|
||||
// ── IItemListDragHandler (B.2 live handler) ──────────────────────────────
|
||||
// Retail: gmToolbarUI is the m_dragHandler for every shortcut slot list.
|
||||
// Retail model (remove-on-lift / place-on-drop / no-restore):
|
||||
// lift → RemoveShortcut (0x019D) + store.Remove (slot empties immediately)
|
||||
// drop → AddShortcut (0x019C) + optional swap of evicted item into source
|
||||
// off-bar release → the lift's removal stands (no restore)
|
||||
// Retail ref: gmToolbarUI::HandleDropRelease acclient_2013_pseudo_c.txt:197971
|
||||
|
||||
/// <inheritdoc/>
|
||||
public void OnDragLift(UiItemList sourceList, UiItemSlot sourceCell, ItemDragPayload payload)
|
||||
{
|
||||
// Retail RecvNotice_ItemListBeginDrag → RemoveShortcut (0x004bd930/0x004bd450): the lifted
|
||||
// shortcut leaves the bar (+ wire) the instant the drag starts; it's re-placed only on a
|
||||
// drop onto a slot. Off-bar release leaves it removed.
|
||||
_store.Remove(payload.SourceSlot);
|
||||
_sendRemoveShortcut?.Invoke((uint)payload.SourceSlot);
|
||||
Populate();
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public bool OnDragOver(UiItemList targetList, UiItemSlot targetCell, ItemDragPayload payload)
|
||||
=> payload.ObjId != 0;
|
||||
|
||||
/// <inheritdoc/>
|
||||
public void HandleDropRelease(UiItemList targetList, UiItemSlot targetCell, ItemDragPayload payload)
|
||||
{
|
||||
// Retail gmToolbarUI::HandleDropRelease (0x004be7c0) within-bar reorder branch (deep-dive §5):
|
||||
// evict the target's occupant, place the dragged item there, and bump the evicted item into
|
||||
// the (now-vacated) source slot if it's free.
|
||||
int target = targetCell.SlotIndex;
|
||||
uint evicted = _store.Get(target); // RemoveShortCutInSlotNum(target)
|
||||
if (evicted != 0) { _store.Remove(target); _sendRemoveShortcut?.Invoke((uint)target); }
|
||||
_store.Set(target, payload.ObjId); // AddShortcut(dragged, target)
|
||||
_sendAddShortcut?.Invoke((uint)target, payload.ObjId);
|
||||
if (evicted != 0 && evicted != payload.ObjId && _store.IsEmpty(payload.SourceSlot))
|
||||
{ // displaced → vacated source slot
|
||||
_store.Set(payload.SourceSlot, evicted);
|
||||
_sendAddShortcut?.Invoke((uint)payload.SourceSlot, evicted);
|
||||
}
|
||||
Populate();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
36
src/AcDream.App/UI/UiCollapsibleFrame.cs
Normal file
36
src/AcDream.App/UI/UiCollapsibleFrame.cs
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace AcDream.App.UI;
|
||||
|
||||
/// <summary>
|
||||
/// A toolbar-frame variant that snaps its height between two stops — collapsed (row 2 hidden) and
|
||||
/// expanded (row 2 shown) — and toggles a set of "second-row" elements to match. Resized via the
|
||||
/// bottom edge (the mount sets <see cref="UiElement.ResizableEdges"/> = Bottom); each tick it resolves
|
||||
/// the dragged height to the nearer stop so the frame always rests collapsed or expanded — never a
|
||||
/// half-row. Toolkit UX (keystone.dll has no decomp; the dat stacks both rows always) — see IA-17.
|
||||
/// </summary>
|
||||
public sealed class UiCollapsibleFrame : UiNineSlicePanel
|
||||
{
|
||||
public UiCollapsibleFrame(Func<uint, (uint, int, int)> resolve) : base(resolve) { }
|
||||
|
||||
public float CollapsedHeight { get; set; }
|
||||
public float ExpandedHeight { get; set; }
|
||||
/// <summary>Elements shown only when expanded (the row-2 slot lists). Hidden when collapsed.</summary>
|
||||
public IReadOnlyList<UiElement> SecondRow { get; set; } = Array.Empty<UiElement>();
|
||||
|
||||
/// <summary>True when the frame is at (or nearer) the expanded stop.</summary>
|
||||
public bool IsExpanded => Height >= (CollapsedHeight + ExpandedHeight) * 0.5f;
|
||||
|
||||
protected override void OnTick(double deltaSeconds)
|
||||
{
|
||||
base.OnTick(deltaSeconds);
|
||||
if (ExpandedHeight <= CollapsedHeight) return; // not configured yet — no snap
|
||||
bool expanded = IsExpanded;
|
||||
Height = expanded ? ExpandedHeight : CollapsedHeight; // snap the dragged height to a stop
|
||||
for (int i = 0; i < SecondRow.Count; i++) SecondRow[i].Visible = expanded;
|
||||
}
|
||||
|
||||
/// <summary>Test hook — OnTick is protected. Drives one snap+visibility reconcile.</summary>
|
||||
internal void TickForTest(double dt) => OnTick(dt);
|
||||
}
|
||||
|
|
@ -113,15 +113,31 @@ public abstract class UiElement
|
|||
/// drag-drop candidacy is suppressed in favour of the element's own handling.</summary>
|
||||
public bool CapturesPointerDrag { get; set; }
|
||||
|
||||
/// <summary>If true, a left-press-and-move on this element starts a DRAG-DROP
|
||||
/// (<see cref="UiRoot"/> promotes to BeginDrag) rather than moving a Draggable
|
||||
/// ancestor window — so an item cell inside the toolbar frame drags the item, not
|
||||
/// the window. Distinct from <see cref="CapturesPointerDrag"/> (a self-driven
|
||||
/// interior drag like text selection, which does NOT promote to BeginDrag). Default
|
||||
/// false; overridden by drag sources (e.g. an occupied <see cref="UiItemSlot"/>).</summary>
|
||||
public virtual bool IsDragSource => false;
|
||||
|
||||
/// <summary>Minimum size enforced while resizing.</summary>
|
||||
public float MinWidth { get; set; } = 40f;
|
||||
public float MinHeight { get; set; } = 40f;
|
||||
|
||||
/// <summary>Maximum height enforced while resizing (default unbounded). Pairs with MinHeight.</summary>
|
||||
public float MaxHeight { get; set; } = float.MaxValue;
|
||||
|
||||
/// <summary>Allow horizontal (width) resize. Ignored unless <see cref="Resizable"/>.</summary>
|
||||
public bool ResizeX { get; set; } = true;
|
||||
/// <summary>Allow vertical (height) resize. Ignored unless <see cref="Resizable"/>.</summary>
|
||||
public bool ResizeY { get; set; } = true;
|
||||
|
||||
/// <summary>Which edges may start a resize, beyond the ResizeX/ResizeY axis gates. Default: all.
|
||||
/// Set to e.g. <see cref="ResizeEdges.Bottom"/> to allow only a bottom-edge drag (the collapse toolbar).</summary>
|
||||
public ResizeEdges ResizableEdges { get; set; } =
|
||||
ResizeEdges.Left | ResizeEdges.Right | ResizeEdges.Top | ResizeEdges.Bottom;
|
||||
|
||||
/// <summary>Edges this element anchors to in its parent. Default Left|Top
|
||||
/// (pinned top-left, fixed size — no reflow). Left|Right stretches width.</summary>
|
||||
public AnchorEdges Anchors { get; set; } = AnchorEdges.Left | AnchorEdges.Top;
|
||||
|
|
@ -203,6 +219,16 @@ public abstract class UiElement
|
|||
/// </summary>
|
||||
public virtual bool OnEvent(in UiEvent e) => false;
|
||||
|
||||
/// <summary>The data this element carries when a drag begins. <see cref="UiRoot"/>
|
||||
/// pulls this on drag-promote; a NULL return CANCELS the drag (retail:
|
||||
/// ItemList_BeginDrag only arms an occupied cell). Default null = not draggable.</summary>
|
||||
public virtual object? GetDragPayload() => null;
|
||||
|
||||
/// <summary>The texture <see cref="UiRoot"/> paints at the cursor while this element
|
||||
/// is the drag source: (GL handle, width, height). Null = no ghost. Keeps
|
||||
/// <see cref="UiRoot"/> item-agnostic. Retail analog: m_dragIcon (decomp 229738).</summary>
|
||||
public virtual (uint tex, int w, int h)? GetDragGhost() => null;
|
||||
|
||||
/// <summary>
|
||||
/// Tooltip text for this widget. Retail fires event 0x07 after
|
||||
/// ~1000ms hover, then queries the widget's virtual "GetString"
|
||||
|
|
|
|||
|
|
@ -24,6 +24,14 @@ public sealed class UiItemList : UiElement
|
|||
|
||||
public Func<uint, (uint tex, int w, int h)>? SpriteResolve { get; set; }
|
||||
|
||||
/// <summary>The drag handler this list routes drops to (a panel controller).
|
||||
/// Null = the list accepts no drops. Retail: UIElement_ItemList::m_dragHandler.</summary>
|
||||
public IItemListDragHandler? DragHandler { get; private set; }
|
||||
|
||||
/// <summary>Register the panel's drag handler on this list. Retail:
|
||||
/// UIElement_ItemList::RegisterItemListDragHandler (decomp 230461).</summary>
|
||||
public void RegisterDragHandler(IItemListDragHandler handler) => DragHandler = handler;
|
||||
|
||||
/// <summary>Convenience for single-cell slots (the toolbar): the first cell.
|
||||
/// Valid only while the list has at least one cell; after <see cref="Flush"/>
|
||||
/// (the inventory-phase rebuild path) the list is empty until <see cref="AddItem"/>
|
||||
|
|
|
|||
|
|
@ -21,6 +21,29 @@ public sealed class UiItemSlot : UiElement
|
|||
/// <summary>Pre-composited icon GL texture for the bound item (0 = none).</summary>
|
||||
public uint IconTexture { get; private set; }
|
||||
|
||||
/// <summary>This cell's own index within its panel (0..17 toolbar; container slot
|
||||
/// for inventory). Distinct from <see cref="ShortcutNum"/> (the 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.</summary>
|
||||
public int SlotIndex { get; set; } = -1;
|
||||
|
||||
/// <summary>What kind of slot this is, for the drag payload (retail InqDropIconInfo
|
||||
/// flags). Controller overrides; default Inventory.</summary>
|
||||
public ItemDragSource SourceKind { get; set; } = ItemDragSource.Inventory;
|
||||
|
||||
/// <summary>Drag-rollover accept frame (retail ItemSlot_DragOver_Accept 0x060011F9,
|
||||
/// state id 0x10000041). Configurable; guard id != 0 before resolving.</summary>
|
||||
public uint DragAcceptSprite { get; set; } = 0x060011F9u;
|
||||
/// <summary>Drag-rollover reject frame (retail ItemSlot_DragOver_Reject 0x060011F8,
|
||||
/// state id 0x10000040).</summary>
|
||||
public uint DragRejectSprite { get; set; } = 0x060011F8u;
|
||||
|
||||
/// <summary>Accept/reject overlay state while a drag hovers this cell.</summary>
|
||||
public enum DragAcceptState { None, Accept, Reject }
|
||||
private DragAcceptState _dragAccept = DragAcceptState.None;
|
||||
/// <summary>Current overlay state — internal so unit tests can assert it (InternalsVisibleTo).</summary>
|
||||
internal DragAcceptState DragAcceptVisual => _dragAccept;
|
||||
|
||||
/// <summary>Empty-slot sprite. Default = the generic toolbar empty-slot border
|
||||
/// 0x060074CF (uiitem template 0x21000037, state ItemSlot_Empty). Configurable so
|
||||
/// paperdoll equip slots can use their per-slot silhouettes later.</summary>
|
||||
|
|
@ -37,6 +60,30 @@ public sealed class UiItemSlot : UiElement
|
|||
|
||||
public void Clear() { ItemId = 0; IconTexture = 0; }
|
||||
|
||||
/// <inheritdoc/>
|
||||
public override object? GetDragPayload()
|
||||
=> ItemId != 0 ? new ItemDragPayload(ItemId, SourceKind, SlotIndex, this) : null;
|
||||
|
||||
/// <inheritdoc/>
|
||||
public override (uint tex, int w, int h)? GetDragGhost()
|
||||
=> ItemId != 0 && IconTexture != 0 ? (IconTexture, (int)Width, (int)Height) : null;
|
||||
|
||||
/// <summary>An OCCUPIED slot is a drag source — a press-and-move picks up the item
|
||||
/// rather than moving the toolbar window. An EMPTY slot is NOT a drag source, so a
|
||||
/// press-and-move there falls through to the IA-12 whole-window-drag, keeping the bar
|
||||
/// movable by its empty cells / chrome. Drives <see cref="UiRoot"/>'s mousedown
|
||||
/// window-vs-item disambiguation (retail moves the window via a dragbar, never cells;
|
||||
/// our whole-window-drag approximation reconciles by gating on occupancy).</summary>
|
||||
public override bool IsDragSource => ItemId != 0;
|
||||
|
||||
/// <summary>Walk up to the containing <see cref="UiItemList"/> (the drop handler owner).</summary>
|
||||
private UiItemList? FindList()
|
||||
{
|
||||
UiElement? e = Parent;
|
||||
while (e is not null) { if (e is UiItemList l) return l; e = e.Parent; }
|
||||
return null;
|
||||
}
|
||||
|
||||
// ── Shortcut number (slot label) ─────────────────────────────────────────
|
||||
// Port of UIElement_UIItem::SetShortcutNum (acclient_2013_pseudo_c.txt:229465).
|
||||
// Retail draws the digit on the cell's ShortcutNum sub-element, picking the
|
||||
|
|
@ -100,7 +147,40 @@ public sealed class UiItemSlot : UiElement
|
|||
/// <inheritdoc/>
|
||||
public override bool OnEvent(in UiEvent e)
|
||||
{
|
||||
if (e.Type == UiEventType.MouseDown) { Clicked?.Invoke(); return true; }
|
||||
switch (e.Type)
|
||||
{
|
||||
// Use fires on CLICK (mouse-up over the same cell), not MouseDown — so a
|
||||
// drag (press + >3px move) does NOT also use the item. UiRoot suppresses the
|
||||
// post-drag Click (UiRoot.cs FinishDrag returns before the Click emit).
|
||||
case UiEventType.MouseDown:
|
||||
return true; // consume the press; no use here
|
||||
case UiEventType.Click:
|
||||
Clicked?.Invoke();
|
||||
return true;
|
||||
|
||||
case UiEventType.DragBegin:
|
||||
// Notify the source list's handler so it can lift (remove + wire) — retail
|
||||
// RecvNotice_ItemListBeginDrag → RemoveShortcut. UiRoot snapshotted the ghost first.
|
||||
if (FindList() is { DragHandler: { } lh } liftList && e.Payload is ItemDragPayload lp)
|
||||
lh.OnDragLift(liftList, this, lp);
|
||||
return true;
|
||||
|
||||
case UiEventType.DragEnter: // pointer entered me mid-drag → ask the list's handler
|
||||
_dragAccept = (FindList() is { DragHandler: { } h } list
|
||||
&& e.Payload is ItemDragPayload p && h.OnDragOver(list, this, p))
|
||||
? DragAcceptState.Accept : DragAcceptState.Reject;
|
||||
return true;
|
||||
|
||||
case UiEventType.DragOver: // UiRoot fires this on LEAVE → neutral
|
||||
_dragAccept = DragAcceptState.None;
|
||||
return true;
|
||||
|
||||
case UiEventType.DropReleased:
|
||||
_dragAccept = DragAcceptState.None;
|
||||
if (FindList() is { DragHandler: { } dh } dl && e.Payload is ItemDragPayload dp)
|
||||
dh.HandleDropRelease(dl, this, dp);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
|
|
@ -139,5 +219,19 @@ public sealed class UiItemSlot : UiElement
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Drag-rollover accept/reject frame (retail SetDragAcceptState 0x10000041/40).
|
||||
// Guard id != 0 BEFORE resolving — resolve(0) returns the 1×1 magenta placeholder
|
||||
// with a non-zero GL handle (feedback_ui_resolve_zero_magenta).
|
||||
if (_dragAccept != DragAcceptState.None && SpriteResolve is not null)
|
||||
{
|
||||
uint id = _dragAccept == DragAcceptState.Accept ? DragAcceptSprite : DragRejectSprite;
|
||||
if (id != 0)
|
||||
{
|
||||
var (tex, _, _) = SpriteResolve(id);
|
||||
if (tex != 0)
|
||||
ctx.DrawSprite(tex, 0f, 0f, Width, Height, 0f, 0f, 1f, 1f, Vector4.One);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@ namespace AcDream.App.UI;
|
|||
/// the widget is testable without GL. In production:
|
||||
/// <c>id => { var t = cache.GetOrUploadRenderSurface(id, out var w, out var h); return (t, w, h); }</c>.
|
||||
/// </summary>
|
||||
public sealed class UiNineSlicePanel : UiPanel
|
||||
public class UiNineSlicePanel : UiPanel
|
||||
{
|
||||
/// <summary>A placed chrome piece: destination rect in local pixel space.</summary>
|
||||
public readonly record struct Rect(float X, float Y, float W, float H);
|
||||
|
|
|
|||
|
|
@ -71,6 +71,9 @@ public sealed class UiRoot : UiElement
|
|||
/// <summary>Current drag source (set between drag-begin and drop/cancel).</summary>
|
||||
public UiElement? DragSource { get; private set; }
|
||||
public object? DragPayload { get; private set; }
|
||||
private (uint tex, int w, int h)? _dragGhost;
|
||||
/// <summary>Snapshotted drag-ghost (tex,w,h), exposed for tests. See BeginDrag.</summary>
|
||||
internal (uint tex, int w, int h)? DragGhostForTest => _dragGhost;
|
||||
private UiElement? _lastDragHoverTarget;
|
||||
private int _pressX, _pressY;
|
||||
private bool _dragCandidate;
|
||||
|
|
@ -141,9 +144,23 @@ public sealed class UiRoot : UiElement
|
|||
// beats even rect backgrounds. Faithful to retail's root-level MakePopup.
|
||||
ctx.BeginOverlayLayer();
|
||||
DrawOverlays(ctx);
|
||||
DrawDragGhost(ctx);
|
||||
ctx.EndOverlayLayer();
|
||||
}
|
||||
|
||||
private const float GhostAlpha = 1.0f; // retail m_dragIcon is the full icon, no fade
|
||||
|
||||
/// <summary>Paint the drag ghost at the cursor. The texture comes from the snapshotted
|
||||
/// ghost captured in <see cref="BeginDrag"/> so UiRoot stays item-agnostic and the ghost
|
||||
/// survives the source cell emptying on lift; the ghost is NOT a tree element, so it
|
||||
/// never intercepts hit-tests.</summary>
|
||||
private void DrawDragGhost(UiRenderContext ctx)
|
||||
{
|
||||
if (_dragGhost is not { } g || g.tex == 0) return;
|
||||
ctx.DrawSprite(g.tex, MouseX - g.w / 2f, MouseY - g.h / 2f, g.w, g.h,
|
||||
0f, 0f, 1f, 1f, new Vector4(1f, 1f, 1f, GhostAlpha));
|
||||
}
|
||||
|
||||
// ── Input entry points (called from GameWindow's Silk.NET handlers) ──
|
||||
|
||||
public void OnMouseMove(int x, int y)
|
||||
|
|
@ -159,7 +176,8 @@ public sealed class UiRoot : UiElement
|
|||
var (nx, ny, nw, nh) = ResizeRect(
|
||||
_resizeStartX, _resizeStartY, _resizeStartW, _resizeStartH,
|
||||
_resizeEdges, x - _resizeMouseX, y - _resizeMouseY,
|
||||
_resizeTarget.MinWidth, _resizeTarget.MinHeight);
|
||||
_resizeTarget.MinWidth, _resizeTarget.MinHeight,
|
||||
float.MaxValue, _resizeTarget.MaxHeight);
|
||||
_resizeTarget.Left = nx; _resizeTarget.Top = ny;
|
||||
_resizeTarget.Width = nw; _resizeTarget.Height = nh;
|
||||
return;
|
||||
|
|
@ -185,7 +203,7 @@ public sealed class UiRoot : UiElement
|
|||
if (Math.Abs(x - _pressX) > DragDistanceThreshold
|
||||
|| Math.Abs(y - _pressY) > DragDistanceThreshold)
|
||||
{
|
||||
BeginDrag(Captured, payload: null);
|
||||
BeginDrag(Captured);
|
||||
}
|
||||
}
|
||||
if (DragSource is not null)
|
||||
|
|
@ -244,6 +262,17 @@ public sealed class UiRoot : UiElement
|
|||
_resizeMouseX = x; _resizeMouseY = y;
|
||||
_dragCandidate = false;
|
||||
}
|
||||
else if (target.IsDragSource)
|
||||
{
|
||||
// A drag SOURCE (e.g. an occupied item cell) inside a Draggable window
|
||||
// starts an item drag-drop, NOT a window move. UiRoot stays item-agnostic:
|
||||
// it only reads the IsDragSource flag (the cell decides occupancy). The
|
||||
// BeginDrag promotion happens on the >3px move (and cancels if the source's
|
||||
// GetDragPayload() returns null). Empty cells are NOT drag sources, so they
|
||||
// fall through to window.Draggable below (IA-12 whole-window-drag), keeping
|
||||
// the bar movable by its empty cells / chrome.
|
||||
_dragCandidate = true;
|
||||
}
|
||||
else if (target.CapturesPointerDrag)
|
||||
{
|
||||
// The pressed widget owns interior drags (e.g. text selection):
|
||||
|
|
@ -447,10 +476,13 @@ public sealed class UiRoot : UiElement
|
|||
|
||||
// ── Drag-drop (retail event chain 0x15 → 0x21 → 0x1C → 0x3E) ────────
|
||||
|
||||
private void BeginDrag(UiElement source, object? payload)
|
||||
private void BeginDrag(UiElement source)
|
||||
{
|
||||
var payload = source.GetDragPayload();
|
||||
if (payload is null) { _dragCandidate = false; return; }
|
||||
DragSource = source;
|
||||
DragPayload = payload;
|
||||
_dragGhost = source.GetDragGhost(); // snapshot NOW — the DragBegin handler may empty the source cell
|
||||
var e = new UiEvent(source.EventId, source, UiEventType.DragBegin, Payload: payload);
|
||||
source.OnEvent(in e);
|
||||
}
|
||||
|
|
@ -483,16 +515,18 @@ public sealed class UiRoot : UiElement
|
|||
private void FinishDrag(int x, int y)
|
||||
{
|
||||
var (t, lx, ly) = HitTestTopDown(x, y);
|
||||
var target = t ?? DragSource!;
|
||||
var accepted = t is not null && t != DragSource;
|
||||
var e = new UiEvent(DragSource!.EventId, target, UiEventType.DropReleased,
|
||||
Data0: accepted ? 1 : 0,
|
||||
Data1: (int)lx, Data2: (int)ly,
|
||||
Payload: DragPayload);
|
||||
target.OnEvent(in e);
|
||||
|
||||
if (t is not null)
|
||||
{
|
||||
// Dropped on a real element — deliver DropReleased; the hit cell's handler places.
|
||||
// A non-item target's OnEvent ignores it, so an off-bar drop leaves the lift's removal.
|
||||
var e = new UiEvent(DragSource!.EventId, t, UiEventType.DropReleased,
|
||||
Data1: (int)lx, Data2: (int)ly, Payload: DragPayload);
|
||||
t.OnEvent(in e);
|
||||
}
|
||||
// else: dropped on nothing — no drop fires; the lift (drag-begin removal) stands (retail).
|
||||
DragSource = null;
|
||||
DragPayload = null;
|
||||
_dragGhost = null;
|
||||
_lastDragHoverTarget = null;
|
||||
}
|
||||
|
||||
|
|
@ -593,21 +627,23 @@ public sealed class UiRoot : UiElement
|
|||
if (System.Math.Abs(y - b) <= grip) e |= ResizeEdges.Bottom;
|
||||
if (!w.ResizeX) e &= ~(ResizeEdges.Left | ResizeEdges.Right);
|
||||
if (!w.ResizeY) e &= ~(ResizeEdges.Top | ResizeEdges.Bottom);
|
||||
e &= w.ResizableEdges;
|
||||
return e;
|
||||
}
|
||||
|
||||
/// <summary>Compute a resized rect from a start rect + drag delta + which edges,
|
||||
/// clamping to (<paramref name="minW"/>,<paramref name="minH"/>). Left/Top edges
|
||||
/// move the origin so the opposite edge stays put.</summary>
|
||||
/// clamping to (<paramref name="minW"/>,<paramref name="minH"/>) and
|
||||
/// (<paramref name="maxW"/>,<paramref name="maxH"/>). Left/Top edges move the
|
||||
/// origin so the opposite edge stays put.</summary>
|
||||
public static (float x, float y, float w, float h) ResizeRect(
|
||||
float startX, float startY, float startW, float startH,
|
||||
ResizeEdges edges, float dx, float dy, float minW, float minH)
|
||||
ResizeEdges edges, float dx, float dy, float minW, float minH, float maxW, float maxH)
|
||||
{
|
||||
float x = startX, y = startY, w = startW, h = startH;
|
||||
if ((edges & ResizeEdges.Right) != 0) w = System.Math.Max(minW, startW + dx);
|
||||
if ((edges & ResizeEdges.Bottom) != 0) h = System.Math.Max(minH, startH + dy);
|
||||
if ((edges & ResizeEdges.Left) != 0) { float nw = System.Math.Max(minW, startW - dx); x = startX + (startW - nw); w = nw; }
|
||||
if ((edges & ResizeEdges.Top) != 0) { float nh = System.Math.Max(minH, startH - dy); y = startY + (startH - nh); h = nh; }
|
||||
if ((edges & ResizeEdges.Right) != 0) w = System.Math.Clamp(startW + dx, minW, maxW);
|
||||
if ((edges & ResizeEdges.Bottom) != 0) h = System.Math.Clamp(startH + dy, minH, maxH);
|
||||
if ((edges & ResizeEdges.Left) != 0) { float nw = System.Math.Clamp(startW - dx, minW, maxW); x = startX + (startW - nw); w = nw; }
|
||||
if ((edges & ResizeEdges.Top) != 0) { float nh = System.Math.Clamp(startH - dy, minH, maxH); y = startY + (startH - nh); h = nh; }
|
||||
return (x, y, w, h);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -95,17 +95,20 @@ public static class InventoryActions
|
|||
return body;
|
||||
}
|
||||
|
||||
/// <summary>Pin an item / spell to a quickbar slot.</summary>
|
||||
/// <summary>Pin an item/spell to a quickbar slot. ShortCutData = Index(u32), ObjectId(u32),
|
||||
/// SpellId(u16), Layer(u16) — CONFIRMED across ACE/Chorizite/holtburger (action-bar deep-dive
|
||||
/// §131-145). For an ITEM: objectGuid = item guid, spellId = layer = 0.</summary>
|
||||
public static byte[] BuildAddShortcut(
|
||||
uint seq, uint slotIndex, uint objectType, uint targetId)
|
||||
uint seq, uint index, uint objectGuid, ushort spellId, ushort layer)
|
||||
{
|
||||
byte[] body = new byte[24];
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(body, GameActionEnvelope);
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(4), seq);
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(8), AddShortcutOpcode);
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(12), slotIndex);
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(16), objectType);
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(20), targetId);
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(12), index);
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(16), objectGuid);
|
||||
BinaryPrimitives.WriteUInt16LittleEndian(body.AsSpan(20), spellId);
|
||||
BinaryPrimitives.WriteUInt16LittleEndian(body.AsSpan(22), layer);
|
||||
return body;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1140,6 +1140,22 @@ public sealed class WorldSession : IDisposable
|
|||
SendGameAction(body);
|
||||
}
|
||||
|
||||
/// <summary>Send AddShortcut (0x019C) — pin an item to toolbar slot <paramref name="index"/>.
|
||||
/// Retail: CM_Character::Event_AddShortCut. Mirrors the SendChangeCombatMode pattern.</summary>
|
||||
public void SendAddShortcut(uint index, uint objectGuid, ushort spellId = 0, ushort layer = 0)
|
||||
{
|
||||
uint seq = NextGameActionSequence();
|
||||
SendGameAction(InventoryActions.BuildAddShortcut(seq, index, objectGuid, spellId, layer));
|
||||
}
|
||||
|
||||
/// <summary>Send RemoveShortcut (0x019D) — clear toolbar slot <paramref name="index"/>.
|
||||
/// Retail: CM_Character::Event_RemoveShortCut.</summary>
|
||||
public void SendRemoveShortcut(uint index)
|
||||
{
|
||||
uint seq = NextGameActionSequence();
|
||||
SendGameAction(InventoryActions.BuildRemoveShortcut(seq, index));
|
||||
}
|
||||
|
||||
/// <summary>Send retail QueryHealth (0x01BF). Server replies UpdateHealth (0x01C0).</summary>
|
||||
/// <remarks>
|
||||
/// Retail anchor: <c>CM_Combat::Event_QueryHealth</c> / <c>gmToolbarUI::HandleSelectionChanged:198635</c>
|
||||
|
|
|
|||
34
src/AcDream.Core/Items/ShortcutStore.cs
Normal file
34
src/AcDream.Core/Items/ShortcutStore.cs
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace AcDream.Core.Items;
|
||||
|
||||
/// <summary>
|
||||
/// Mutable client-side model of the 18 toolbar shortcut slots — port of retail
|
||||
/// <c>ShortCutManager::shortCuts_[18]</c> (acclient.h:36492). Holds the bound object guid per
|
||||
/// slot (0 = empty). Loaded from the login shortcut list, then mutated by drag-drop (lift
|
||||
/// removes, drop places); the server is notified via AddShortcut/RemoveShortcut so the store
|
||||
/// stays the client's source of truth within a session (retail: client owns the array).
|
||||
/// Item shortcuts only — entries with ObjGuid 0 (spell-only) are skipped on Load. Pure model
|
||||
/// (no Core.Net dependency): callers project their wire entries to (slot, objGuid) pairs.
|
||||
/// </summary>
|
||||
public sealed class ShortcutStore
|
||||
{
|
||||
public const int SlotCount = 18;
|
||||
private readonly uint[] _objIds = new uint[SlotCount];
|
||||
|
||||
/// <summary>Replace all slots from a (slot, objectGuid) sequence (item entries only;
|
||||
/// ObjGuid 0 and out-of-range slots are skipped).</summary>
|
||||
public void Load(IEnumerable<(int Slot, uint ObjGuid)> entries)
|
||||
{
|
||||
Array.Clear(_objIds);
|
||||
foreach (var (slot, objGuid) in entries)
|
||||
if ((uint)slot < SlotCount && objGuid != 0) _objIds[slot] = objGuid;
|
||||
}
|
||||
|
||||
/// <summary>Bound object guid at <paramref name="slot"/>, or 0 (empty / out of range).</summary>
|
||||
public uint Get(int slot) => (uint)slot < SlotCount ? _objIds[slot] : 0u;
|
||||
public bool IsEmpty(int slot) => Get(slot) == 0u;
|
||||
public void Set(int slot, uint objId) { if ((uint)slot < SlotCount) _objIds[slot] = objId; }
|
||||
public void Remove(int slot) { if ((uint)slot < SlotCount) _objIds[slot] = 0u; }
|
||||
}
|
||||
294
tests/AcDream.App.Tests/UI/DragDropSpineTests.cs
Normal file
294
tests/AcDream.App.Tests/UI/DragDropSpineTests.cs
Normal file
|
|
@ -0,0 +1,294 @@
|
|||
using AcDream.App.UI;
|
||||
using Xunit;
|
||||
|
||||
namespace AcDream.App.Tests.UI;
|
||||
|
||||
public class DragDropSpineTests
|
||||
{
|
||||
// A spy handler used across the spine tests.
|
||||
private sealed class SpyHandler : IItemListDragHandler
|
||||
{
|
||||
public bool AcceptResult = true;
|
||||
public (UiItemList list, UiItemSlot cell, ItemDragPayload payload)? LastOver;
|
||||
public (UiItemList list, UiItemSlot cell, ItemDragPayload payload)? LastDrop;
|
||||
public (UiItemList list, UiItemSlot cell, ItemDragPayload payload)? LastLift;
|
||||
public void OnDragLift(UiItemList list, UiItemSlot cell, ItemDragPayload p)
|
||||
{ LastLift = (list, cell, p); }
|
||||
|
||||
public bool OnDragOver(UiItemList list, UiItemSlot cell, ItemDragPayload p)
|
||||
{ LastOver = (list, cell, p); return AcceptResult; }
|
||||
|
||||
public void HandleDropRelease(UiItemList list, UiItemSlot cell, ItemDragPayload p)
|
||||
{ LastDrop = (list, cell, p); }
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Payload_holdsAllFields()
|
||||
{
|
||||
var src = new UiItemSlot();
|
||||
var p = new ItemDragPayload(0x5001u, ItemDragSource.ShortcutBar, 3, src);
|
||||
Assert.Equal(0x5001u, p.ObjId);
|
||||
Assert.Equal(ItemDragSource.ShortcutBar, p.SourceKind);
|
||||
Assert.Equal(3, p.SourceSlot);
|
||||
Assert.Same(src, p.SourceCell);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void UiItemList_registerDragHandler_roundtrips()
|
||||
{
|
||||
var list = new UiItemList(_ => (0u, 0, 0));
|
||||
Assert.Null(list.DragHandler);
|
||||
var h = new SpyHandler();
|
||||
list.RegisterDragHandler(h);
|
||||
Assert.Same(h, list.DragHandler);
|
||||
}
|
||||
|
||||
// ── UiItemSlot drag-source payload/ghost ────────────────────────────────
|
||||
[Fact]
|
||||
public void GetDragPayload_emptyCell_isNull()
|
||||
=> Assert.Null(new UiItemSlot().GetDragPayload());
|
||||
|
||||
[Fact]
|
||||
public void GetDragPayload_boundCell_snapshotsFields()
|
||||
{
|
||||
var cell = new UiItemSlot { SlotIndex = 4, SourceKind = ItemDragSource.ShortcutBar };
|
||||
cell.SetItem(0x5001u, 0x99u);
|
||||
var p = Assert.IsType<ItemDragPayload>(cell.GetDragPayload());
|
||||
Assert.Equal(0x5001u, p.ObjId);
|
||||
Assert.Equal(ItemDragSource.ShortcutBar, p.SourceKind);
|
||||
Assert.Equal(4, p.SourceSlot);
|
||||
Assert.Same(cell, p.SourceCell);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetDragGhost_emptyCell_isNull()
|
||||
=> Assert.Null(new UiItemSlot().GetDragGhost());
|
||||
|
||||
[Fact]
|
||||
public void GetDragGhost_boundCell_returnsIconTuple()
|
||||
{
|
||||
var cell = new UiItemSlot { Width = 32, Height = 32 };
|
||||
cell.SetItem(0x5001u, 0x99u);
|
||||
var g = cell.GetDragGhost();
|
||||
Assert.NotNull(g);
|
||||
Assert.Equal(0x99u, g!.Value.tex);
|
||||
Assert.Equal(32, g.Value.w);
|
||||
Assert.Equal(32, g.Value.h);
|
||||
}
|
||||
|
||||
// ── cell drop-target: DragEnter overlay + DropReleased dispatch ──────────
|
||||
private static (UiItemList list, UiItemSlot cell, SpyHandler h) ListWithHandler()
|
||||
{
|
||||
var list = new UiItemList(_ => (1u, 1, 1)); // non-zero resolve so overlay draw is harmless
|
||||
var h = new SpyHandler();
|
||||
list.RegisterDragHandler(h);
|
||||
return (list, list.Cell, h);
|
||||
}
|
||||
|
||||
private static ItemDragPayload SomePayload()
|
||||
=> new(0x5001u, ItemDragSource.ShortcutBar, 0, new UiItemSlot());
|
||||
|
||||
[Fact]
|
||||
public void DragEnter_setsAcceptOverlay_whenHandlerAccepts()
|
||||
{
|
||||
var (_, cell, h) = ListWithHandler();
|
||||
h.AcceptResult = true;
|
||||
cell.OnEvent(new UiEvent(0u, cell, UiEventType.DragEnter, Payload: SomePayload()));
|
||||
Assert.Equal(UiItemSlot.DragAcceptState.Accept, cell.DragAcceptVisual);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DragEnter_setsRejectOverlay_whenHandlerRejects()
|
||||
{
|
||||
var (_, cell, h) = ListWithHandler();
|
||||
h.AcceptResult = false;
|
||||
cell.OnEvent(new UiEvent(0u, cell, UiEventType.DragEnter, Payload: SomePayload()));
|
||||
Assert.Equal(UiItemSlot.DragAcceptState.Reject, cell.DragAcceptVisual);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DragOver_resetsOverlayToNeutral()
|
||||
{
|
||||
var (_, cell, h) = ListWithHandler();
|
||||
cell.OnEvent(new UiEvent(0u, cell, UiEventType.DragEnter, Payload: SomePayload()));
|
||||
cell.OnEvent(new UiEvent(0u, cell, UiEventType.DragOver, Payload: SomePayload()));
|
||||
Assert.Equal(UiItemSlot.DragAcceptState.None, cell.DragAcceptVisual);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DropReleased_accepted_dispatchesToHandler()
|
||||
{
|
||||
var (list, cell, h) = ListWithHandler();
|
||||
var p = SomePayload();
|
||||
cell.OnEvent(new UiEvent(0u, cell, UiEventType.DropReleased, Data0: 1, Payload: p));
|
||||
Assert.NotNull(h.LastDrop);
|
||||
Assert.Same(list, h.LastDrop!.Value.list);
|
||||
Assert.Same(cell, h.LastDrop.Value.cell);
|
||||
Assert.Same(p, h.LastDrop.Value.payload);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DropReleased_dispatchesToHandler_regardlessOfData0()
|
||||
{
|
||||
// Retail model: reaching the cell means a real slot was hit (FinishDrag only delivers on a
|
||||
// hit), so the handler is authoritative — it dispatches whether or not Data0 is set.
|
||||
var (list, cell, h) = ListWithHandler();
|
||||
cell.OnEvent(new UiEvent(0u, cell, UiEventType.DropReleased, Data0: 0, Payload: SomePayload()));
|
||||
Assert.NotNull(h.LastDrop);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DragBegin_callsHandlerOnDragLift()
|
||||
{
|
||||
var (list, cell, h) = ListWithHandler();
|
||||
var p = SomePayload();
|
||||
cell.OnEvent(new UiEvent(0u, cell, UiEventType.DragBegin, Payload: p));
|
||||
Assert.NotNull(h.LastLift);
|
||||
Assert.Same(list, h.LastLift!.Value.list);
|
||||
Assert.Same(cell, h.LastLift.Value.cell);
|
||||
Assert.Same(p, h.LastLift.Value.payload);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Ghost_isSnapshottedAtBeginDrag_survivesSourceCellClearing()
|
||||
{
|
||||
var (root, _, cell) = RootWithBoundSlot(0x5001u); // icon tex 0x99
|
||||
root.OnMouseDown(UiMouseButton.Left, 10, 10);
|
||||
root.OnMouseMove(20, 10); // BeginDrag → snapshot ghost
|
||||
cell.Clear(); // simulate the lift emptying the source
|
||||
Assert.Equal((0x99u, 32, 32), root.DragGhostForTest);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FinishDrag_overNothing_deliversNoDrop_butLiftStands()
|
||||
{
|
||||
var root = new UiRoot { Width = 800, Height = 600 };
|
||||
var list = new UiItemList(_ => (1u, 1, 1)) { Left = 0, Top = 0, Width = 32, Height = 32 };
|
||||
list.Cell.Width = 32; list.Cell.Height = 32;
|
||||
list.Cell.SetItem(0x5001u, 0x99u);
|
||||
var h = new SpyHandler();
|
||||
list.RegisterDragHandler(h);
|
||||
root.AddChild(list);
|
||||
|
||||
root.OnMouseDown(UiMouseButton.Left, 10, 10);
|
||||
root.OnMouseMove(20, 10); // BeginDrag → OnDragLift
|
||||
root.OnMouseUp(UiMouseButton.Left, 600, 500); // release over empty space
|
||||
Assert.NotNull(h.LastLift); // lift happened
|
||||
Assert.Null(h.LastDrop); // no drop dispatched (off-bar)
|
||||
Assert.Null(root.DragSource); // cleaned up
|
||||
}
|
||||
|
||||
// ── Full UiRoot chain: arming + use-vs-drag ─────────────────────────────
|
||||
// A bound, hit-testable slot inside a list, sized for the hit-test.
|
||||
private static (UiRoot root, UiItemList list, UiItemSlot cell) RootWithBoundSlot(uint itemId)
|
||||
{
|
||||
var root = new UiRoot { Width = 800, Height = 600 };
|
||||
var list = new UiItemList(_ => (1u, 1, 1)) { Left = 0, Top = 0, Width = 32, Height = 32 };
|
||||
// Tests don't run OnDraw (which sizes the cell), so size the cell explicitly.
|
||||
list.Cell.Width = 32; list.Cell.Height = 32;
|
||||
if (itemId != 0) list.Cell.SetItem(itemId, 0x99u);
|
||||
root.AddChild(list);
|
||||
return (root, list, list.Cell);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BeginDrag_arms_whenPayloadNonNull()
|
||||
{
|
||||
var (root, _, cell) = RootWithBoundSlot(0x5001u);
|
||||
root.OnMouseDown(UiMouseButton.Left, 10, 10);
|
||||
root.OnMouseMove(20, 10); // >3px → promote to drag
|
||||
Assert.Same(cell, root.DragSource);
|
||||
Assert.IsType<ItemDragPayload>(root.DragPayload);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BeginDrag_doesNotArm_whenPayloadNull_emptySlot()
|
||||
{
|
||||
var (root, _, _) = RootWithBoundSlot(0u); // empty cell → GetDragPayload null
|
||||
root.OnMouseDown(UiMouseButton.Left, 10, 10);
|
||||
root.OnMouseMove(20, 10);
|
||||
Assert.Null(root.DragSource); // never armed
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Click_withoutDrag_firesUse()
|
||||
{
|
||||
var (root, _, cell) = RootWithBoundSlot(0x5001u);
|
||||
bool used = false;
|
||||
cell.Clicked = () => used = true;
|
||||
root.OnMouseDown(UiMouseButton.Left, 10, 10);
|
||||
root.OnMouseUp(UiMouseButton.Left, 10, 10); // no move → Click emitted
|
||||
Assert.True(used);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CompletedDrag_doesNotFireUse()
|
||||
{
|
||||
var (root, _, cell) = RootWithBoundSlot(0x5001u);
|
||||
bool used = false;
|
||||
cell.Clicked = () => used = true;
|
||||
root.OnMouseDown(UiMouseButton.Left, 10, 10);
|
||||
root.OnMouseMove(20, 10); // promote to drag
|
||||
root.OnMouseUp(UiMouseButton.Left, 20, 10); // FinishDrag, NOT Click
|
||||
Assert.False(used);
|
||||
}
|
||||
|
||||
// ── no-handler / orphan-cell DragEnter defaults to Reject (review carry-forward) ──
|
||||
[Fact]
|
||||
public void DragEnter_orphanCell_noList_defaultsToReject()
|
||||
{
|
||||
var cell = new UiItemSlot(); // no parent list → FindList() null
|
||||
cell.OnEvent(new UiEvent(0u, cell, UiEventType.DragEnter, Payload: SomePayload()));
|
||||
Assert.Equal(UiItemSlot.DragAcceptState.Reject, cell.DragAcceptVisual);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DragEnter_listWithoutHandler_defaultsToReject()
|
||||
{
|
||||
var list = new UiItemList(_ => (1u, 1, 1)); // no RegisterDragHandler
|
||||
list.Cell.OnEvent(new UiEvent(0u, list.Cell, UiEventType.DragEnter, Payload: SomePayload()));
|
||||
Assert.Equal(UiItemSlot.DragAcceptState.Reject, list.Cell.DragAcceptVisual);
|
||||
}
|
||||
|
||||
// ── item drag inside a Draggable window (the LIVE toolbar topology) ──────
|
||||
// Regression (visual gate 2026-06-20): the slot sits inside the Draggable toolbar
|
||||
// frame, so FindWindow returns the frame. An OCCUPIED slot must start an ITEM drag
|
||||
// (IsDragSource), NOT move the window; an EMPTY slot falls through to whole-window
|
||||
// drag (IA-12) so the bar stays movable by its empty cells / chrome. The earlier
|
||||
// RootWithBoundSlot tests put the slot directly under the root (no draggable
|
||||
// ancestor), so they could not catch this.
|
||||
private static (UiRoot root, UiPanel frame, UiItemList list) DraggableFrameWithSlot(uint itemId)
|
||||
{
|
||||
var root = new UiRoot { Width = 800, Height = 600 };
|
||||
var frame = new UiPanel { Left = 10, Top = 300, Width = 200, Height = 60, Draggable = true };
|
||||
var list = new UiItemList(_ => (1u, 1, 1)) { Left = 5, Top = 5, Width = 32, Height = 32 };
|
||||
list.Cell.Width = 32; list.Cell.Height = 32;
|
||||
if (itemId != 0) list.Cell.SetItem(itemId, 0x99u);
|
||||
frame.AddChild(list);
|
||||
root.AddChild(frame);
|
||||
return (root, frame, list);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void OccupiedSlotInsideDraggableWindow_armsItemDrag_doesNotMoveWindow()
|
||||
{
|
||||
var (root, frame, list) = DraggableFrameWithSlot(0x5001u);
|
||||
// Slot screen rect = frame(10,300)+list(5,5) → (15,305)..(47,337). Press inside, drag >3px.
|
||||
root.OnMouseDown(UiMouseButton.Left, 20, 310);
|
||||
root.OnMouseMove(40, 310);
|
||||
Assert.Same(list.Cell, root.DragSource); // item drag armed
|
||||
Assert.Equal(10f, frame.Left); // window did NOT move
|
||||
Assert.Equal(300f, frame.Top);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EmptySlotInsideDraggableWindow_movesWindow_notItemDrag()
|
||||
{
|
||||
var (root, frame, _) = DraggableFrameWithSlot(0u); // empty slot → not a drag source
|
||||
root.OnMouseDown(UiMouseButton.Left, 20, 310);
|
||||
root.OnMouseMove(40, 310);
|
||||
Assert.Null(root.DragSource); // no item drag
|
||||
Assert.Equal(30f, frame.Left); // window moved (offX=20-10=10; new Left=40-10=30)
|
||||
Assert.Equal(300f, frame.Top); // y unchanged (310-10=300)
|
||||
}
|
||||
}
|
||||
|
|
@ -89,8 +89,8 @@ public class ToolbarControllerTests
|
|||
|
||||
ToolbarController.Bind(layout, repo, () => shortcuts,
|
||||
iconIds: (_,_,_,_,_) => 0x77u, useItem: g => used = g);
|
||||
// UiEvent is a positional record struct: (SourceId, Target, Type, Data0..3, Payload)
|
||||
slots[Row1[0]].Cell.OnEvent(new UiEvent(0u, null, UiEventType.MouseDown));
|
||||
// Use now fires on Click (mouse-up), not MouseDown — drag/click disambiguation.
|
||||
slots[Row1[0]].Cell.OnEvent(new UiEvent(0u, null, UiEventType.Click));
|
||||
|
||||
Assert.Equal(0x5001u, used);
|
||||
}
|
||||
|
|
@ -421,4 +421,177 @@ public class ToolbarControllerTests
|
|||
|
||||
Assert.Equal(callsAfterBind, iconCallCount); // unchanged
|
||||
}
|
||||
|
||||
// ── B.1: drag-drop spine wiring ──────────────────────────────────────────
|
||||
|
||||
/// <summary>Bind registers the controller as each slot list's drag handler and
|
||||
/// stamps every cell's SlotIndex + SourceKind=ShortcutBar.</summary>
|
||||
[Fact]
|
||||
public void Bind_registersDragHandler_andStampsSlots()
|
||||
{
|
||||
var (layout, slots, _) = FakeToolbar();
|
||||
var repo = new ClientObjectTable();
|
||||
|
||||
var ctrl = ToolbarController.Bind(layout, repo,
|
||||
() => Array.Empty<PlayerDescriptionParser.ShortcutEntry>(),
|
||||
iconIds: (_,_,_,_,_) => 0u, useItem: _ => { });
|
||||
|
||||
for (int i = 0; i < Row1.Length; i++)
|
||||
{
|
||||
Assert.Same(ctrl, slots[Row1[i]].DragHandler);
|
||||
Assert.Equal(i, slots[Row1[i]].Cell.SlotIndex);
|
||||
Assert.Equal(ItemDragSource.ShortcutBar, slots[Row1[i]].Cell.SourceKind);
|
||||
}
|
||||
// Bottom row slots are indices 9..17; same handler + source kind as the top row.
|
||||
for (int j = 0; j < Row2.Length; j++)
|
||||
{
|
||||
Assert.Same(ctrl, slots[Row2[j]].DragHandler);
|
||||
Assert.Equal(9 + j, slots[Row2[j]].Cell.SlotIndex);
|
||||
Assert.Equal(ItemDragSource.ShortcutBar, slots[Row2[j]].Cell.SourceKind);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>OnDragOver accepts a real item (ObjId != 0). Eligibility (IsShortcutEligible)
|
||||
/// is Stream B.2; the stub accepts any non-empty payload.</summary>
|
||||
[Fact]
|
||||
public void OnDragOver_acceptsRealItem()
|
||||
{
|
||||
var (layout, slots, _) = FakeToolbar();
|
||||
var ctrl = ToolbarController.Bind(layout, new ClientObjectTable(),
|
||||
() => Array.Empty<PlayerDescriptionParser.ShortcutEntry>(),
|
||||
iconIds: (_,_,_,_,_) => 0u, useItem: _ => { });
|
||||
|
||||
var list = slots[Row1[0]];
|
||||
var payload = new ItemDragPayload(0x5001u, ItemDragSource.Inventory, 0, new UiItemSlot());
|
||||
Assert.True(ctrl.OnDragOver(list, list.Cell, payload));
|
||||
}
|
||||
|
||||
/// <summary>OnDragOver rejects a ghost payload with ObjId 0 (the guard against an
|
||||
/// empty cell that somehow produced a payload). Complements OnDragOver_acceptsRealItem.</summary>
|
||||
[Fact]
|
||||
public void OnDragOver_rejectsZeroObjId()
|
||||
{
|
||||
var (layout, slots, _) = FakeToolbar();
|
||||
var ctrl = ToolbarController.Bind(layout, new ClientObjectTable(),
|
||||
() => Array.Empty<PlayerDescriptionParser.ShortcutEntry>(),
|
||||
iconIds: (_,_,_,_,_) => 0u, useItem: _ => { });
|
||||
var list = slots[Row1[0]];
|
||||
var payload = new ItemDragPayload(0u, ItemDragSource.Inventory, 0, new UiItemSlot());
|
||||
Assert.False(ctrl.OnDragOver(list, list.Cell, payload));
|
||||
}
|
||||
|
||||
// ── B.2: live drag handler (store + reorder/remove + wire) ───────────────
|
||||
private static (System.Collections.Generic.List<(uint i,uint g)> adds,
|
||||
System.Collections.Generic.List<uint> removes) NewSpies(out System.Action<uint,uint> add, out System.Action<uint> rem)
|
||||
{
|
||||
var adds = new System.Collections.Generic.List<(uint,uint)>();
|
||||
var removes = new System.Collections.Generic.List<uint>();
|
||||
add = (i, g) => adds.Add((i, g));
|
||||
rem = i => removes.Add(i);
|
||||
return (adds, removes);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void OnDragLift_removesSourceSlot_sendsRemove_emptiesCell()
|
||||
{
|
||||
var (layout, slots, _) = FakeToolbar();
|
||||
var repo = new ClientObjectTable();
|
||||
repo.AddOrUpdate(new ClientObject { ObjectId = 0x5001u, WeenieClassId = 1u, IconId = 0x06001234u });
|
||||
var shortcuts = new System.Collections.Generic.List<PlayerDescriptionParser.ShortcutEntry>
|
||||
{ new(Index: 3, ObjectGuid: 0x5001u, SpellId: 0, Layer: 0) };
|
||||
var (adds, removes) = NewSpies(out var add, out var rem);
|
||||
|
||||
var ctrl = ToolbarController.Bind(layout, repo, () => shortcuts,
|
||||
iconIds: (_,_,_,_,_) => 0x77u, useItem: _ => { },
|
||||
sendAddShortcut: add, sendRemoveShortcut: rem);
|
||||
Assert.Equal(0x5001u, slots[Row1[3]].Cell.ItemId);
|
||||
|
||||
var payload = new ItemDragPayload(0x5001u, ItemDragSource.ShortcutBar, 3, slots[Row1[3]].Cell);
|
||||
ctrl.OnDragLift(slots[Row1[3]], slots[Row1[3]].Cell, payload);
|
||||
|
||||
Assert.Contains(3u, removes);
|
||||
Assert.Equal(0u, slots[Row1[3]].Cell.ItemId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void HandleDropRelease_ontoOccupied_swaps_andSendsRetailSequence()
|
||||
{
|
||||
var (layout, slots, _) = FakeToolbar();
|
||||
var repo = new ClientObjectTable();
|
||||
repo.AddOrUpdate(new ClientObject { ObjectId = 0x5001u, WeenieClassId = 1u, IconId = 0x06001234u }); // A
|
||||
repo.AddOrUpdate(new ClientObject { ObjectId = 0x5002u, WeenieClassId = 1u, IconId = 0x06005678u }); // B
|
||||
var shortcuts = new System.Collections.Generic.List<PlayerDescriptionParser.ShortcutEntry>
|
||||
{ new(Index: 3, ObjectGuid: 0x5001u, SpellId: 0, Layer: 0),
|
||||
new(Index: 5, ObjectGuid: 0x5002u, SpellId: 0, Layer: 0) };
|
||||
var (adds, removes) = NewSpies(out var add, out var rem);
|
||||
var ctrl = ToolbarController.Bind(layout, repo, () => shortcuts,
|
||||
iconIds: (_,_,_,_,_) => 0x77u, useItem: _ => { },
|
||||
sendAddShortcut: add, sendRemoveShortcut: rem);
|
||||
|
||||
var payload = new ItemDragPayload(0x5001u, ItemDragSource.ShortcutBar, 3, slots[Row1[3]].Cell);
|
||||
ctrl.OnDragLift(slots[Row1[3]], slots[Row1[3]].Cell, payload);
|
||||
ctrl.HandleDropRelease(slots[Row1[5]], slots[Row1[5]].Cell, payload);
|
||||
|
||||
Assert.Equal(0x5001u, slots[Row1[5]].Cell.ItemId);
|
||||
Assert.Equal(0x5002u, slots[Row1[3]].Cell.ItemId);
|
||||
Assert.Equal(new[] { 3u, 5u }, removes.ToArray());
|
||||
Assert.Contains((5u, 0x5001u), adds);
|
||||
Assert.Contains((3u, 0x5002u), adds);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void HandleDropRelease_ontoEmpty_placesOnly_sourceStaysEmpty()
|
||||
{
|
||||
var (layout, slots, _) = FakeToolbar();
|
||||
var repo = new ClientObjectTable();
|
||||
repo.AddOrUpdate(new ClientObject { ObjectId = 0x5001u, WeenieClassId = 1u, IconId = 0x06001234u });
|
||||
var shortcuts = new System.Collections.Generic.List<PlayerDescriptionParser.ShortcutEntry>
|
||||
{ new(Index: 3, ObjectGuid: 0x5001u, SpellId: 0, Layer: 0) };
|
||||
var (adds, removes) = NewSpies(out var add, out var rem);
|
||||
var ctrl = ToolbarController.Bind(layout, repo, () => shortcuts,
|
||||
iconIds: (_,_,_,_,_) => 0x77u, useItem: _ => { },
|
||||
sendAddShortcut: add, sendRemoveShortcut: rem);
|
||||
|
||||
var payload = new ItemDragPayload(0x5001u, ItemDragSource.ShortcutBar, 3, slots[Row1[3]].Cell);
|
||||
ctrl.OnDragLift(slots[Row1[3]], slots[Row1[3]].Cell, payload);
|
||||
ctrl.HandleDropRelease(slots[Row1[7]], slots[Row1[7]].Cell, payload);
|
||||
|
||||
Assert.Equal(0x5001u, slots[Row1[7]].Cell.ItemId);
|
||||
Assert.Equal(0u, slots[Row1[3]].Cell.ItemId);
|
||||
Assert.Contains((7u, 0x5001u), adds);
|
||||
Assert.DoesNotContain(adds, a => a.i == 3u);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void HandleDropRelease_ontoSelf_reAddsToSource()
|
||||
{
|
||||
var (layout, slots, _) = FakeToolbar();
|
||||
var repo = new ClientObjectTable();
|
||||
repo.AddOrUpdate(new ClientObject { ObjectId = 0x5001u, WeenieClassId = 1u, IconId = 0x06001234u });
|
||||
var shortcuts = new System.Collections.Generic.List<PlayerDescriptionParser.ShortcutEntry>
|
||||
{ new(Index: 3, ObjectGuid: 0x5001u, SpellId: 0, Layer: 0) };
|
||||
var (adds, removes) = NewSpies(out var add, out var rem);
|
||||
var ctrl = ToolbarController.Bind(layout, repo, () => shortcuts,
|
||||
iconIds: (_,_,_,_,_) => 0x77u, useItem: _ => { },
|
||||
sendAddShortcut: add, sendRemoveShortcut: rem);
|
||||
|
||||
var payload = new ItemDragPayload(0x5001u, ItemDragSource.ShortcutBar, 3, slots[Row1[3]].Cell);
|
||||
ctrl.OnDragLift(slots[Row1[3]], slots[Row1[3]].Cell, payload); // slot 3 emptied
|
||||
ctrl.HandleDropRelease(slots[Row1[3]], slots[Row1[3]].Cell, payload); // drop back on slot 3
|
||||
|
||||
Assert.Equal(0x5001u, slots[Row1[3]].Cell.ItemId); // re-added to source (net no-op)
|
||||
Assert.Contains((3u, 0x5001u), adds); // AddShortcut(3, A) sent
|
||||
Assert.Single(removes); // exactly RemoveShortcut(3) [from the lift]
|
||||
Assert.Single(adds); // exactly AddShortcut(3, A) [the re-place]
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ToolbarSlots_useGreenCrossAcceptSprite()
|
||||
{
|
||||
var (layout, slots, _) = FakeToolbar();
|
||||
ToolbarController.Bind(layout, new ClientObjectTable(),
|
||||
() => System.Array.Empty<PlayerDescriptionParser.ShortcutEntry>(),
|
||||
iconIds: (_,_,_,_,_) => 0u, useItem: _ => { });
|
||||
Assert.Equal(0x060011FAu, slots[Row1[0]].Cell.DragAcceptSprite); // green cross, not the ring F9
|
||||
}
|
||||
}
|
||||
|
|
|
|||
63
tests/AcDream.App.Tests/UI/UiCollapsibleFrameTests.cs
Normal file
63
tests/AcDream.App.Tests/UI/UiCollapsibleFrameTests.cs
Normal file
|
|
@ -0,0 +1,63 @@
|
|||
using AcDream.App.UI;
|
||||
using Xunit;
|
||||
|
||||
namespace AcDream.App.Tests.UI;
|
||||
|
||||
public class UiCollapsibleFrameTests
|
||||
{
|
||||
private static UiCollapsibleFrame MakeFrame(out UiPanel row2a, out UiPanel row2b)
|
||||
{
|
||||
var f = new UiCollapsibleFrame(_ => (1u, 1, 1))
|
||||
{
|
||||
CollapsedHeight = 96f,
|
||||
ExpandedHeight = 128f,
|
||||
};
|
||||
row2a = new UiPanel(); row2b = new UiPanel();
|
||||
f.SecondRow = new UiElement[] { row2a, row2b };
|
||||
return f;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Tick_belowMidpoint_snapsCollapsed_hidesSecondRow()
|
||||
{
|
||||
var f = MakeFrame(out var a, out var b);
|
||||
f.Height = 100f; // nearer the collapsed stop (midpoint 112)
|
||||
f.TickForTest(0.016);
|
||||
Assert.Equal(96f, f.Height);
|
||||
Assert.False(a.Visible);
|
||||
Assert.False(b.Visible);
|
||||
Assert.False(f.IsExpanded);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Tick_aboveMidpoint_snapsExpanded_showsSecondRow()
|
||||
{
|
||||
var f = MakeFrame(out var a, out var b);
|
||||
f.Height = 120f; // nearer the expanded stop
|
||||
f.TickForTest(0.016);
|
||||
Assert.Equal(128f, f.Height);
|
||||
Assert.True(a.Visible);
|
||||
Assert.True(b.Visible);
|
||||
Assert.True(f.IsExpanded);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Tick_notConfigured_isNoOp()
|
||||
{
|
||||
var f = new UiCollapsibleFrame(_ => (1u, 1, 1)); // Collapsed==Expanded==0
|
||||
f.Height = 50f;
|
||||
f.TickForTest(0.016);
|
||||
Assert.Equal(50f, f.Height); // unchanged, no divide/no forced height
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void HitEdges_respectsResizableEdgesMask_bottomOnly()
|
||||
{
|
||||
var panel = new UiPanel { Left = 100, Top = 100, Width = 200, Height = 100,
|
||||
Resizable = true, ResizableEdges = ResizeEdges.Bottom };
|
||||
// bottom edge (y=200) → Bottom only
|
||||
Assert.Equal(ResizeEdges.Bottom, UiRoot.HitEdges(panel, 200, 200, 5));
|
||||
// top edge (y=100) → masked out → None
|
||||
Assert.Equal(ResizeEdges.None, UiRoot.HitEdges(panel, 200, 100, 5));
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
39
tests/AcDream.Core.Tests/Items/ShortcutStoreTests.cs
Normal file
39
tests/AcDream.Core.Tests/Items/ShortcutStoreTests.cs
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
using AcDream.Core.Items;
|
||||
using Xunit;
|
||||
|
||||
namespace AcDream.Core.Tests.Items;
|
||||
|
||||
public class ShortcutStoreTests
|
||||
{
|
||||
[Fact]
|
||||
public void Load_mapsSlotToObjId_skipsEmptyAndOutOfRange()
|
||||
{
|
||||
var store = new ShortcutStore();
|
||||
store.Load(new (int, uint)[]
|
||||
{
|
||||
(0, 0x5001u),
|
||||
(3, 0x5002u),
|
||||
(5, 0u), // empty/spell → skipped
|
||||
(99, 0x5003u), // out of range → skipped
|
||||
});
|
||||
Assert.Equal(0x5001u, store.Get(0));
|
||||
Assert.Equal(0x5002u, store.Get(3));
|
||||
Assert.Equal(0u, store.Get(5));
|
||||
Assert.True(store.IsEmpty(1));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SetRemoveGet_roundtrip_andBoundsSafe()
|
||||
{
|
||||
var store = new ShortcutStore();
|
||||
store.Set(4, 0xABCDu);
|
||||
Assert.Equal(0xABCDu, store.Get(4));
|
||||
Assert.False(store.IsEmpty(4));
|
||||
store.Remove(4);
|
||||
Assert.True(store.IsEmpty(4));
|
||||
Assert.Equal(0u, store.Get(-1));
|
||||
Assert.Equal(0u, store.Get(18));
|
||||
store.Set(18, 1u); // no-op, no throw
|
||||
store.Remove(-1); // no-op, no throw
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue