merge: integrate main (D.2b paperdoll/inventory UI line) into the physics/collision branch
Brings the 134 D.2b UI commits onto the physics/collision development line so main can fast-forward. Today's #149 (BSP-less static collision) + #150 (open doors fully passable) + the full collision/streaming/dense-town-FPS arc meet the paperdoll/inventory work. # Conflicts: # docs/ISSUES.md # docs/architecture/retail-divergence-register.md
This commit is contained in:
commit
01594b4cfd
95 changed files with 17201 additions and 167 deletions
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.
|
||||
```
|
||||
390
docs/superpowers/plans/2026-06-20-d2b-inventory-grid-mount.md
Normal file
390
docs/superpowers/plans/2026-06-20-d2b-inventory-grid-mount.md
Normal file
|
|
@ -0,0 +1,390 @@
|
|||
# D.2b B-Grid Implementation Plan (inventory sub-window mount + UiItemList grid)
|
||||
|
||||
> **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 `LayoutImporter.Import(0x21000023)` produce the full nested inventory frame and give `UiItemList` an N-cell grid, so F12 shows the real inventory window.
|
||||
|
||||
**Architecture:** A ~4-line mount in `LayoutImporter.Resolve` attaches a base element's resolved children to a childless, media-less inheritor (the three gmInventoryUI panels nest via the existing `BaseElement`+`BaseLayoutId` path). `UiItemList` gains a column-count + cell-pitch layout, with single-cell (toolbar) behavior preserved by defaults. `GameWindow` swaps the Sub-phase A placeholder for the real import.
|
||||
|
||||
**Tech Stack:** C# .NET 10, xUnit, the `AcDream.App.UI` toolkit + `LayoutImporter`, `DatCollection`.
|
||||
|
||||
**Spec:** `docs/superpowers/specs/2026-06-20-d2b-inventory-grid-mount-design.md`
|
||||
|
||||
---
|
||||
|
||||
## File Structure
|
||||
|
||||
- **Modify** `src/AcDream.App/UI/UiItemList.cs` — add `Columns`/`CellWidth`/`CellHeight` + grid layout (`CellOffset` helper, `LayoutCells`).
|
||||
- **Modify** `src/AcDream.App/UI/Layout/LayoutImporter.cs` — `ShouldMountBaseChildren` predicate + the `Resolve` mount.
|
||||
- **Modify** `src/AcDream.App/Rendering/GameWindow.cs` — swap the placeholder for `Import(0x21000023)`.
|
||||
- **Create** `tests/AcDream.App.Tests/UI/UiItemListGridTests.cs` — grid layout tests.
|
||||
- **Create** `tests/AcDream.App.Tests/UI/LayoutImporterMountTests.cs` — mount predicate tests.
|
||||
|
||||
(`InternalsVisibleTo` from `AcDream.App` → `AcDream.App.Tests` is already configured, so `internal static` helpers are testable — see `UiItemSlot.DragAcceptVisual`.)
|
||||
|
||||
---
|
||||
|
||||
## Task 1: `UiItemList` grid mode
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/AcDream.App/UI/UiItemList.cs`
|
||||
- Test: `tests/AcDream.App.Tests/UI/UiItemListGridTests.cs` (create)
|
||||
|
||||
- [ ] **Step 1: Write the failing tests**
|
||||
|
||||
Create `tests/AcDream.App.Tests/UI/UiItemListGridTests.cs`:
|
||||
|
||||
```csharp
|
||||
using AcDream.App.UI;
|
||||
|
||||
namespace AcDream.App.Tests.UI;
|
||||
|
||||
public class UiItemListGridTests
|
||||
{
|
||||
[Fact]
|
||||
public void CellOffset_RowMajor()
|
||||
{
|
||||
Assert.Equal((0f, 0f), UiItemList.CellOffset(0, 3, 36, 36));
|
||||
Assert.Equal((72f, 0f), UiItemList.CellOffset(2, 3, 36, 36));
|
||||
Assert.Equal((36f, 36f), UiItemList.CellOffset(4, 3, 36, 36)); // col 1, row 1
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GridMode_PositionsCellsInColumns()
|
||||
{
|
||||
var list = new UiItemList { Columns = 3, CellWidth = 36, CellHeight = 36 };
|
||||
list.Flush(); // drop the ctor's default cell
|
||||
for (int i = 0; i < 7; i++) list.AddItem(new UiItemSlot());
|
||||
|
||||
var c4 = list.GetItem(4)!;
|
||||
Assert.Equal(36f, c4.Left);
|
||||
Assert.Equal(36f, c4.Top);
|
||||
Assert.Equal(36f, c4.Width);
|
||||
Assert.Equal(36f, c4.Height);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FillMode_SizesSingleCellToList()
|
||||
{
|
||||
// CellWidth defaults to 0 = "fill the list" (single-cell toolbar legacy).
|
||||
var list = new UiItemList { Width = 36, Height = 36 };
|
||||
list.Flush();
|
||||
list.AddItem(new UiItemSlot());
|
||||
|
||||
var c = list.Cell;
|
||||
Assert.Equal(0f, c.Left);
|
||||
Assert.Equal(0f, c.Top);
|
||||
Assert.Equal(36f, c.Width);
|
||||
Assert.Equal(36f, c.Height);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run the tests to verify they fail**
|
||||
|
||||
Run: `dotnet test tests/AcDream.App.Tests/AcDream.App.Tests.csproj --filter "FullyQualifiedName~UiItemListGridTests"`
|
||||
Expected: FAIL — compile error, `'UiItemList' does not contain a definition for 'Columns'` / `'CellOffset'`.
|
||||
|
||||
- [ ] **Step 3: Implement grid mode**
|
||||
|
||||
In `src/AcDream.App/UI/UiItemList.cs`, replace the `AddItem` method and the `OnDraw` method:
|
||||
|
||||
```csharp
|
||||
public void AddItem(UiItemSlot cell)
|
||||
{
|
||||
cell.SpriteResolve ??= SpriteResolve;
|
||||
_cells.Add(cell);
|
||||
AddChild(cell);
|
||||
LayoutCells();
|
||||
}
|
||||
```
|
||||
|
||||
```csharp
|
||||
protected override void OnDraw(UiRenderContext ctx)
|
||||
{
|
||||
// The factory sets Width/Height AFTER construction, so re-layout each frame:
|
||||
// fill mode keeps the single toolbar cell sized to the list; grid mode tiles cells.
|
||||
LayoutCells();
|
||||
}
|
||||
```
|
||||
|
||||
Then add these members to the class (e.g. after the `Cell` property):
|
||||
|
||||
```csharp
|
||||
/// <summary>Grid columns (row-major). 1 = single column. Ignored in fill mode.</summary>
|
||||
public int Columns { get; set; } = 1;
|
||||
|
||||
/// <summary>Fixed cell width in grid mode. 0 = "fill the list" — the single cell sizes
|
||||
/// to the whole list (the toolbar single-slot legacy). Set >0 (with CellHeight) for a grid.</summary>
|
||||
public float CellWidth { get; set; }
|
||||
/// <summary>Fixed cell height in grid mode (pairs with CellWidth).</summary>
|
||||
public float CellHeight { get; set; }
|
||||
|
||||
/// <summary>Row-major pixel offset of cell <paramref name="index"/> in a grid of
|
||||
/// <paramref name="columns"/> columns at the given cell pitch.</summary>
|
||||
internal static (float x, float y) CellOffset(int index, int columns, float cellW, float cellH)
|
||||
{
|
||||
int col = index % columns, row = index / columns;
|
||||
return (col * cellW, row * cellH);
|
||||
}
|
||||
|
||||
/// <summary>Position every cell per the current mode: fill (CellWidth<=0) sizes the single
|
||||
/// cell to the list; grid (CellWidth>0) tiles cells row-major at the cell pitch.</summary>
|
||||
private void LayoutCells()
|
||||
{
|
||||
if (CellWidth <= 0f)
|
||||
{
|
||||
if (_cells.Count > 0)
|
||||
{
|
||||
var c = _cells[0];
|
||||
c.Left = 0; c.Top = 0; c.Width = Width; c.Height = Height;
|
||||
}
|
||||
return;
|
||||
}
|
||||
int cols = Columns < 1 ? 1 : Columns;
|
||||
for (int i = 0; i < _cells.Count; i++)
|
||||
{
|
||||
var (x, y) = CellOffset(i, cols, CellWidth, CellHeight);
|
||||
var cell = _cells[i];
|
||||
cell.Left = x; cell.Top = y; cell.Width = CellWidth; cell.Height = CellHeight;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run the tests to verify they pass**
|
||||
|
||||
Run: `dotnet test tests/AcDream.App.Tests/AcDream.App.Tests.csproj --filter "FullyQualifiedName~UiItemListGridTests"`
|
||||
Expected: PASS (3 tests).
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add src/AcDream.App/UI/UiItemList.cs tests/AcDream.App.Tests/UI/UiItemListGridTests.cs
|
||||
git commit -m "feat(ui): D.2b-B — UiItemList N-cell grid mode
|
||||
|
||||
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 2: Sub-window mount in `LayoutImporter.Resolve`
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/AcDream.App/UI/Layout/LayoutImporter.cs` (add `ShouldMountBaseChildren`; restructure `Resolve`)
|
||||
- Test: `tests/AcDream.App.Tests/UI/LayoutImporterMountTests.cs` (create)
|
||||
|
||||
- [ ] **Step 1: Write the failing tests**
|
||||
|
||||
Create `tests/AcDream.App.Tests/UI/LayoutImporterMountTests.cs`:
|
||||
|
||||
```csharp
|
||||
using AcDream.App.UI.Layout;
|
||||
|
||||
namespace AcDream.App.Tests.UI;
|
||||
|
||||
public class LayoutImporterMountTests
|
||||
{
|
||||
[Fact]
|
||||
public void Mounts_ChildlessMediaLessInheritor_WithContentfulBase()
|
||||
=> Assert.True(LayoutImporter.ShouldMountBaseChildren(derivedChildCount: 0, derivedMediaCount: 0, baseChildCount: 5));
|
||||
|
||||
[Fact]
|
||||
public void DoesNotMount_WhenDerivedHasOwnMedia() // close button / title
|
||||
=> Assert.False(LayoutImporter.ShouldMountBaseChildren(0, 1, 5));
|
||||
|
||||
[Fact]
|
||||
public void DoesNotMount_WhenDerivedHasOwnChildren()
|
||||
=> Assert.False(LayoutImporter.ShouldMountBaseChildren(2, 0, 5));
|
||||
|
||||
[Fact]
|
||||
public void DoesNotMount_WhenBaseIsChildless() // vitals/chat/toolbar style prototypes
|
||||
=> Assert.False(LayoutImporter.ShouldMountBaseChildren(0, 0, 0));
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run the tests to verify they fail**
|
||||
|
||||
Run: `dotnet test tests/AcDream.App.Tests/AcDream.App.Tests.csproj --filter "FullyQualifiedName~LayoutImporterMountTests"`
|
||||
Expected: FAIL — compile error, `'LayoutImporter' does not contain a definition for 'ShouldMountBaseChildren'`.
|
||||
|
||||
- [ ] **Step 3: Add the predicate**
|
||||
|
||||
In `src/AcDream.App/UI/Layout/LayoutImporter.cs`, add this method just above the private `Resolve` method (after the `Import` method, in the "Inheritance resolution" region):
|
||||
|
||||
```csharp
|
||||
/// <summary>True when a pure-container leaf should inherit its base's subtree (the
|
||||
/// gmInventoryUI sub-window mount): the derived element has no own children, no own
|
||||
/// state media, and the base resolved to ≥1 child. Inert for media-bearing inheritors
|
||||
/// (close button / title), elements with their own children, and childless style
|
||||
/// prototypes (vitals/chat/toolbar text).</summary>
|
||||
internal static bool ShouldMountBaseChildren(int derivedChildCount, int derivedMediaCount, int baseChildCount)
|
||||
=> derivedChildCount == 0 && derivedMediaCount == 0 && baseChildCount > 0;
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Wire the mount into `Resolve`**
|
||||
|
||||
In `src/AcDream.App/UI/Layout/LayoutImporter.cs`, replace the entire body of the private `Resolve` method:
|
||||
|
||||
```csharp
|
||||
private static ElementInfo Resolve(
|
||||
DatCollection dats,
|
||||
ElementDesc d,
|
||||
HashSet<(uint layoutId, uint elementId)> baseChain)
|
||||
{
|
||||
// Read this element's own fields + media (no inheritance, no children yet).
|
||||
var self = ToInfo(d);
|
||||
var result = self;
|
||||
List<ElementInfo>? baseChildren = null;
|
||||
|
||||
// Apply BaseElement / BaseLayoutId inheritance if present.
|
||||
if (d.BaseElement != 0 && d.BaseLayoutId != 0
|
||||
&& baseChain.Add((d.BaseLayoutId, d.BaseElement)))
|
||||
{
|
||||
var baseLd = dats.Get<LayoutDesc>(d.BaseLayoutId);
|
||||
var baseDesc = baseLd is null ? null : FindDesc(baseLd, d.BaseElement);
|
||||
if (baseDesc is not null)
|
||||
{
|
||||
// Recurse the base chain (already guarded by the HashSet add above).
|
||||
var baseInfo = Resolve(dats, baseDesc, baseChain);
|
||||
// Derived fields override the base; children are attached below.
|
||||
result = ElementReader.Merge(baseInfo, self);
|
||||
baseChildren = baseInfo.Children; // capture for the sub-window mount
|
||||
}
|
||||
}
|
||||
|
||||
// Resolve + attach children. Each child gets a FRESH base-chain set:
|
||||
// the cycle guard is per-element, not shared across siblings.
|
||||
foreach (var kv in d.Children)
|
||||
result.Children.Add(Resolve(dats, kv.Value, new HashSet<(uint, uint)>()));
|
||||
|
||||
// Sub-window mount: a pure-container leaf (no own children, no own media) that inherits
|
||||
// from a base WITH content attaches the base's subtree. Targets the gmInventoryUI panels
|
||||
// (0x100001CD/CE/CF — Type-0, media-less, childless, BaseLayoutId → a gm*UI window);
|
||||
// inert for media-bearing inheritors (close button/title) and childless style prototypes
|
||||
// (vitals/chat/toolbar). See ShouldMountBaseChildren + the B-Grid spec.
|
||||
if (baseChildren is not null
|
||||
&& ShouldMountBaseChildren(d.Children.Count, self.StateMedia.Count, baseChildren.Count))
|
||||
result.Children.AddRange(baseChildren);
|
||||
|
||||
return result;
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Run the tests to verify they pass**
|
||||
|
||||
Run: `dotnet test tests/AcDream.App.Tests/AcDream.App.Tests.csproj --filter "FullyQualifiedName~LayoutImporterMountTests"`
|
||||
Expected: PASS (4 tests).
|
||||
|
||||
- [ ] **Step 6: Commit**
|
||||
|
||||
```bash
|
||||
git add src/AcDream.App/UI/Layout/LayoutImporter.cs tests/AcDream.App.Tests/UI/LayoutImporterMountTests.cs
|
||||
git commit -m "feat(ui): D.2b-B — sub-window mount (inheritor attaches base subtree)
|
||||
|
||||
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 3: Swap the placeholder for the real import (`GameWindow`)
|
||||
|
||||
No unit tests — GL-coupled, runs only with `ACDREAM_RETAIL_UI=1`; verified by build + the visual check in Task 4.
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/AcDream.App/Rendering/GameWindow.cs` (the Sub-phase A placeholder block in `_options.RetailUi`)
|
||||
|
||||
- [ ] **Step 1: Replace the placeholder mount**
|
||||
|
||||
In `src/AcDream.App/Rendering/GameWindow.cs`, find the Sub-phase A placeholder block:
|
||||
|
||||
```csharp
|
||||
// Phase D.2b-A — placeholder inventory window. Starts HIDDEN; F12
|
||||
// (InputAction.ToggleInventoryPanel) reveals it via the UiRoot window
|
||||
// manager. Throwaway scaffolding: Sub-phase B replaces the body with the
|
||||
// real gmInventoryUI (0x21000023) nested layout, keeping the same name.
|
||||
var inventoryWindow = new AcDream.App.UI.UiNineSlicePanel(ResolveChrome)
|
||||
{
|
||||
Left = 220, Top = 120, Width = 320, Height = 400,
|
||||
Visible = false,
|
||||
};
|
||||
_uiHost.Root.AddChild(inventoryWindow);
|
||||
_uiHost.RegisterWindow(AcDream.App.UI.WindowNames.Inventory, inventoryWindow);
|
||||
Console.WriteLine("[D.2b-A] placeholder inventory window registered (F12 toggles).");
|
||||
```
|
||||
|
||||
Replace it with the real import:
|
||||
|
||||
```csharp
|
||||
// Phase D.2b-B — the real inventory window from LayoutDesc 0x21000023 (gmInventoryUI),
|
||||
// via the LayoutImporter. The sub-window mount pulls in the nested paperdoll/backpack/
|
||||
// 3D-items panels (Type-0 leaves inheriting BaseLayoutId 0x21000024/22/21). Starts
|
||||
// HIDDEN; F12 toggles it via the window manager. Position is the dat's own (X=500,Y=138).
|
||||
AcDream.App.UI.Layout.ImportedLayout? invLayout;
|
||||
lock (_datLock)
|
||||
invLayout = AcDream.App.UI.Layout.LayoutImporter.Import(
|
||||
_dats!, 0x21000023u, ResolveChrome, vitalsDatFont);
|
||||
if (invLayout is not null)
|
||||
{
|
||||
var inventoryWindow = invLayout.Root;
|
||||
inventoryWindow.Visible = false;
|
||||
inventoryWindow.Anchors = AcDream.App.UI.AnchorEdges.None; // user-positioned
|
||||
inventoryWindow.Draggable = true;
|
||||
_uiHost.Root.AddChild(inventoryWindow);
|
||||
_uiHost.RegisterWindow(AcDream.App.UI.WindowNames.Inventory, inventoryWindow);
|
||||
Console.WriteLine("[D.2b-B] retail inventory window from LayoutDesc importer (0x21000023).");
|
||||
}
|
||||
else Console.WriteLine("[D.2b-B] inventory: LayoutDesc 0x21000023 not found.");
|
||||
```
|
||||
|
||||
(`_dats`, `_datLock`, `ResolveChrome`, and `vitalsDatFont` are all in scope in this block — the vitals/chat imports above use the same.)
|
||||
|
||||
- [ ] **Step 2: Build to verify it compiles**
|
||||
|
||||
Run: `dotnet build src/AcDream.App/AcDream.App.csproj -c Debug`
|
||||
Expected: Build succeeded, 0 errors.
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add src/AcDream.App/Rendering/GameWindow.cs
|
||||
git commit -m "feat(ui): D.2b-B — F12 shows the real gmInventoryUI (0x21000023)
|
||||
|
||||
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 4: Regression guard + full verification
|
||||
|
||||
- [ ] **Step 1: Run the full test suite (regression guard)**
|
||||
|
||||
Run: `dotnet test`
|
||||
Expected: PASS — full suite green (2757 baseline + 3 grid + 4 mount-predicate = ~2764). The existing `LayoutImporter` / vitals / chat / toolbar tests passing confirms the mount is inert for those windows (their bases are childless prototypes).
|
||||
|
||||
- [ ] **Step 2: Hand off for visual verification**
|
||||
|
||||
Launch with `ACDREAM_RETAIL_UI=1` (standard launch in CLAUDE.md) and confirm with the user:
|
||||
- F12 now shows the **real nested inventory frame** (outer chrome + backpack strip on the right + a 3D-items area + an empty paperdoll panel), not the blank placeholder.
|
||||
- F12 again hides it; clicking it raises it; it doesn't toggle while chat is focused (Sub-phase A behavior intact).
|
||||
- **Vitals, chat, and toolbar look unchanged** (the mount regression guard, visually).
|
||||
|
||||
Watch the launch log for `[D.2b-B] retail inventory window from LayoutDesc importer (0x21000023).` and the absence of importer errors.
|
||||
|
||||
Do NOT mark B-Grid done until the user confirms the visual.
|
||||
|
||||
---
|
||||
|
||||
## Self-Review
|
||||
|
||||
**Spec coverage:**
|
||||
- Sub-window mount (spec §4) → Task 2 (`ShouldMountBaseChildren` + `Resolve`). ✓
|
||||
- `UiItemList` grid mode (spec §5) → Task 1. ✓
|
||||
- Placeholder → real import swap (spec §9) → Task 3. ✓
|
||||
- Grid + mount-predicate tests (spec §6) → Tasks 1–2. ✓
|
||||
- Regression guard, vitals/chat/toolbar unchanged (spec §6/§8) → Task 4 Step 1 (suite) + Step 2 (visual). ✓
|
||||
- No divergence row (spec §7) → nothing to add. ✓
|
||||
- Visual acceptance (spec §8) → Task 4 Step 2. ✓
|
||||
|
||||
**Placeholder scan:** No TBD/TODO/"handle edge cases"; every code step shows full code. ✓
|
||||
|
||||
**Type consistency:** `Columns`/`CellWidth`/`CellHeight`/`CellOffset`/`LayoutCells` (Task 1), `ShouldMountBaseChildren(int,int,int)` (Task 2 — same signature in predicate, test, and `Resolve` call site), `LayoutImporter.Import(_dats, 0x21000023u, ResolveChrome, vitalsDatFont)` (Task 3, matches the vitals/chat call shape). ✓
|
||||
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.
|
||||
472
docs/superpowers/plans/2026-06-20-d2b-window-manager.md
Normal file
472
docs/superpowers/plans/2026-06-20-d2b-window-manager.md
Normal file
|
|
@ -0,0 +1,472 @@
|
|||
# D.2b Window Manager 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:** Add open/close/raise for top-level retail-UI windows and toggle a (placeholder) inventory window with F12.
|
||||
|
||||
**Architecture:** A named-window registry on `UiRoot` (`RegisterWindow` / `ShowWindow` / `HideWindow` / `ToggleWindow` / `BringToFront`) — `UiRoot` already owns the top-level children, `ZOrder`, hit-test, and focus, so window show/hide/raise is its job. `Show`/`Hide` flip `UiElement.Visible` (which already gates Draw/Tick/HitTest); `BringToFront` bumps `ZOrder` above peers. The existing F12-bound `InputAction.ToggleInventoryPanel` is wired through `OnInputAction` to toggle a throwaway placeholder window that Sub-phase B replaces with the real `gmInventoryUI`.
|
||||
|
||||
**Tech Stack:** C# .NET 10, xUnit, the in-tree `AcDream.App.UI` retained-mode toolkit (`UiRoot`/`UiElement`/`UiHost`/`UiNineSlicePanel`), the `InputDispatcher` input pipeline.
|
||||
|
||||
**Spec:** `docs/superpowers/specs/2026-06-20-d2b-window-manager-design.md`
|
||||
|
||||
---
|
||||
|
||||
## File Structure
|
||||
|
||||
- **Create** `src/AcDream.App/UI/WindowNames.cs` — canonical registry-name constants (one literal shared by mount, registry, toggle).
|
||||
- **Modify** `src/AcDream.App/UI/UiRoot.cs` — add the window registry + `BringToFront`; add raise-on-click in `OnMouseDown`.
|
||||
- **Modify** `src/AcDream.App/UI/UiHost.cs` — two forwarders (`RegisterWindow`, `ToggleWindow`) delegating to `Root`.
|
||||
- **Modify** `src/AcDream.App/Rendering/GameWindow.cs` — mount the placeholder inventory window (default-hidden) in the `_options.RetailUi` block; add the `ToggleInventoryPanel` case in `OnInputAction`.
|
||||
- **Modify** `tests/AcDream.App.Tests/UI/UiRootInputTests.cs` — registry + z-order + raise-on-click unit tests.
|
||||
- **Modify** `docs/architecture/retail-divergence-register.md` — extend IA-12 "Where" to cite the window manager.
|
||||
|
||||
---
|
||||
|
||||
## Task 1: `WindowNames` + `UiRoot` window registry (visibility)
|
||||
|
||||
**Files:**
|
||||
- Create: `src/AcDream.App/UI/WindowNames.cs`
|
||||
- Modify: `src/AcDream.App/UI/UiRoot.cs` (insert after `ReleaseCapture`, ~line 475)
|
||||
- Test: `tests/AcDream.App.Tests/UI/UiRootInputTests.cs`
|
||||
|
||||
- [ ] **Step 1: Create the `WindowNames` constant holder**
|
||||
|
||||
Create `src/AcDream.App/UI/WindowNames.cs`:
|
||||
|
||||
```csharp
|
||||
namespace AcDream.App.UI;
|
||||
|
||||
/// <summary>Canonical registry names for top-level retail-UI windows, so the
|
||||
/// mount, the window registry, and the toggle keybind all agree on one literal.</summary>
|
||||
public static class WindowNames
|
||||
{
|
||||
public const string Inventory = "inventory";
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Write the failing tests**
|
||||
|
||||
Append to `tests/AcDream.App.Tests/UI/UiRootInputTests.cs`, inside the class (before the final closing `}`):
|
||||
|
||||
```csharp
|
||||
[Fact]
|
||||
public void ToggleWindow_FlipsVisible_AndReturnsNewState()
|
||||
{
|
||||
var root = new UiRoot { Width = 800, Height = 600 };
|
||||
var win = new UiPanel { Width = 100, Height = 100, Visible = false };
|
||||
root.AddChild(win);
|
||||
root.RegisterWindow("inventory", win);
|
||||
|
||||
Assert.True(root.ToggleWindow("inventory")); // hidden -> shown
|
||||
Assert.True(win.Visible);
|
||||
Assert.False(root.ToggleWindow("inventory")); // shown -> hidden
|
||||
Assert.False(win.Visible);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ShowHideWindow_SetVisibility_UnknownNameIsNoOp()
|
||||
{
|
||||
var root = new UiRoot { Width = 800, Height = 600 };
|
||||
var win = new UiPanel { Width = 100, Height = 100, Visible = false };
|
||||
root.AddChild(win);
|
||||
root.RegisterWindow("inventory", win);
|
||||
|
||||
Assert.True(root.ShowWindow("inventory"));
|
||||
Assert.True(win.Visible);
|
||||
Assert.True(root.HideWindow("inventory"));
|
||||
Assert.False(win.Visible);
|
||||
|
||||
Assert.False(root.ShowWindow("nope"));
|
||||
Assert.False(root.HideWindow("nope"));
|
||||
Assert.False(root.ToggleWindow("nope"));
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Run the tests to verify they fail**
|
||||
|
||||
Run: `dotnet test tests/AcDream.App.Tests/AcDream.App.Tests.csproj --filter "FullyQualifiedName~UiRootInputTests"`
|
||||
Expected: FAIL — compile error, `'UiRoot' does not contain a definition for 'RegisterWindow'`.
|
||||
|
||||
- [ ] **Step 4: Implement the registry**
|
||||
|
||||
In `src/AcDream.App/UI/UiRoot.cs`, find:
|
||||
|
||||
```csharp
|
||||
public void SetCapture(UiElement e) => Captured = e;
|
||||
public void ReleaseCapture() => Captured = null;
|
||||
```
|
||||
|
||||
Insert immediately after it:
|
||||
|
||||
```csharp
|
||||
|
||||
// ── Window manager (named top-level windows: Show / Hide / Toggle) ───
|
||||
|
||||
private readonly Dictionary<string, UiElement> _windows = new();
|
||||
|
||||
/// <summary>Register a top-level window under a name for Show/Hide/Toggle.
|
||||
/// Does NOT add it to the tree — the caller mounts via AddChild and controls
|
||||
/// initial Visible. Idempotent (re-register replaces). The dict is the source
|
||||
/// of truth — independent of UiElement.Name (init-only, not set here).</summary>
|
||||
public void RegisterWindow(string name, UiElement window) => _windows[name] = window;
|
||||
|
||||
/// <summary>Make the named window visible. No-op (returns false) if unknown.</summary>
|
||||
public bool ShowWindow(string name)
|
||||
{
|
||||
if (!_windows.TryGetValue(name, out var w)) return false;
|
||||
w.Visible = true;
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>Hide the named window. No-op (returns false) if unknown.</summary>
|
||||
public bool HideWindow(string name)
|
||||
{
|
||||
if (!_windows.TryGetValue(name, out var w)) return false;
|
||||
w.Visible = false;
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>Flip the named window's visibility (Show if hidden, Hide if shown).
|
||||
/// Returns the new IsVisible state (false for an unknown name).</summary>
|
||||
public bool ToggleWindow(string name)
|
||||
{
|
||||
if (!_windows.TryGetValue(name, out var w)) return false;
|
||||
if (w.Visible) { HideWindow(name); return false; }
|
||||
ShowWindow(name);
|
||||
return true;
|
||||
}
|
||||
```
|
||||
|
||||
(`using System.Collections.Generic;` is already present at the top of `UiRoot.cs`.)
|
||||
|
||||
- [ ] **Step 5: Run the tests to verify they pass**
|
||||
|
||||
Run: `dotnet test tests/AcDream.App.Tests/AcDream.App.Tests.csproj --filter "FullyQualifiedName~UiRootInputTests"`
|
||||
Expected: PASS (all `UiRootInputTests`, including the two new ones).
|
||||
|
||||
- [ ] **Step 6: Commit**
|
||||
|
||||
```bash
|
||||
git add src/AcDream.App/UI/WindowNames.cs src/AcDream.App/UI/UiRoot.cs tests/AcDream.App.Tests/UI/UiRootInputTests.cs
|
||||
git commit -m "feat(ui): D.2b-A — UiRoot named-window registry (Show/Hide/Toggle)
|
||||
|
||||
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 2: `BringToFront` + raise-on-show + raise-on-click
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/AcDream.App/UI/UiRoot.cs` (add `BringToFront`; edit `ShowWindow` + `OnMouseDown`)
|
||||
- Test: `tests/AcDream.App.Tests/UI/UiRootInputTests.cs`
|
||||
|
||||
- [ ] **Step 1: Write the failing tests**
|
||||
|
||||
Append to `tests/AcDream.App.Tests/UI/UiRootInputTests.cs`, inside the class:
|
||||
|
||||
```csharp
|
||||
[Fact]
|
||||
public void ShowWindow_RaisesAbovePeers()
|
||||
{
|
||||
var root = new UiRoot { Width = 800, Height = 600 };
|
||||
var a = new UiPanel { Width = 100, Height = 100 };
|
||||
var b = new UiPanel { Width = 100, Height = 100 };
|
||||
var win = new UiPanel { Width = 100, Height = 100, Visible = false };
|
||||
root.AddChild(a); root.AddChild(b); root.AddChild(win);
|
||||
root.RegisterWindow("inventory", win);
|
||||
|
||||
root.ShowWindow("inventory");
|
||||
Assert.True(win.ZOrder > a.ZOrder);
|
||||
Assert.True(win.ZOrder > b.ZOrder);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BringToFront_SetsStrictlyGreatestZOrder()
|
||||
{
|
||||
var root = new UiRoot { Width = 800, Height = 600 };
|
||||
var a = new UiPanel { Width = 100, Height = 100, ZOrder = 5 };
|
||||
var b = new UiPanel { Width = 100, Height = 100, ZOrder = 9 };
|
||||
root.AddChild(a); root.AddChild(b);
|
||||
|
||||
root.BringToFront(a);
|
||||
Assert.True(a.ZOrder > b.ZOrder); // 10 > 9
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MouseDown_OnWindow_RaisesItAbovePeers()
|
||||
{
|
||||
var root = new UiRoot { Width = 800, Height = 600 };
|
||||
// 'peer' has a higher ZOrder but does NOT cover (50,50); 'clicked' does.
|
||||
var peer = new UiPanel { Left = 300, Top = 0, Width = 100, Height = 100, ZOrder = 9 };
|
||||
var clicked = new UiPanel { Left = 0, Top = 0, Width = 100, Height = 100, ZOrder = 0, Draggable = true };
|
||||
root.AddChild(peer); root.AddChild(clicked);
|
||||
|
||||
root.OnMouseDown(UiMouseButton.Left, 50, 50); // only 'clicked' occupies (50,50)
|
||||
Assert.True(clicked.ZOrder > peer.ZOrder);
|
||||
root.OnMouseUp(UiMouseButton.Left, 50, 50);
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run the tests to verify they fail**
|
||||
|
||||
Run: `dotnet test tests/AcDream.App.Tests/AcDream.App.Tests.csproj --filter "FullyQualifiedName~UiRootInputTests"`
|
||||
Expected: FAIL — compile error, `'UiRoot' does not contain a definition for 'BringToFront'`.
|
||||
|
||||
- [ ] **Step 3: Add `BringToFront`**
|
||||
|
||||
In `src/AcDream.App/UI/UiRoot.cs`, find the end of `ToggleWindow` (added in Task 1):
|
||||
|
||||
```csharp
|
||||
if (w.Visible) { HideWindow(name); return false; }
|
||||
ShowWindow(name);
|
||||
return true;
|
||||
}
|
||||
```
|
||||
|
||||
Insert immediately after it:
|
||||
|
||||
```csharp
|
||||
|
||||
/// <summary>Raise a top-level window above its siblings by setting its ZOrder
|
||||
/// one past the current max among the OTHER top-level children. Used on Show
|
||||
/// and on click. Leaves ZOrder unchanged if it is the only / already-topmost child.</summary>
|
||||
public void BringToFront(UiElement window)
|
||||
{
|
||||
int top = window.ZOrder;
|
||||
foreach (var c in Children)
|
||||
if (!ReferenceEquals(c, window))
|
||||
top = System.Math.Max(top, c.ZOrder + 1);
|
||||
window.ZOrder = top;
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Make `ShowWindow` raise**
|
||||
|
||||
In `src/AcDream.App/UI/UiRoot.cs`, find (inside `ShowWindow`):
|
||||
|
||||
```csharp
|
||||
if (!_windows.TryGetValue(name, out var w)) return false;
|
||||
w.Visible = true;
|
||||
return true;
|
||||
```
|
||||
|
||||
Replace with:
|
||||
|
||||
```csharp
|
||||
if (!_windows.TryGetValue(name, out var w)) return false;
|
||||
w.Visible = true;
|
||||
BringToFront(w);
|
||||
return true;
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Add raise-on-click in `OnMouseDown`**
|
||||
|
||||
In `src/AcDream.App/UI/UiRoot.cs`, find (in `OnMouseDown`):
|
||||
|
||||
```csharp
|
||||
var window = FindWindow(target);
|
||||
if (btn == UiMouseButton.Left && window is not null)
|
||||
```
|
||||
|
||||
Replace with:
|
||||
|
||||
```csharp
|
||||
var window = FindWindow(target);
|
||||
// Retail-faithful: pressing on a window raises it above its peers.
|
||||
if (window is not null) BringToFront(window);
|
||||
if (btn == UiMouseButton.Left && window is not null)
|
||||
```
|
||||
|
||||
- [ ] **Step 6: Run the tests to verify they pass**
|
||||
|
||||
Run: `dotnet test tests/AcDream.App.Tests/AcDream.App.Tests.csproj --filter "FullyQualifiedName~UiRootInputTests"`
|
||||
Expected: PASS — all `UiRootInputTests` (the new three plus the existing window-drag/resize tests, which still pass: raise-on-click only changes `ZOrder`, not geometry).
|
||||
|
||||
- [ ] **Step 7: Commit**
|
||||
|
||||
```bash
|
||||
git add src/AcDream.App/UI/UiRoot.cs tests/AcDream.App.Tests/UI/UiRootInputTests.cs
|
||||
git commit -m "feat(ui): D.2b-A — BringToFront + raise on show/click
|
||||
|
||||
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 3: `UiHost` forwarders + `GameWindow` wiring (placeholder + F12)
|
||||
|
||||
No unit tests — these sites are GL-coupled (`UiHost` needs a `GL`) and only run with `ACDREAM_RETAIL_UI=1`; they are verified by build + the visual check in Task 4.
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/AcDream.App/UI/UiHost.cs` (add forwarders before `Dispose`)
|
||||
- Modify: `src/AcDream.App/Rendering/GameWindow.cs` (placeholder mount in the `_options.RetailUi` block; `OnInputAction` case)
|
||||
|
||||
- [ ] **Step 1: Add `UiHost` forwarders**
|
||||
|
||||
In `src/AcDream.App/UI/UiHost.cs`, find:
|
||||
|
||||
```csharp
|
||||
public void Dispose()
|
||||
{
|
||||
TextRenderer.Dispose();
|
||||
}
|
||||
```
|
||||
|
||||
Insert immediately before it:
|
||||
|
||||
```csharp
|
||||
// ── Window manager forwarders (delegate to UiRoot) ─────────────────
|
||||
|
||||
/// <summary>Register a top-level window for Show/Hide/Toggle. See <see cref="UiRoot.RegisterWindow"/>.</summary>
|
||||
public void RegisterWindow(string name, UiElement window) => Root.RegisterWindow(name, window);
|
||||
|
||||
/// <summary>Toggle a registered window's visibility; returns the new IsVisible.</summary>
|
||||
public bool ToggleWindow(string name) => Root.ToggleWindow(name);
|
||||
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Mount the placeholder inventory window**
|
||||
|
||||
In `src/AcDream.App/Rendering/GameWindow.cs`, find the END of the plugin-panel drain, which is the last block inside `if (_options.RetailUi)`:
|
||||
|
||||
```csharp
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.WriteLine($"[D.2b] plugin UI panel '{p.MarkupPath}' failed to load: {ex.Message}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Insert the placeholder mount between the inner `}` (closing `if (_uiRegistry is not null)`) and the outer `}` (closing `if (_options.RetailUi)`) — i.e. make it the last statement inside the `_options.RetailUi` block:
|
||||
|
||||
```csharp
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.WriteLine($"[D.2b] plugin UI panel '{p.MarkupPath}' failed to load: {ex.Message}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Phase D.2b-A — placeholder inventory window. Starts HIDDEN; F12
|
||||
// (InputAction.ToggleInventoryPanel) reveals it via the UiRoot window
|
||||
// manager. Throwaway scaffolding: Sub-phase B replaces the body with the
|
||||
// real gmInventoryUI (0x21000023) nested layout, keeping the same name.
|
||||
var inventoryWindow = new AcDream.App.UI.UiNineSlicePanel(ResolveChrome)
|
||||
{
|
||||
Left = 220, Top = 120, Width = 320, Height = 400,
|
||||
Visible = false,
|
||||
};
|
||||
_uiHost.Root.AddChild(inventoryWindow);
|
||||
_uiHost.RegisterWindow(AcDream.App.UI.WindowNames.Inventory, inventoryWindow);
|
||||
Console.WriteLine("[D.2b-A] placeholder inventory window registered (F12 toggles).");
|
||||
}
|
||||
```
|
||||
|
||||
(`ResolveChrome` and `_uiHost` are both in scope here — `ResolveChrome` is the local function defined earlier in the same block; `_uiHost` is the field assigned at the top of the block.)
|
||||
|
||||
- [ ] **Step 3: Wire F12 → toggle in `OnInputAction`**
|
||||
|
||||
In `src/AcDream.App/Rendering/GameWindow.cs`, find the first arm of the `switch (action)` in `OnInputAction`:
|
||||
|
||||
```csharp
|
||||
case AcDream.UI.Abstractions.Input.InputAction.AcdreamToggleDebugPanel:
|
||||
```
|
||||
|
||||
Insert this case immediately before it:
|
||||
|
||||
```csharp
|
||||
case AcDream.UI.Abstractions.Input.InputAction.ToggleInventoryPanel:
|
||||
// Retail F12 (rebindable). Gated upstream by WantsKeyboard, so it
|
||||
// does not fire while the chat input holds focus. Null _uiHost =
|
||||
// retail UI off (ACDREAM_RETAIL_UI unset) → no-op.
|
||||
_uiHost?.ToggleWindow(AcDream.App.UI.WindowNames.Inventory);
|
||||
break;
|
||||
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Build to verify it compiles**
|
||||
|
||||
Run: `dotnet build src/AcDream.App/AcDream.App.csproj -c Debug`
|
||||
Expected: Build succeeded, 0 errors.
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add src/AcDream.App/UI/UiHost.cs src/AcDream.App/Rendering/GameWindow.cs
|
||||
git commit -m "feat(ui): D.2b-A — F12 toggles placeholder inventory window
|
||||
|
||||
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 4: Divergence register + full verification
|
||||
|
||||
**Files:**
|
||||
- Modify: `docs/architecture/retail-divergence-register.md` (extend IA-12 "Where")
|
||||
|
||||
- [ ] **Step 1: Extend IA-12 to cite the window manager**
|
||||
|
||||
In `docs/architecture/retail-divergence-register.md`, find the IA-12 row's "Where" cell:
|
||||
|
||||
```
|
||||
| IA-12 | UI toolkit mirrors retail behavior from research docs, not a byte-port — keystone.dll is outside decomp coverage; observed constants embedded (drag 3 px, tooltip 1000 ms) | `src/AcDream.App/UI/README.md:3` | keystone.dll has no PDB/decomp; semantics reconstructed from the six `docs/research/retail-ui/` deep-dives, keeping retail's event-type constants so panel switch-cases transplant cleanly | Edge-case input semantics the research under-specified (drag threshold, tooltip timing, focus hand-off, capture corners) differ silently with no oracle to diff against | keystone.dll Device DAT_00837ff4; docs/research/retail-ui/04-input-events.md |
|
||||
```
|
||||
|
||||
Replace the "Where" cell `` `src/AcDream.App/UI/README.md:3` `` with:
|
||||
|
||||
```
|
||||
`src/AcDream.App/UI/README.md:3` (window mgr: `UiRoot.cs` RegisterWindow/Show/Hide/Toggle/BringToFront — F12 inventory toggle IS retail-faithful)
|
||||
```
|
||||
|
||||
So the full row becomes:
|
||||
|
||||
```
|
||||
| IA-12 | UI toolkit mirrors retail behavior from research docs, not a byte-port — keystone.dll is outside decomp coverage; observed constants embedded (drag 3 px, tooltip 1000 ms) | `src/AcDream.App/UI/README.md:3` (window mgr: `UiRoot.cs` RegisterWindow/Show/Hide/Toggle/BringToFront — F12 inventory toggle IS retail-faithful) | keystone.dll has no PDB/decomp; semantics reconstructed from the six `docs/research/retail-ui/` deep-dives, keeping retail's event-type constants so panel switch-cases transplant cleanly | Edge-case input semantics the research under-specified (drag threshold, tooltip timing, focus hand-off, capture corners) differ silently with no oracle to diff against | keystone.dll Device DAT_00837ff4; docs/research/retail-ui/04-input-events.md |
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run the full test suite**
|
||||
|
||||
Run: `dotnet test`
|
||||
Expected: PASS — full suite green (2752+ tests; the window-manager facts add ~5).
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add docs/architecture/retail-divergence-register.md
|
||||
git commit -m "docs(register): D.2b-A — extend IA-12 to cite the window manager
|
||||
|
||||
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>"
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Hand off for visual verification**
|
||||
|
||||
Stop and ask the user to launch the client (`ACDREAM_RETAIL_UI=1`, the standard launch in CLAUDE.md) and confirm:
|
||||
- F12 shows the placeholder inventory window above vitals/chat/toolbar; F12 again hides it.
|
||||
- Clicking any window (vitals/chat/toolbar/inventory) raises it above the others.
|
||||
- F12 does nothing while the chat input is focused (write mode).
|
||||
|
||||
Do NOT mark Sub-phase A done until the user confirms the visual behavior.
|
||||
|
||||
---
|
||||
|
||||
## Self-Review
|
||||
|
||||
**Spec coverage:**
|
||||
- Registry on `UiRoot` (§4.1) → Task 1. ✓
|
||||
- `BringToFront` + raise-on-click (§4.2) → Task 2. ✓
|
||||
- `UiHost` forwarders (§4.3) → Task 3 Step 1. ✓
|
||||
- F12 wiring in `OnInputAction` (§4.4) → Task 3 Step 3. ✓
|
||||
- Placeholder inventory window (§4.5) → Task 3 Step 2. ✓
|
||||
- In-memory persistence (§5) → no code needed (Visible + hardcoded positions); covered by construction. ✓
|
||||
- Tests (§6) → Tasks 1–2. ✓
|
||||
- Divergence register (§7) → Task 4 Step 1 (extend IA-12, dedup convention). ✓
|
||||
- Acceptance (§8): build+tests green → Tasks 1–4; visual → Task 4 Step 4. ✓
|
||||
|
||||
**Placeholder scan:** No TBD/TODO/"handle edge cases"/"similar to" — every code step shows full code. ✓
|
||||
|
||||
**Type consistency:** `RegisterWindow(string, UiElement)`, `ShowWindow/HideWindow/ToggleWindow(string)→bool`, `BringToFront(UiElement)`, `WindowNames.Inventory`, `InputAction.ToggleInventoryPanel`, `_uiHost.ToggleWindow` — names identical across Tasks 1–3 and the spec. ✓
|
||||
976
docs/superpowers/plans/2026-06-21-d2b-inventory-controller.md
Normal file
976
docs/superpowers/plans/2026-06-21-d2b-inventory-controller.md
Normal file
|
|
@ -0,0 +1,976 @@
|
|||
# D.2b B-Controller — Inventory Population 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 the F12 inventory window show the player's live contents — populate the "Contents of Backpack" grid + the right-strip pack-selector from `ClientObjectTable`, drive the vertical burden meter, and render the Type-0 captions.
|
||||
|
||||
**Architecture:** A new `InventoryController` (the `gm*UI::PostInit` analogue) binds the imported `gmInventoryUI` (`LayoutDesc 0x21000023`) tree by element id, partitions `GetContents(player)` into loose items + side bags, fills the `UiItemList` grids (reusing B-Grid's grid mode), drives a faithful burden meter (one new vertical-fill path on `UiMeter`), and attaches caption `UiText` overlays. Pure AC math (encumbrance) lives in `Core`; the controller + widget live in `App`. Bound inline in GameWindow's existing inventory-init block.
|
||||
|
||||
**Tech Stack:** C# .NET 10, xUnit, the shipped D.2b toolkit (`UiItemList`/`UiItemSlot`/`UiMeter`/`UiText`/`ImportedLayout`/`IconComposer`).
|
||||
|
||||
**Spec:** `docs/superpowers/specs/2026-06-21-d2b-inventory-controller-design.md`
|
||||
|
||||
---
|
||||
|
||||
## File Structure
|
||||
|
||||
| File | Responsibility | Create/Modify |
|
||||
|---|---|---|
|
||||
| `src/AcDream.Core/Items/ClientObject.cs` (`BurdenMath`) | Encumbrance math (capacity, load ratio, fill, percent) — ports of `EncumbranceSystem` + `SetLoadLevel` | Modify |
|
||||
| `src/AcDream.Core/Items/ClientObjectTable.cs` | `SumCarriedBurden(owner)` — client-side carried-Burden fallback | Modify |
|
||||
| `src/AcDream.App/UI/UiMeter.cs` | Vertical fill path (the burden bar is vertical) | Modify |
|
||||
| `src/AcDream.App/UI/Layout/InventoryController.cs` | The controller: bind by id, populate grids, drive burden, captions | Create |
|
||||
| `src/AcDream.App/Rendering/GameWindow.cs` | Wire `InventoryController.Bind` into the inventory-init block | Modify (~2149) |
|
||||
| `tests/AcDream.Core.Tests/Items/BurdenMathTests.cs` | Encumbrance golden values | Create |
|
||||
| `tests/AcDream.Core.Tests/Items/ClientObjectTableBurdenTests.cs` | `SumCarriedBurden` | Create |
|
||||
| `tests/AcDream.App.Tests/UI/UiMeterVerticalTests.cs` | Vertical fill rect | Create |
|
||||
| `tests/AcDream.App.Tests/UI/Layout/InventoryControllerTests.cs` | Bind + populate + partition + burden + captions | Create |
|
||||
| `docs/architecture/retail-divergence-register.md` | New AP rows | Modify |
|
||||
|
||||
**Reference (read before coding):** spec §1 (element id table), §4 (burden formula + decomp anchors), and the live code: `ToolbarController.cs` (controller pattern), `SelectedObjectController.cs` (UiText-attach pattern), `UiItemList.cs` (grid mode), `DatWidgetFactory.BuildMeter` (meter sprite assignment — already correct for the burden meter).
|
||||
|
||||
---
|
||||
|
||||
## Task 1: Encumbrance math (`BurdenMath`)
|
||||
|
||||
Port `EncumbranceSystem::EncumbranceCapacity` (decomp 256393, `0x004fcc00`), `EncumbranceSystem::Load` (256413, `0x004fcc40`), and the `gmBackpackUI::SetLoadLevel` fill/percent transform (176536, `0x004a6ea0`). `BurdenMath` already has `BurdenPerStrength = 150` and `ComputeMax`.
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/AcDream.Core/Items/ClientObject.cs` (the `BurdenMath` static class, ~line 215)
|
||||
- Test: `tests/AcDream.Core.Tests/Items/BurdenMathTests.cs`
|
||||
|
||||
- [ ] **Step 1: Write the failing test**
|
||||
|
||||
Create `tests/AcDream.Core.Tests/Items/BurdenMathTests.cs`:
|
||||
|
||||
```csharp
|
||||
using AcDream.Core.Items;
|
||||
using Xunit;
|
||||
|
||||
namespace AcDream.Core.Tests.Items;
|
||||
|
||||
public class BurdenMathTests
|
||||
{
|
||||
[Theory]
|
||||
[InlineData(100, 0, 15000)] // base: str*150
|
||||
[InlineData(100, 3, 24000)] // +clamp(3*30,0,150)=90 -> +90*100
|
||||
[InlineData(100, 10, 30000)] // 10*30=300 clamped to 150 -> +150*100
|
||||
[InlineData(0, 5, 0)] // str<=0 -> 0
|
||||
public void EncumbranceCapacity_matches_retail(int str, int aug, int expected)
|
||||
=> Assert.Equal(expected, BurdenMath.EncumbranceCapacity(str, aug));
|
||||
|
||||
[Theory]
|
||||
[InlineData(15000, 7500, 0.5f)]
|
||||
[InlineData(15000, 0, 0f)]
|
||||
[InlineData(0, 100, 0f)] // cap<=0 guard
|
||||
public void LoadRatio_is_burden_over_capacity(int cap, int burden, float expected)
|
||||
=> Assert.Equal(expected, BurdenMath.LoadRatio(cap, burden), 4);
|
||||
|
||||
[Theory]
|
||||
[InlineData(0f, 0f)]
|
||||
[InlineData(0.5f, 0.16667f)]
|
||||
[InlineData(1.0f, 0.33333f)]
|
||||
[InlineData(3.0f, 1.0f)] // full at 300%
|
||||
[InlineData(4.0f, 1.0f)] // clamped
|
||||
public void LoadToFill_is_third_clamped(float load, float expected)
|
||||
=> Assert.Equal(expected, BurdenMath.LoadToFill(load), 4);
|
||||
|
||||
[Theory]
|
||||
[InlineData(0f, 0)]
|
||||
[InlineData(0.5f, 50)]
|
||||
[InlineData(1.0f, 100)]
|
||||
[InlineData(3.0f, 300)]
|
||||
[InlineData(4.0f, 400)] // percent NOT clamped
|
||||
public void LoadToPercent_is_floor_load_times_100(float load, int expected)
|
||||
=> Assert.Equal(expected, BurdenMath.LoadToPercent(load));
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run test to verify it fails**
|
||||
|
||||
Run: `dotnet test tests/AcDream.Core.Tests/AcDream.Core.Tests.csproj --filter "FullyQualifiedName~BurdenMathTests"`
|
||||
Expected: FAIL — `BurdenMath` has no `EncumbranceCapacity`/`LoadRatio`/`LoadToFill`/`LoadToPercent` (compile error).
|
||||
|
||||
- [ ] **Step 3: Add the methods to `BurdenMath`**
|
||||
|
||||
In `src/AcDream.Core/Items/ClientObject.cs`, inside `public static class BurdenMath`, add (keep existing members):
|
||||
|
||||
```csharp
|
||||
/// <summary>Augmentation bonus-burden per aug rank (retail EncumbranceCapacity, 0x1e).</summary>
|
||||
public const int AugBurdenPerRank = 30;
|
||||
/// <summary>Max augmentation bonus-burden contribution per Strength (retail clamp 0x96).</summary>
|
||||
public const int AugBurdenCap = 150;
|
||||
|
||||
/// <summary>
|
||||
/// Retail <c>EncumbranceSystem::EncumbranceCapacity(strength, aug)</c>
|
||||
/// (named-retail decomp 256393, <c>0x004fcc00</c>):
|
||||
/// <c>strength <= 0 ? 0 : strength*150 + clamp(aug*30, 0, 150)*strength</c>.
|
||||
/// </summary>
|
||||
public static int EncumbranceCapacity(int strength, int aug)
|
||||
{
|
||||
if (strength <= 0) return 0;
|
||||
int bonus = aug * AugBurdenPerRank;
|
||||
if (bonus < 0) bonus = 0; // decomp: eax_2 < 0 -> base only
|
||||
if (bonus > AugBurdenCap) bonus = AugBurdenCap;
|
||||
return strength * BurdenPerStrength + bonus * strength;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Retail <c>EncumbranceSystem::Load(capacity, burden)</c> (decomp 256413,
|
||||
/// <c>0x004fcc40</c>): the encumbrance ratio <c>burden / capacity</c>
|
||||
/// (1.0 = at capacity, up to ~3.0). The decompiler mangled the FP divide to
|
||||
/// <c>return burden</c>; the <c>cap <= 0</c> guard + single divide is unambiguous.
|
||||
/// Returns 0 when capacity <= 0 (no-data).
|
||||
/// </summary>
|
||||
public static float LoadRatio(int capacity, int burden)
|
||||
=> capacity <= 0 ? 0f : (float)burden / capacity;
|
||||
|
||||
/// <summary>
|
||||
/// Retail <c>gmBackpackUI::SetLoadLevel</c> bar fill (decomp 176542,
|
||||
/// <c>0x004a6ea6</c>): <c>clamp(load * 0.3333…, 0, 1)</c> — the bar is 1/3 full at
|
||||
/// 100% capacity, full at 300%.
|
||||
/// </summary>
|
||||
public static float LoadToFill(float load)
|
||||
{
|
||||
float fill = load / 3f;
|
||||
if (fill < 0f) return 0f;
|
||||
return fill > 1f ? 1f : fill;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Retail <c>gmBackpackUI::SetLoadLevel</c> percent text (decomp 176576): after
|
||||
/// <c>arg2 = load/3</c>, the text is <c>floor(arg2 * 300) = floor(load * 100)</c>,
|
||||
/// NOT clamped (reads up to 300%+).
|
||||
/// </summary>
|
||||
public static int LoadToPercent(float load)
|
||||
=> (int)System.MathF.Floor(load * 100f);
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run test to verify it passes**
|
||||
|
||||
Run: `dotnet test tests/AcDream.Core.Tests/AcDream.Core.Tests.csproj --filter "FullyQualifiedName~BurdenMathTests"`
|
||||
Expected: PASS (12 cases).
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add src/AcDream.Core/Items/ClientObject.cs tests/AcDream.Core.Tests/Items/BurdenMathTests.cs
|
||||
git commit -m "feat(core): D.2b-B — port EncumbranceSystem capacity/load + SetLoadLevel fill/percent
|
||||
|
||||
EncumbranceCapacity (decomp 0x004fcc00), Load (0x004fcc40), SetLoadLevel
|
||||
fill=load/3 clamped + percent=floor(load*100) (0x004a6ea0). Golden tests."
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 2: Carried-burden fallback (`ClientObjectTable.SumCarriedBurden`)
|
||||
|
||||
acdream does not track the player's wire `EncumbranceVal` yet, so the controller falls back to summing carried `Burden`. (Divergence row in Task 7.)
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/AcDream.Core/Items/ClientObjectTable.cs`
|
||||
- Test: `tests/AcDream.Core.Tests/Items/ClientObjectTableBurdenTests.cs`
|
||||
|
||||
- [ ] **Step 1: Write the failing test**
|
||||
|
||||
Create `tests/AcDream.Core.Tests/Items/ClientObjectTableBurdenTests.cs`:
|
||||
|
||||
```csharp
|
||||
using AcDream.Core.Items;
|
||||
using Xunit;
|
||||
|
||||
namespace AcDream.Core.Tests.Items;
|
||||
|
||||
public class ClientObjectTableBurdenTests
|
||||
{
|
||||
private const uint Player = 0x50000001u;
|
||||
|
||||
[Fact]
|
||||
public void SumCarriedBurden_sums_pack_sidebag_and_wielded_but_not_unrelated()
|
||||
{
|
||||
var t = new ClientObjectTable();
|
||||
// loose pack item
|
||||
t.AddOrUpdate(new ClientObject { ObjectId = 0xA, ContainerId = Player, Burden = 100 });
|
||||
// side bag in pack
|
||||
t.AddOrUpdate(new ClientObject { ObjectId = 0xB, ContainerId = Player, Burden = 50,
|
||||
Type = ItemType.Container });
|
||||
// item inside the side bag (2-deep)
|
||||
t.AddOrUpdate(new ClientObject { ObjectId = 0xC, ContainerId = 0xB, Burden = 30 });
|
||||
// wielded (ContainerId 0, WielderId = player)
|
||||
t.AddOrUpdate(new ClientObject { ObjectId = 0xD, WielderId = Player, Burden = 200 });
|
||||
// unrelated object in the world
|
||||
t.AddOrUpdate(new ClientObject { ObjectId = 0xE, ContainerId = 0, Burden = 999 });
|
||||
|
||||
Assert.Equal(380, t.SumCarriedBurden(Player)); // 100+50+30+200
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SumCarriedBurden_unknown_owner_is_zero()
|
||||
=> Assert.Equal(0, new ClientObjectTable().SumCarriedBurden(Player));
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run test to verify it fails**
|
||||
|
||||
Run: `dotnet test tests/AcDream.Core.Tests/AcDream.Core.Tests.csproj --filter "FullyQualifiedName~ClientObjectTableBurdenTests"`
|
||||
Expected: FAIL — no `SumCarriedBurden`.
|
||||
|
||||
- [ ] **Step 3: Add `SumCarriedBurden` to `ClientObjectTable`**
|
||||
|
||||
In `src/AcDream.Core/Items/ClientObjectTable.cs`, add (near `GetContents`):
|
||||
|
||||
```csharp
|
||||
/// <summary>
|
||||
/// Σ Burden over every object carried by <paramref name="ownerGuid"/>: items
|
||||
/// whose container chain roots at the owner (pack + side-bag contents, retail
|
||||
/// hierarchy is 2-deep) plus items wielded by the owner. The client-side
|
||||
/// equivalent of the server's <c>EncumbranceVal</c> (PropertyInt 5) — used by
|
||||
/// the inventory burden bar until the wire value is parsed (B-Wire). The hop
|
||||
/// cap (8) is a cycle guard; real chains are ≤2.
|
||||
/// </summary>
|
||||
public int SumCarriedBurden(uint ownerGuid)
|
||||
{
|
||||
int total = 0;
|
||||
foreach (var o in _objects.Values)
|
||||
if (IsCarriedBy(o, ownerGuid))
|
||||
total += o.Burden;
|
||||
return total;
|
||||
}
|
||||
|
||||
private bool IsCarriedBy(ClientObject o, uint ownerGuid)
|
||||
{
|
||||
if (o.WielderId == ownerGuid) return true;
|
||||
uint c = o.ContainerId;
|
||||
for (int hops = 0; c != 0 && hops < 8; hops++)
|
||||
{
|
||||
if (c == ownerGuid) return true;
|
||||
c = _objects.TryGetValue(c, out var parent) ? parent.ContainerId : 0u;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run test to verify it passes**
|
||||
|
||||
Run: `dotnet test tests/AcDream.Core.Tests/AcDream.Core.Tests.csproj --filter "FullyQualifiedName~ClientObjectTableBurdenTests"`
|
||||
Expected: PASS (2 cases).
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add src/AcDream.Core/Items/ClientObjectTable.cs tests/AcDream.Core.Tests/Items/ClientObjectTableBurdenTests.cs
|
||||
git commit -m "feat(core): D.2b-B — ClientObjectTable.SumCarriedBurden (carried-Burden fallback)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 3: `UiMeter` vertical fill
|
||||
|
||||
The burden bar (`0x100001D9`, 11×58) is vertical; `UiMeter` is horizontal-only. Per `UIElement_Meter::DrawChildren` (decomp 123574) the meter draws its own DirectState (`BackTile`) as the track + the child (`FrontTile`) clipped to the fill fraction along `m_eDirection`. `BuildMeter`'s single-image assignment is already correct (meter-own→`BackTile`/track, child→`FrontTile`/fill); this task only adds the vertical clip.
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/AcDream.App/UI/UiMeter.cs`
|
||||
- Test: `tests/AcDream.App.Tests/UI/UiMeterVerticalTests.cs`
|
||||
|
||||
- [ ] **Step 1: Write the failing test**
|
||||
|
||||
Create `tests/AcDream.App.Tests/UI/UiMeterVerticalTests.cs`:
|
||||
|
||||
```csharp
|
||||
using AcDream.App.UI;
|
||||
using Xunit;
|
||||
|
||||
namespace AcDream.App.Tests.UI;
|
||||
|
||||
public class UiMeterVerticalTests
|
||||
{
|
||||
[Fact]
|
||||
public void Vertical_fill_from_bottom_occupies_lower_fraction()
|
||||
{
|
||||
// 11x58 bar, 50% → fill rect is the bottom 29px.
|
||||
var (x, y, w, h) = UiMeter.ComputeVFillRect(0.5f, 11f, 58f, fromBottom: true);
|
||||
Assert.Equal(0f, x);
|
||||
Assert.Equal(29f, y, 3); // h - h*p = 58 - 29
|
||||
Assert.Equal(11f, w);
|
||||
Assert.Equal(29f, h, 3);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Vertical_fill_from_top_starts_at_zero()
|
||||
{
|
||||
var (_, y, _, h) = UiMeter.ComputeVFillRect(0.25f, 11f, 58f, fromBottom: false);
|
||||
Assert.Equal(0f, y);
|
||||
Assert.Equal(14.5f, h, 3);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(-1f, 0f)]
|
||||
[InlineData(2f, 58f)]
|
||||
public void Vertical_fill_clamps_fraction(float pct, float expectedH)
|
||||
{
|
||||
var (_, _, _, h) = UiMeter.ComputeVFillRect(pct, 11f, 58f, fromBottom: true);
|
||||
Assert.Equal(expectedH, h, 3);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run test to verify it fails**
|
||||
|
||||
Run: `dotnet test tests/AcDream.App.Tests/AcDream.App.Tests.csproj --filter "FullyQualifiedName~UiMeterVerticalTests"`
|
||||
Expected: FAIL — no `ComputeVFillRect`.
|
||||
|
||||
- [ ] **Step 3: Add the vertical path to `UiMeter`**
|
||||
|
||||
In `src/AcDream.App/UI/UiMeter.cs`:
|
||||
|
||||
(a) Add properties after the sprite-id block (after `FrontRight`):
|
||||
|
||||
```csharp
|
||||
/// <summary>Vertical orientation (retail <c>m_eDirection</c> 2/4). Default false =
|
||||
/// horizontal (direction 1). The burden bar is the only vertical meter today.</summary>
|
||||
public bool Vertical { get; set; }
|
||||
|
||||
/// <summary>For a vertical meter, fill grows from the bottom up (retail direction 4)
|
||||
/// when true, top-down (direction 2) when false. Default bottom-up. Visual-confirmed.</summary>
|
||||
public bool FillFromBottom { get; set; } = true;
|
||||
```
|
||||
|
||||
(b) Add the testable rect helper next to `ComputeFillRect`:
|
||||
|
||||
```csharp
|
||||
/// <summary>Clamp <paramref name="pct"/> to [0,1] and return the vertical fill rect
|
||||
/// (local px). <paramref name="fromBottom"/> true → the fill occupies the bottom
|
||||
/// <c>h*pct</c> px (retail direction 4); false → the top (direction 2).</summary>
|
||||
public static (float x, float y, float w, float h) ComputeVFillRect(
|
||||
float pct, float w, float h, bool fromBottom)
|
||||
{
|
||||
if (pct < 0f) pct = 0f;
|
||||
if (pct > 1f) pct = 1f;
|
||||
float fh = h * pct;
|
||||
return (0f, fromBottom ? h - fh : 0f, w, fh);
|
||||
}
|
||||
```
|
||||
|
||||
(c) In `OnDraw`, replace the sprite branch (the `if (SpriteResolve is { } resolve && (...))` block) so it routes vertical meters to a vertical draw. Change:
|
||||
|
||||
```csharp
|
||||
if (SpriteResolve is { } resolve && (BackLeft != 0 || BackTile != 0 || FrontTile != 0))
|
||||
{
|
||||
DrawHBar(ctx, resolve, BackLeft, BackTile, BackRight, Width);
|
||||
if (pct is not null && p > 0f)
|
||||
DrawHBar(ctx, resolve, FrontLeft, FrontTile, FrontRight, Width * p);
|
||||
}
|
||||
```
|
||||
|
||||
to:
|
||||
|
||||
```csharp
|
||||
if (SpriteResolve is { } resolve && (BackLeft != 0 || BackTile != 0 || FrontTile != 0))
|
||||
{
|
||||
if (Vertical)
|
||||
{
|
||||
// Track full height, fill clipped to the fraction along the fill direction.
|
||||
// The burden meter is a single-tile bar (BackTile/FrontTile, no caps).
|
||||
DrawVBar(ctx, resolve, BackTile, Height, fromBottom: FillFromBottom, isFill: false);
|
||||
if (pct is not null && p > 0f)
|
||||
DrawVBar(ctx, resolve, FrontTile, Height * p, fromBottom: FillFromBottom, isFill: true);
|
||||
}
|
||||
else
|
||||
{
|
||||
DrawHBar(ctx, resolve, BackLeft, BackTile, BackRight, Width);
|
||||
if (pct is not null && p > 0f)
|
||||
DrawHBar(ctx, resolve, FrontLeft, FrontTile, FrontRight, Width * p);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
(d) Add `DrawVBar` after `DrawHBar`:
|
||||
|
||||
```csharp
|
||||
/// <summary>Draws a single-tile vertical bar slice. The track (<paramref name="isFill"/>
|
||||
/// false) spans the full height; the fill spans <paramref name="visibleH"/> px from the
|
||||
/// bottom (<paramref name="fromBottom"/>) or top, UV-cropped to that fraction of the
|
||||
/// sprite so the fill reveals the matching part of the art (retail
|
||||
/// UIElement_Meter::DrawChildren Box2D clip, direction 2/4). A 0 id is a no-op.</summary>
|
||||
private void DrawVBar(UiRenderContext ctx, Func<uint, (uint tex, int w, int h)> resolve,
|
||||
uint tileId, float visibleH, bool fromBottom, bool isFill)
|
||||
{
|
||||
if (tileId == 0 || visibleH <= 0f) return;
|
||||
var (tex, _, _) = resolve(tileId);
|
||||
if (tex == 0) return;
|
||||
float w = Width, h = Height;
|
||||
if (visibleH > h) visibleH = h;
|
||||
float frac = h > 0f ? visibleH / h : 0f;
|
||||
// Bottom-up fill: bottom of the rect AND bottom of the sprite (v in [1-frac, 1]).
|
||||
// Top-down / track: top of the rect AND top of the sprite (v in [0, frac]).
|
||||
float y = isFill && fromBottom ? h - visibleH : 0f;
|
||||
float v0 = isFill && fromBottom ? 1f - frac : 0f;
|
||||
float v1 = isFill && fromBottom ? 1f : frac;
|
||||
ctx.DrawSprite(tex, 0f, y, w, visibleH, 0f, v0, 1f, v1, System.Numerics.Vector4.One);
|
||||
}
|
||||
```
|
||||
|
||||
(Note: track always draws full height → `frac=1`, `v0=0`, `v1=1`. Good.)
|
||||
|
||||
- [ ] **Step 4: Run test to verify it passes**
|
||||
|
||||
Run: `dotnet test tests/AcDream.App.Tests/AcDream.App.Tests.csproj --filter "FullyQualifiedName~UiMeterVerticalTests"`
|
||||
Expected: PASS (4 cases). Also confirm the existing meter tests still pass:
|
||||
Run: `dotnet test tests/AcDream.App.Tests/AcDream.App.Tests.csproj --filter "FullyQualifiedName~UiMeter"`
|
||||
Expected: PASS.
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add src/AcDream.App/UI/UiMeter.cs tests/AcDream.App.Tests/UI/UiMeterVerticalTests.cs
|
||||
git commit -m "feat(ui): D.2b-B — UiMeter vertical fill (burden bar, retail m_eDirection 2/4)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 4: `InventoryController` — bind + grid population
|
||||
|
||||
The controller skeleton: find-by-id bind, partition `GetContents(player)`, populate the 3D-items grid + side-bag list + main-pack cell. (Burden + captions in Task 5.)
|
||||
|
||||
**Files:**
|
||||
- Create: `src/AcDream.App/UI/Layout/InventoryController.cs`
|
||||
- Test: `tests/AcDream.App.Tests/UI/Layout/InventoryControllerTests.cs`
|
||||
|
||||
- [ ] **Step 1: Write the failing test**
|
||||
|
||||
Create `tests/AcDream.App.Tests/UI/Layout/InventoryControllerTests.cs`. The helper builds a dat-free `ImportedLayout` from constructed widgets (constructor is public):
|
||||
|
||||
```csharp
|
||||
using System.Collections.Generic;
|
||||
using AcDream.App.UI;
|
||||
using AcDream.App.UI.Layout;
|
||||
using AcDream.Core.Items;
|
||||
using Xunit;
|
||||
|
||||
namespace AcDream.App.Tests.UI.Layout;
|
||||
|
||||
public class InventoryControllerTests
|
||||
{
|
||||
private const uint Player = 0x50000001u;
|
||||
|
||||
// UiElement is abstract — a concrete, parameterless stand-in for the root + caption hosts
|
||||
// (the real captions are Type-0 → UiDatElement; in tests we only need a host that holds children).
|
||||
private sealed class TestElement : UiElement { }
|
||||
|
||||
// Element ids (spec §1).
|
||||
private const uint ContentsGrid = 0x100001C6u;
|
||||
private const uint ContainerList = 0x100001CAu;
|
||||
private const uint TopContainer = 0x100001C9u;
|
||||
private const uint BurdenMeter = 0x100001D9u;
|
||||
private const uint BurdenText = 0x100001D8u;
|
||||
private const uint BurdenCaption = 0x100001D7u;
|
||||
private const uint ContentsCaption = 0x100001C5u;
|
||||
|
||||
private static (ImportedLayout layout, UiItemList grid, UiItemList containers,
|
||||
UiItemList top, UiMeter meter, UiElement burdenText,
|
||||
UiElement burdenCap, UiElement contentsCap) BuildLayout()
|
||||
{
|
||||
var grid = new UiItemList { Width = 192, Height = 96 };
|
||||
var containers = new UiItemList { Width = 36, Height = 252 };
|
||||
var top = new UiItemList { Width = 36, Height = 36 };
|
||||
var meter = new UiMeter { Width = 11, Height = 58 };
|
||||
var burdenText = new TestElement { Width = 36, Height = 15 };
|
||||
var burdenCap = new TestElement { Width = 36, Height = 15 };
|
||||
var contentsCap= new TestElement { Width = 192, Height = 15 };
|
||||
var root = new TestElement { Width = 300, Height = 362 };
|
||||
root.AddChild(grid); root.AddChild(containers); root.AddChild(top);
|
||||
root.AddChild(meter); root.AddChild(burdenText); root.AddChild(burdenCap);
|
||||
root.AddChild(contentsCap);
|
||||
var byId = new Dictionary<uint, UiElement>
|
||||
{
|
||||
[ContentsGrid] = grid, [ContainerList] = containers, [TopContainer] = top,
|
||||
[BurdenMeter] = meter, [BurdenText] = burdenText, [BurdenCaption] = burdenCap,
|
||||
[ContentsCaption] = contentsCap,
|
||||
};
|
||||
return (new ImportedLayout(root, byId), grid, containers, top, meter,
|
||||
burdenText, burdenCap, contentsCap);
|
||||
}
|
||||
|
||||
private static InventoryController Bind(ImportedLayout layout, ClientObjectTable objects,
|
||||
int? strength = 100)
|
||||
=> InventoryController.Bind(layout, objects, () => Player,
|
||||
iconIds: (_, _, _, _, _) => 0u, // no real GL upload in tests
|
||||
strength: () => strength, datFont: null);
|
||||
|
||||
[Fact]
|
||||
public void Populate_fills_contents_grid_with_loose_items_only()
|
||||
{
|
||||
var (layout, grid, containers, _, _, _, _, _) = BuildLayout();
|
||||
var objects = new ClientObjectTable();
|
||||
objects.AddOrUpdate(new ClientObject { ObjectId = 0xA, ContainerId = Player,
|
||||
ContainerSlot = 0, Burden = 10 }); // loose
|
||||
objects.AddOrUpdate(new ClientObject { ObjectId = 0xB, ContainerId = Player,
|
||||
ContainerSlot = 1, Burden = 20 }); // loose
|
||||
objects.AddOrUpdate(new ClientObject { ObjectId = 0xC, ContainerId = Player,
|
||||
ContainerSlot = 2, Type = ItemType.Container }); // side bag
|
||||
|
||||
Bind(layout, objects);
|
||||
|
||||
Assert.Equal(2, grid.GetNumUIItems()); // 2 loose
|
||||
Assert.Equal(0xAu, grid.GetItem(0)!.ItemId);
|
||||
Assert.Equal(0xBu, grid.GetItem(1)!.ItemId);
|
||||
Assert.Equal(1, containers.GetNumUIItems()); // 1 side bag
|
||||
Assert.Equal(0xCu, containers.GetItem(0)!.ItemId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Grid_is_six_columns_thirtytwo_px()
|
||||
{
|
||||
var (layout, grid, _, _, _, _, _, _) = BuildLayout();
|
||||
Bind(layout, new ClientObjectTable());
|
||||
Assert.Equal(6, grid.Columns);
|
||||
Assert.Equal(32f, grid.CellWidth);
|
||||
Assert.Equal(32f, grid.CellHeight);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ObjectAdded_for_player_item_rebuilds_grid()
|
||||
{
|
||||
var (layout, grid, _, _, _, _, _, _) = BuildLayout();
|
||||
var objects = new ClientObjectTable();
|
||||
Bind(layout, objects);
|
||||
Assert.Equal(0, grid.GetNumUIItems());
|
||||
|
||||
objects.Ingest(new WeenieData(0xA, "Sword", ItemType.MeleeWeapon, 1, 0, 0, 0, 0,
|
||||
null, null, null, 5, Player, null, null, null, null, null, null, null, null, null));
|
||||
|
||||
Assert.Equal(1, grid.GetNumUIItems());
|
||||
Assert.Equal(0xAu, grid.GetItem(0)!.ItemId);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run test to verify it fails**
|
||||
|
||||
Run: `dotnet test tests/AcDream.App.Tests/AcDream.App.Tests.csproj --filter "FullyQualifiedName~InventoryControllerTests"`
|
||||
Expected: FAIL — no `InventoryController`.
|
||||
|
||||
- [ ] **Step 3: Create `InventoryController` (bind + populate)**
|
||||
|
||||
Create `src/AcDream.App/UI/Layout/InventoryController.cs`:
|
||||
|
||||
```csharp
|
||||
using System;
|
||||
using AcDream.Core.Items;
|
||||
|
||||
namespace AcDream.App.UI.Layout;
|
||||
|
||||
/// <summary>
|
||||
/// Binds the imported gmInventoryUI tree (LayoutDesc 0x21000023) and populates it from
|
||||
/// <see cref="ClientObjectTable"/>. The acdream analogue of retail
|
||||
/// gmInventoryUI/gmBackpackUI/gm3DItemsUI ::PostInit (named-retail decomp 176236/176596/176728).
|
||||
/// Read-only: no container switching / drag / wield-drop wire (later sub-phases).
|
||||
/// </summary>
|
||||
public sealed class InventoryController
|
||||
{
|
||||
// Element ids — spec §1 (dat dump of 0x21000022 / 0x21000021 + the *::PostInit binds).
|
||||
public const uint ContentsGridId = 0x100001C6u; // gm3DItemsUI m_itemList ("Contents of Backpack")
|
||||
public const uint ContainerListId = 0x100001CAu; // gmBackpackUI m_containerList (side-bag selector)
|
||||
public const uint TopContainerId = 0x100001C9u; // gmBackpackUI m_topContainer (main-pack cell)
|
||||
public const uint BurdenMeterId = 0x100001D9u; // gmBackpackUI m_burdenMeter (vertical)
|
||||
public const uint BurdenTextId = 0x100001D8u; // gmBackpackUI m_burdenText ("%d%%")
|
||||
public const uint BurdenCaptionId = 0x100001D7u; // "Burden"
|
||||
public const uint ContentsCaptionId= 0x100001C5u; // "Contents of Backpack"
|
||||
|
||||
// 3D-items grid: 192x96 → 6 cols x 3 rows of the 32x32 UIItem cell (template 0x21000037).
|
||||
private const int ContentsColumns = 6;
|
||||
private const float CellPx = 32f;
|
||||
|
||||
private readonly ClientObjectTable _objects;
|
||||
private readonly Func<uint> _playerGuid;
|
||||
private readonly Func<ItemType, uint, uint, uint, uint, uint> _iconIds;
|
||||
|
||||
private readonly UiItemList? _contentsGrid;
|
||||
private readonly UiItemList? _containerList;
|
||||
private readonly UiItemList? _topContainer;
|
||||
|
||||
private InventoryController(
|
||||
ImportedLayout layout,
|
||||
ClientObjectTable objects,
|
||||
Func<uint> playerGuid,
|
||||
Func<ItemType, uint, uint, uint, uint, uint> iconIds,
|
||||
Func<int?> strength,
|
||||
UiDatFont? datFont)
|
||||
{
|
||||
_objects = objects;
|
||||
_playerGuid = playerGuid;
|
||||
_iconIds = iconIds;
|
||||
|
||||
_contentsGrid = layout.FindElement(ContentsGridId) as UiItemList;
|
||||
_containerList = layout.FindElement(ContainerListId) as UiItemList;
|
||||
_topContainer = layout.FindElement(TopContainerId) as UiItemList;
|
||||
|
||||
if (_contentsGrid is not null)
|
||||
{
|
||||
_contentsGrid.Columns = ContentsColumns;
|
||||
_contentsGrid.CellWidth = CellPx;
|
||||
_contentsGrid.CellHeight = CellPx;
|
||||
}
|
||||
if (_containerList is not null)
|
||||
{
|
||||
_containerList.Columns = 1;
|
||||
_containerList.CellWidth = CellPx;
|
||||
_containerList.CellHeight = CellPx;
|
||||
}
|
||||
|
||||
// Burden + captions are bound in Task 5 (BindBurden / BindCaptions called here).
|
||||
|
||||
// Rebuild on any change to the player's possessions.
|
||||
_objects.ObjectAdded += OnObjectChanged;
|
||||
_objects.ObjectMoved += OnObjectMoved;
|
||||
_objects.ObjectRemoved += OnObjectChanged;
|
||||
_objects.ObjectUpdated += OnObjectChanged;
|
||||
|
||||
Populate();
|
||||
}
|
||||
|
||||
public static InventoryController Bind(
|
||||
ImportedLayout layout,
|
||||
ClientObjectTable objects,
|
||||
Func<uint> playerGuid,
|
||||
Func<ItemType, uint, uint, uint, uint, uint> iconIds,
|
||||
Func<int?> strength,
|
||||
UiDatFont? datFont)
|
||||
=> new InventoryController(layout, objects, playerGuid, iconIds, strength, datFont);
|
||||
|
||||
private void OnObjectChanged(ClientObject o) { if (Concerns(o)) Populate(); }
|
||||
private void OnObjectMoved(ClientObject o, uint from, uint to)
|
||||
{ if (Concerns(o) || from == _playerGuid() || to == _playerGuid()) Populate(); }
|
||||
|
||||
/// <summary>True if the object is in (or wielded by) the player — i.e. a rebuild is warranted.</summary>
|
||||
private bool Concerns(ClientObject o)
|
||||
{
|
||||
uint p = _playerGuid();
|
||||
return o.ContainerId == p || o.WielderId == p
|
||||
|| (o.ContainerId != 0 && _objects.Get(o.ContainerId)?.ContainerId == p); // 2-deep
|
||||
}
|
||||
|
||||
/// <summary>Retail gm*UI display refresh: partition the player's direct contents into loose
|
||||
/// items (the "Contents of Backpack" grid) and side bags (the pack-selector list).</summary>
|
||||
public void Populate()
|
||||
{
|
||||
uint p = _playerGuid();
|
||||
var contents = _objects.GetContents(p);
|
||||
|
||||
_contentsGrid?.Flush();
|
||||
_containerList?.Flush();
|
||||
|
||||
foreach (var guid in contents)
|
||||
{
|
||||
var item = _objects.Get(guid);
|
||||
if (item is null) continue;
|
||||
bool isBag = item.Type.HasFlag(ItemType.Container) || item.ItemsCapacity > 0;
|
||||
var list = isBag ? _containerList : _contentsGrid;
|
||||
if (list is null) continue;
|
||||
uint tex = _iconIds(item.Type, item.IconId, item.IconUnderlayId,
|
||||
item.IconOverlayId, item.Effects);
|
||||
var cell = new UiItemSlot { SpriteResolve = list.SpriteResolve };
|
||||
cell.SetItem(guid, tex);
|
||||
list.AddItem(cell);
|
||||
}
|
||||
|
||||
// Main-pack cell: the player's own container. Icon = placeholder until a backpack
|
||||
// RenderSurface DID is pinned (spec §3 / divergence row). Bind the guid so a future
|
||||
// click can select it.
|
||||
if (_topContainer is not null)
|
||||
{
|
||||
_topContainer.Flush();
|
||||
var main = new UiItemSlot { SpriteResolve = _topContainer.SpriteResolve };
|
||||
main.SetItem(p, 0u);
|
||||
_topContainer.AddItem(main);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Detach event handlers (idempotent).</summary>
|
||||
public void Dispose()
|
||||
{
|
||||
_objects.ObjectAdded -= OnObjectChanged;
|
||||
_objects.ObjectMoved -= OnObjectMoved;
|
||||
_objects.ObjectRemoved -= OnObjectChanged;
|
||||
_objects.ObjectUpdated -= OnObjectChanged;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run test to verify it passes**
|
||||
|
||||
Run: `dotnet test tests/AcDream.App.Tests/AcDream.App.Tests.csproj --filter "FullyQualifiedName~InventoryControllerTests"`
|
||||
Expected: PASS (3 cases). (Note: `UiItemList` is constructed with one default cell; `Flush()` then `AddItem` per item gives exact counts — the default cell is flushed away.)
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add src/AcDream.App/UI/Layout/InventoryController.cs tests/AcDream.App.Tests/UI/Layout/InventoryControllerTests.cs
|
||||
git commit -m "feat(ui): D.2b-B — InventoryController bind + grid population (loose/side-bag partition)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 5: `InventoryController` — burden meter + captions
|
||||
|
||||
Drive the vertical burden meter + `%` text + the two captions.
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/AcDream.App/UI/Layout/InventoryController.cs`
|
||||
- Modify: `tests/AcDream.App.Tests/UI/Layout/InventoryControllerTests.cs`
|
||||
|
||||
- [ ] **Step 1: Write the failing test**
|
||||
|
||||
Append to `InventoryControllerTests.cs`:
|
||||
|
||||
```csharp
|
||||
[Fact]
|
||||
public void Burden_meter_fill_and_percent_from_load()
|
||||
{
|
||||
var (layout, _, _, _, meter, burdenText, _, _) = BuildLayout();
|
||||
var objects = new ClientObjectTable();
|
||||
// Str 100 → capacity 15000. Carried 7500 → load 0.5 → fill 0.1667, "50%".
|
||||
objects.AddOrUpdate(new ClientObject { ObjectId = 0xA, ContainerId = Player, Burden = 7500 });
|
||||
Bind(layout, objects, strength: 100);
|
||||
|
||||
Assert.Equal(0.16667f, meter.Fill() ?? -1f, 3);
|
||||
Assert.True(meter.Vertical);
|
||||
// The % text caption child reads "50%".
|
||||
Assert.Contains("50%", CaptionText(burdenText));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Captions_render_known_strings()
|
||||
{
|
||||
var (layout, _, _, _, _, _, burdenCap, contentsCap) = BuildLayout();
|
||||
Bind(layout, new ClientObjectTable());
|
||||
Assert.Contains("Burden", CaptionText(burdenCap));
|
||||
Assert.Contains("Contents of Backpack", CaptionText(contentsCap));
|
||||
}
|
||||
|
||||
// Reads the text of the UiText caption child attached by the controller.
|
||||
private static string CaptionText(UiElement host)
|
||||
{
|
||||
foreach (var c in host.Children)
|
||||
if (c is UiText t)
|
||||
{
|
||||
var lines = t.LinesProvider();
|
||||
if (lines.Count > 0) return lines[0].Text;
|
||||
}
|
||||
return "";
|
||||
}
|
||||
```
|
||||
|
||||
> If `UiElement.Children` is not publicly enumerable, expose a read-only view or use the existing accessor the other controllers' tests use (check `SelectedObjectController` tests). The `host.Children` enumeration mirrors `UiElement.AddChild` usage in `SelectedObjectController`.
|
||||
|
||||
- [ ] **Step 2: Run test to verify it fails**
|
||||
|
||||
Run: `dotnet test tests/AcDream.App.Tests/AcDream.App.Tests.csproj --filter "FullyQualifiedName~InventoryControllerTests"`
|
||||
Expected: FAIL — meter `Fill` not driven; no caption children.
|
||||
|
||||
- [ ] **Step 3: Add burden + captions to `InventoryController`**
|
||||
|
||||
Add fields + wire into the constructor (after the grid setup, before `Populate()`), and the helper methods. Add `using System.Numerics;` and `using System.Collections.Generic;`.
|
||||
|
||||
Fields:
|
||||
```csharp
|
||||
private readonly Func<int?> _strength;
|
||||
private readonly UiMeter? _burdenMeter;
|
||||
private float _burdenFill;
|
||||
private int _burdenPercent;
|
||||
private static readonly Vector4 CaptionColor = new(1f, 1f, 1f, 1f);
|
||||
```
|
||||
|
||||
In the constructor, set `_strength = strength;`, then after the list setup:
|
||||
```csharp
|
||||
_burdenMeter = layout.FindElement(BurdenMeterId) as UiMeter;
|
||||
if (_burdenMeter is not null)
|
||||
{
|
||||
_burdenMeter.Vertical = true; // 11x58 vertical bar
|
||||
_burdenMeter.FillFromBottom = true; // retail direction 4 (visual-confirmed)
|
||||
_burdenMeter.Fill = () => _burdenFill;
|
||||
}
|
||||
|
||||
// Captions: attach a centered UiText child carrying the known string. "Contents of
|
||||
// Backpack" + "%d%%" are procedural in retail (gm3DItemsUI/gmBackpackUI PostInit/
|
||||
// SetLoadLevel); "Burden" is the dat label. (Caption pass, spec §5.)
|
||||
AttachCaption(layout.FindElement(BurdenCaptionId), () => "Burden", datFont);
|
||||
AttachCaption(layout.FindElement(ContentsCaptionId), () => "Contents of Backpack", datFont);
|
||||
AttachCaption(layout.FindElement(BurdenTextId), () => _burdenPercent + "%", datFont);
|
||||
|
||||
RefreshBurden();
|
||||
```
|
||||
|
||||
Also subscribe to Strength changes — but `LocalPlayerState` lives in Core and the controller only gets `Func<int?> strength`; recompute burden on item events (already wired) is sufficient for the live path. (Strength rarely changes mid-session; the next item event refreshes it. A dedicated attribute subscription is wired at the GameWindow call site in Task 6 if needed.)
|
||||
|
||||
Add helpers:
|
||||
```csharp
|
||||
private void AttachCaption(UiElement? host, Func<string> text, UiDatFont? datFont)
|
||||
{
|
||||
if (host is null) return;
|
||||
var label = new UiText
|
||||
{
|
||||
Left = 0f, Top = 0f, Width = host.Width, Height = host.Height,
|
||||
Anchors = AnchorEdges.Left | AnchorEdges.Top | AnchorEdges.Right,
|
||||
Centered = true, DatFont = datFont, ClickThrough = true,
|
||||
AcceptsFocus = false, IsEditControl = false, CapturesPointerDrag = false,
|
||||
LinesProvider = () =>
|
||||
{
|
||||
var s = text();
|
||||
return string.IsNullOrEmpty(s)
|
||||
? System.Array.Empty<UiText.Line>()
|
||||
: new[] { new UiText.Line(s, CaptionColor) };
|
||||
},
|
||||
};
|
||||
host.AddChild(label);
|
||||
}
|
||||
|
||||
/// <summary>Recompute the burden fill + percent. Port of CACQualities::InqLoad
|
||||
/// (decomp 0x0058f130) → gmBackpackUI::SetLoadLevel (0x004a6ea0). currentBurden:
|
||||
/// player wire EncumbranceVal (PropertyInt 5) if present, else the carried-Burden sum.</summary>
|
||||
private void RefreshBurden()
|
||||
{
|
||||
uint p = _playerGuid();
|
||||
int str = _strength() ?? 10; // InqAttribute default 0xa
|
||||
int aug = _objects.Get(p)?.Properties.GetInt(EncumbranceAugProperty) ?? 0;
|
||||
int capacity = BurdenMath.EncumbranceCapacity(str, aug);
|
||||
|
||||
int? wire = _objects.Get(p)?.Properties.Ints.TryGetValue(EncumbranceValProperty, out var ev) == true
|
||||
? ev : (int?)null;
|
||||
int burden = wire ?? _objects.SumCarriedBurden(p);
|
||||
|
||||
float load = BurdenMath.LoadRatio(capacity, burden);
|
||||
_burdenFill = BurdenMath.LoadToFill(load);
|
||||
_burdenPercent = BurdenMath.LoadToPercent(load);
|
||||
}
|
||||
|
||||
// ACE PropertyInt ids read by retail InqLoad (decomp 0x0058f130: InqInt(5)/InqInt(0xe6)).
|
||||
private const uint EncumbranceValProperty = 5u; // total carried burden
|
||||
private const uint EncumbranceAugProperty = 0xE6u; // carry-capacity augmentation
|
||||
```
|
||||
|
||||
Finally, call `RefreshBurden()` from `Populate()` (so item changes refresh the bar). Add at the end of `Populate()`:
|
||||
```csharp
|
||||
RefreshBurden();
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run test to verify it passes**
|
||||
|
||||
Run: `dotnet test tests/AcDream.App.Tests/AcDream.App.Tests.csproj --filter "FullyQualifiedName~InventoryControllerTests"`
|
||||
Expected: PASS (5 cases). Run the whole App suite to confirm no regression:
|
||||
Run: `dotnet test tests/AcDream.App.Tests/AcDream.App.Tests.csproj`
|
||||
Expected: PASS.
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add src/AcDream.App/UI/Layout/InventoryController.cs tests/AcDream.App.Tests/UI/Layout/InventoryControllerTests.cs
|
||||
git commit -m "feat(ui): D.2b-B — InventoryController burden meter (InqLoad port) + captions"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 6: Wire `InventoryController` into GameWindow
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/AcDream.App/Rendering/GameWindow.cs` (field ~607; inventory-init block ~2149-2158)
|
||||
|
||||
- [ ] **Step 1: Add the controller field**
|
||||
|
||||
Near the other UI controller fields (e.g. after `_selectedObjectController`; grep `private … _selectedObjectController` for the exact spot), add:
|
||||
|
||||
```csharp
|
||||
private AcDream.App.UI.Layout.InventoryController? _inventoryController;
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Bind it in the inventory-init block**
|
||||
|
||||
In `GameWindow.cs`, the block at ~2149 currently ends with `RegisterWindow(...)` + a `Console.WriteLine`. Insert the bind BEFORE the `Console.WriteLine("[D.2b-B] retail inventory window …")`, using the same `iconComposer` / `vitalsDatFont` / `Objects` already in scope (mirroring the toolbar bind at 2013):
|
||||
|
||||
```csharp
|
||||
_uiHost.RegisterWindow(AcDream.App.UI.WindowNames.Inventory, inventoryWindow);
|
||||
|
||||
// Phase D.2b-B — populate the inventory from ClientObjectTable: the
|
||||
// "Contents of Backpack" grid + the pack-selector strip + the burden meter +
|
||||
// captions. Analogue of gmInventoryUI/gmBackpackUI/gm3DItemsUI::PostInit.
|
||||
_inventoryController = AcDream.App.UI.Layout.InventoryController.Bind(
|
||||
invLayout, Objects,
|
||||
playerGuid: () => _playerServerGuid,
|
||||
iconIds: (type, icon, under, over, effects) => iconComposer.GetIcon(type, icon, under, over, effects),
|
||||
strength: () => (int?)LocalPlayer.GetAttribute(
|
||||
AcDream.Core.Player.LocalPlayerState.AttributeKind.Strength)?.Current,
|
||||
datFont: vitalsDatFont);
|
||||
|
||||
Console.WriteLine("[D.2b-B] retail inventory window from LayoutDesc importer (0x21000023).");
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Build**
|
||||
|
||||
Run: `dotnet build src/AcDream.App/AcDream.App.csproj -c Debug`
|
||||
Expected: `Build succeeded. 0 Error(s)`.
|
||||
|
||||
- [ ] **Step 4: Full build + test**
|
||||
|
||||
Run: `dotnet build AcDream.slnx -c Debug` then `dotnet test AcDream.slnx -c Debug`
|
||||
Expected: all green.
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add src/AcDream.App/Rendering/GameWindow.cs
|
||||
git commit -m "feat(ui): D.2b-B — wire InventoryController into the inventory-init block"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 7: Divergence register + roadmap/issues + memory
|
||||
|
||||
**Files:**
|
||||
- Modify: `docs/architecture/retail-divergence-register.md`
|
||||
- Modify: `docs/ISSUES.md` and/or `docs/plans/2026-04-11-roadmap.md` (if a row tracks B-Controller)
|
||||
|
||||
- [ ] **Step 1: Add divergence rows**
|
||||
|
||||
Append rows to `docs/architecture/retail-divergence-register.md` (match the table's existing column format; use the next `AP-NN` ids):
|
||||
|
||||
- **AP** — inventory burden `currentBurden` summed client-side (`ClientObjectTable.SumCarriedBurden`) when the player's wire `EncumbranceVal` (PropertyInt 5) is absent; retail reads the server value via `CACQualities::InqLoad`. Risk: drift from coins/untracked items. Retire when B-Wire parses `EncumbranceVal`. (`InventoryController.RefreshBurden`)
|
||||
- **AP** — capacity augmentation (PropertyInt `0xE6`) not tracked → `bonus=0` → un-augmented `Str×150`. Risk: augmented chars read slightly low. (`BurdenMath.EncumbranceCapacity` caller)
|
||||
- **AP** — burden meter orientation/direction set from geometry + default bottom-up (`UiMeter.FillFromBottom=true`) rather than retail's `m_eDirection` from LayoutDesc property `0x6f`. Risk: wrong fill direction (visual-confirmed). (`InventoryController` / `UiMeter.Vertical`)
|
||||
- **AP** — main-pack `m_topContainer` cell uses placeholder/empty icon, not a weenie-driven icon (the main pack ≡ the player, no item IconId). Risk: blank main-pack cell. (`InventoryController.Populate`)
|
||||
|
||||
- [ ] **Step 2: Update ISSUES/roadmap**
|
||||
|
||||
If `docs/ISSUES.md` has a B-Controller line, move it to "Recently closed" with the final commit SHA; note B-Wire / B-Drag / Sub-phase C remain open. Otherwise add a one-line "Recently closed" entry.
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add docs/architecture/retail-divergence-register.md docs/ISSUES.md docs/plans/2026-04-11-roadmap.md
|
||||
git commit -m "docs(D.2b-B): divergence rows + issues/roadmap for inventory population"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 8: Visual verification (acceptance gate — user)
|
||||
|
||||
**Not a code task.** Build green, launch with `ACDREAM_RETAIL_UI=1`, press F12, and confirm with the user:
|
||||
|
||||
- [ ] Pack items show as icons in the "Contents of Backpack" grid (6 cols).
|
||||
- [ ] Side bags (if any) appear in the right strip; the main-pack cell is present.
|
||||
- [ ] The burden bar fills **vertically** with the right sprites; the fill **direction** (bottom-up) looks right; the `%` reads sanely.
|
||||
- [ ] The "Burden" + "Contents of Backpack" captions render.
|
||||
- [ ] **No #145 z-order regression** on chat + toolbar (the deferred eyeball — chat is provably safe; the toolbar now honors ZLevel).
|
||||
|
||||
Two visual-confirm unknowns to settle here (not guessed in code): the **cell pitch** (32px → 6 cols is the clean fit) and the **fill direction** (bottom-up = retail dir 4). If either is wrong, the fix is a one-line change (`ContentsColumns`/`CellPx` or `FillFromBottom`).
|
||||
|
||||
After the user confirms, update `memory/project_d2b_retail_ui.md` (or the `claude-memory` mirror) with a B-Controller "SHIPPED" note + the key facts (burden formula port; `BuildMeter` already-correct finding; the four AP rows).
|
||||
|
||||
---
|
||||
|
||||
## Self-Review
|
||||
|
||||
**Spec coverage:** §1 element map → Task 4/5 ids. §2 architecture (controller, inline bind) → Task 4/6. §3 population (3D grid 6-col, side bags, top cell) → Task 4. §4 burden (formula + vertical + data source) → Task 1/3/5. §5 captions → Task 5. §6 divergence rows → Task 7. §7 testing (bind/populate/partition/burden golden/vertical/caption/rebuild) → Tasks 1-5. §8 visual → Task 8. §9 acceptance → all. ✓ No gaps.
|
||||
|
||||
**Placeholder scan:** All steps have concrete code/commands. The two deferred values (main-pack icon DID, property-`0x6f` read) are explicit, register-documented fallbacks, not placeholders. The one soft spot — `UiElement.Children` public enumeration in the Task 5 test — has an inline note to verify against the existing controller-test pattern. ✓
|
||||
|
||||
**Type consistency:** `BurdenMath.EncumbranceCapacity/LoadRatio/LoadToFill/LoadToPercent` (Task 1) used in Task 5 `RefreshBurden`. `ClientObjectTable.SumCarriedBurden` (Task 2) used in Task 5. `UiMeter.Vertical/FillFromBottom/ComputeVFillRect` (Task 3) used in Task 5 + Task 3 tests. `InventoryController.Bind(layout, objects, playerGuid, iconIds, strength, datFont)` signature identical in Task 4 def, Task 4/5 tests, Task 6 call site. `UiItemSlot.SetItem(guid, tex)` + `UiItemList.Flush/AddItem/Columns/CellWidth/CellHeight/GetNumUIItems/GetItem` match the shipped `UiItemList.cs`. ✓
|
||||
535
docs/superpowers/plans/2026-06-21-d2b-inventory-window-finish.md
Normal file
535
docs/superpowers/plans/2026-06-21-d2b-inventory-window-finish.md
Normal file
|
|
@ -0,0 +1,535 @@
|
|||
# D.2b inventory window finish (Stage 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:** Make the inventory window match retail's 2D presentation — the contents grid clips + scrolls, the backdrop covers the whole window, and the side-bag column shows proper slots.
|
||||
|
||||
**Architecture:** Reuse the existing `UiScrollable` scroll model (the chat transcript + `UiScrollbar` already use it) by giving `UiItemList` a clip+scroll capability (cells positioned offset by `-ScrollY`, `cell.Visible` toggled by whole-row clip — the same whole-row approach `UiText` uses, since there is no GL scissor). `InventoryController` binds the dat gutter scrollbar `0x100001C7` to the grid's model exactly like `ChatWindowController` does, splits the cell pitch (contents 32px / backpack 36px), and pads the side-bag column with empty slots. The backdrop is mostly fixed for free by clipping the grid; any residual is a screenshot-gated render fix.
|
||||
|
||||
**Tech Stack:** C# / .NET 10, xUnit. `src/AcDream.App/UI/`. Spec: `docs/superpowers/specs/2026-06-21-d2b-inventory-window-finish-design.md`. Grounding (dat geometry): contents grid `0x100001C6` = 192×96 (6×3, 32px cells); gutter scrollbar `0x100001C7` = 16×96; side-bag column `0x100001CA` = 36×252 (7 slots of 36px); backdrop `0x100001D0` = 300×362 full-window.
|
||||
|
||||
**Reuse references (read before coding):**
|
||||
- `src/AcDream.App/UI/UiScrollable.cs` — the scroll model (ContentHeight/ViewHeight/ScrollY/ScrollByLines/etc.). Already unit-tested; do NOT re-test it.
|
||||
- `src/AcDream.App/UI/UiText.cs:117-247` — the whole-row clip (`y < top || y+lh > bottom → skip`, line 198) + the wheel handler (`OnEvent` Scroll case, line 239) to mirror.
|
||||
- `src/AcDream.App/UI/Layout/ChatWindowController.cs:44-49` (scrollbar sprite-id constants) + `:237-255` (the `FindElement → bar.Model = …Scroll + sprite ids` binding pattern).
|
||||
- `src/AcDream.App/UI/UiItemList.cs` + `src/AcDream.App/UI/Layout/InventoryController.cs` — the files being changed.
|
||||
- `tests/AcDream.App.Tests/UI/UiItemListTests.cs` + `.../UI/Layout/InventoryControllerTests.cs` — test style + the `BuildLayout`/`Bind`/`SeedContained` harness.
|
||||
|
||||
Every commit message MUST end with:
|
||||
`Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>`
|
||||
|
||||
---
|
||||
|
||||
## Task 1: `UiItemList` — scroll model + scroll-aware clip layout
|
||||
|
||||
Give the grid a `UiScrollable` and make `LayoutCells` clip whole rows + offset by the scroll position. Fill mode (the toolbar single-cell, `CellWidth <= 0`) is unchanged.
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/AcDream.App/UI/UiItemList.cs`
|
||||
- Test: `tests/AcDream.App.Tests/UI/UiItemListScrollTests.cs` (create)
|
||||
|
||||
- [ ] **Step 1: Write the failing tests**
|
||||
|
||||
Create `tests/AcDream.App.Tests/UI/UiItemListScrollTests.cs`:
|
||||
|
||||
```csharp
|
||||
using AcDream.App.UI;
|
||||
using Xunit;
|
||||
|
||||
namespace AcDream.App.Tests.UI;
|
||||
|
||||
public sealed class UiItemListScrollTests
|
||||
{
|
||||
private static UiItemList Grid(int items)
|
||||
{
|
||||
var list = new UiItemList { Columns = 6, CellWidth = 32, CellHeight = 32, Width = 192, Height = 96 };
|
||||
list.Flush(); // drop the default single cell
|
||||
for (int i = 0; i < items; i++) list.AddItem(new UiItemSlot());
|
||||
list.LayoutCells(); // drive the scroll model + position cells
|
||||
return list;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RowCount_ceils_to_whole_rows()
|
||||
{
|
||||
Assert.Equal(0, UiItemList.RowCount(0, 6));
|
||||
Assert.Equal(1, UiItemList.RowCount(1, 6));
|
||||
Assert.Equal(7, UiItemList.RowCount(41, 6));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Grid_drives_scroll_model_from_content()
|
||||
{
|
||||
var list = Grid(30); // 5 rows × 32 = 160 content, 96 view
|
||||
Assert.Equal(160, list.Scroll.ContentHeight);
|
||||
Assert.Equal(96, list.Scroll.ViewHeight);
|
||||
Assert.Equal(64, list.Scroll.MaxScroll);
|
||||
Assert.True(list.Scroll.HasOverflow);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void At_top_first_three_rows_visible_rest_clipped()
|
||||
{
|
||||
var list = Grid(30);
|
||||
Assert.True(list.GetItem(0)!.Visible); // row 0, top 0
|
||||
Assert.True(list.GetItem(12)!.Visible); // row 2, top 64 (+32 == 96)
|
||||
Assert.False(list.GetItem(18)!.Visible); // row 3, top 96 (off the bottom)
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Scrolled_one_row_shifts_window_and_clips_top_row()
|
||||
{
|
||||
var list = Grid(30);
|
||||
list.Scroll.SetScrollY(32);
|
||||
list.LayoutCells();
|
||||
Assert.False(list.GetItem(0)!.Visible); // row 0 scrolled above
|
||||
Assert.Equal(0f, list.GetItem(6)!.Top); // row 1 now at the view top
|
||||
Assert.True(list.GetItem(18)!.Visible); // row 3 now visible
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run to verify it fails**
|
||||
|
||||
Run: `dotnet test tests/AcDream.App.Tests/AcDream.App.Tests.csproj --filter "FullyQualifiedName~UiItemListScrollTests"`
|
||||
Expected: FAIL — `UiItemList` has no `Scroll`, no `RowCount`, `LayoutCells` is private.
|
||||
|
||||
- [ ] **Step 3: Implement**
|
||||
|
||||
In `src/AcDream.App/UI/UiItemList.cs`:
|
||||
|
||||
(a) add `using System;` if missing, and the scroll model field after `_cells`:
|
||||
|
||||
```csharp
|
||||
/// <summary>Vertical scroll model for grid mode (clip+scroll). Bound to the gutter
|
||||
/// UiScrollbar by the panel controller. Inert in fill mode (single toolbar cell).</summary>
|
||||
public UiScrollable Scroll { get; } = new();
|
||||
```
|
||||
|
||||
(b) add the row-count helper (next to `CellOffset`):
|
||||
|
||||
```csharp
|
||||
/// <summary>Whole rows needed for <paramref name="cellCount"/> cells in a grid of
|
||||
/// <paramref name="columns"/> columns.</summary>
|
||||
public static int RowCount(int cellCount, int columns)
|
||||
{
|
||||
int cols = columns < 1 ? 1 : columns;
|
||||
return (cellCount + cols - 1) / cols;
|
||||
}
|
||||
```
|
||||
|
||||
(c) replace the whole `private void LayoutCells()` body with this (and change `private` → `internal` so tests can drive it):
|
||||
|
||||
```csharp
|
||||
internal void LayoutCells()
|
||||
{
|
||||
if (CellWidth <= 0f)
|
||||
{
|
||||
// Fill mode (the toolbar single cell): size the one cell to the list.
|
||||
if (_cells.Count > 0)
|
||||
{
|
||||
var c = _cells[0];
|
||||
c.Left = 0; c.Top = 0; c.Width = Width; c.Height = Height; c.Visible = true;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
int cols = Columns < 1 ? 1 : Columns;
|
||||
int cellH = (int)MathF.Round(CellHeight);
|
||||
|
||||
// Drive the shared scroll model from the current geometry, then re-clamp the
|
||||
// offset to the (possibly changed) max. ContentHeight/ViewHeight must be set
|
||||
// BEFORE reading ScrollY so the clamp uses the right max.
|
||||
Scroll.LineHeight = cellH > 0 ? cellH : 1;
|
||||
Scroll.ContentHeight = RowCount(_cells.Count, cols) * cellH;
|
||||
Scroll.ViewHeight = (int)MathF.Floor(Height);
|
||||
Scroll.SetScrollY(Scroll.ScrollY); // re-clamp to new max
|
||||
float scrollY = Scroll.ScrollY;
|
||||
|
||||
for (int i = 0; i < _cells.Count; i++)
|
||||
{
|
||||
int col = i % cols, row = i / cols;
|
||||
float top = row * CellHeight - scrollY;
|
||||
var cell = _cells[i];
|
||||
cell.Left = col * CellWidth;
|
||||
cell.Top = top;
|
||||
cell.Width = CellWidth;
|
||||
cell.Height = CellHeight;
|
||||
// Whole-row vertical clip (no scissor — mirrors UiText.cs:198). A row fully
|
||||
// inside [0, Height] draws; a partially-scrolled row is hidden.
|
||||
cell.Visible = top >= -0.5f && top + CellHeight <= Height + 0.5f;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run to verify it passes**
|
||||
|
||||
Run: `dotnet test tests/AcDream.App.Tests/AcDream.App.Tests.csproj --filter "FullyQualifiedName~UiItemListScrollTests"`
|
||||
Expected: PASS (4 tests).
|
||||
|
||||
- [ ] **Step 5: Run the existing UiItemList tests (no regression)**
|
||||
|
||||
Run: `dotnet test tests/AcDream.App.Tests/AcDream.App.Tests.csproj --filter "FullyQualifiedName~UiItemList"`
|
||||
Expected: PASS (the scroll tests + the pre-existing `UiItemListTests`/`UiItemListGridTests`; fill mode + grid offsets unchanged for non-scrolled grids).
|
||||
|
||||
- [ ] **Step 6: Commit**
|
||||
|
||||
```bash
|
||||
git add src/AcDream.App/UI/UiItemList.cs tests/AcDream.App.Tests/UI/UiItemListScrollTests.cs
|
||||
git commit -F - <<'EOF'
|
||||
feat(ui): D.2b inventory finish — UiItemList clip+scroll via UiScrollable
|
||||
|
||||
Grid mode now drives a shared UiScrollable from its content + clips whole rows
|
||||
to the panel (cells offset by -ScrollY, Visible toggled by whole-row clip,
|
||||
mirroring UiText). Fill mode (toolbar single cell) unchanged.
|
||||
|
||||
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|
||||
EOF
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 2: `UiItemList` — mouse-wheel scroll
|
||||
|
||||
Wheel over the grid scrolls it, mirroring `UiText`'s Scroll handler.
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/AcDream.App/UI/UiItemList.cs`
|
||||
- Test: `tests/AcDream.App.Tests/UI/UiItemListScrollTests.cs` (append)
|
||||
|
||||
- [ ] **Step 1: Write the failing test**
|
||||
|
||||
First open `src/AcDream.App/UI/UiText.cs` around line 239 to confirm the `UiEvent` Scroll shape (`e.Data0` = wheel delta). Then append to `UiItemListScrollTests.cs`:
|
||||
|
||||
```csharp
|
||||
[Fact]
|
||||
public void Wheel_down_scrolls_toward_the_bottom()
|
||||
{
|
||||
var list = Grid(30); // max scroll 64, LineHeight 32
|
||||
// Silk wheel +Y = up/older; UiText negates Data0. Wheel DOWN (Data0 < 0) → +ScrollY.
|
||||
var e = new UiEvent { Type = UiEventType.Scroll, Data0 = -1f };
|
||||
bool handled = list.OnEvent(e);
|
||||
Assert.True(handled);
|
||||
Assert.Equal(32, list.Scroll.ScrollY); // one line (32px) down
|
||||
}
|
||||
```
|
||||
|
||||
(If `UiEvent`'s field types differ — e.g. `Data0` is `int` — match them; the construction style is the only thing to align, the assertion stands.)
|
||||
|
||||
- [ ] **Step 2: Run to verify it fails**
|
||||
|
||||
Run: `dotnet test tests/AcDream.App.Tests/AcDream.App.Tests.csproj --filter "FullyQualifiedName~UiItemListScrollTests.Wheel"`
|
||||
Expected: FAIL — `UiItemList` doesn't override `OnEvent` (wheel ignored, ScrollY stays 0).
|
||||
|
||||
- [ ] **Step 3: Implement**
|
||||
|
||||
In `src/AcDream.App/UI/UiItemList.cs` add an `OnEvent` override (mirror `UiText.cs:239`):
|
||||
|
||||
```csharp
|
||||
public override bool OnEvent(in UiEvent e)
|
||||
{
|
||||
if (e.Type == UiEventType.Scroll && CellWidth > 0f)
|
||||
{
|
||||
// Mirror UiText: Silk +Y wheel = up/older = decrease ScrollY; negate Data0.
|
||||
Scroll.ScrollByLines(-(int)e.Data0);
|
||||
return true;
|
||||
}
|
||||
return base.OnEvent(e);
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run to verify it passes**
|
||||
|
||||
Run: `dotnet test tests/AcDream.App.Tests/AcDream.App.Tests.csproj --filter "FullyQualifiedName~UiItemListScrollTests"`
|
||||
Expected: PASS (5 tests).
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add src/AcDream.App/UI/UiItemList.cs tests/AcDream.App.Tests/UI/UiItemListScrollTests.cs
|
||||
git commit -F - <<'EOF'
|
||||
feat(ui): D.2b inventory finish — UiItemList mouse-wheel scroll
|
||||
|
||||
OnEvent handles the Scroll wheel in grid mode (mirrors UiText's sign), driving
|
||||
the shared UiScrollable. The bound gutter scrollbar (Task 3) is the primary
|
||||
scroll affordance; the wheel is the convenience path.
|
||||
|
||||
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|
||||
EOF
|
||||
```
|
||||
|
||||
> NOTE for the implementer: the wheel only fires if a Scroll event reaches the list (directly hovered, or bubbled from a cell that doesn't consume Scroll). `UiItemSlot` does not handle Scroll, so it should bubble — but confirm at the visual gate (Task 5). If the wheel doesn't reach the list, the scrollbar (Task 3) still works; file a follow-up rather than reworking event dispatch here.
|
||||
|
||||
---
|
||||
|
||||
## Task 3: `InventoryController` — bind the contents-grid gutter scrollbar
|
||||
|
||||
Bind `0x100001C7` (a factory-built Type-11 `UiScrollbar`) to the contents grid's scroll model, with the same sprite ids the chat scrollbar uses (both inherit base layout `0x2100003E`).
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/AcDream.App/UI/Layout/InventoryController.cs`
|
||||
- Test: `tests/AcDream.App.Tests/UI/Layout/InventoryControllerTests.cs` (extend `BuildLayout` + add a test)
|
||||
|
||||
- [ ] **Step 1: Write the failing test**
|
||||
|
||||
In `InventoryControllerTests.cs`: (a) add the scrollbar id constant near the others (`private const uint ContentsScrollbar = 0x100001C7u;`); (b) extend `BuildLayout` to create + register + return a `UiScrollbar`. Change its signature/return tuple to append `UiScrollbar scrollbar`:
|
||||
|
||||
```csharp
|
||||
var scrollbar = new UiScrollbar { Width = 16, Height = 96 };
|
||||
// ... after the existing root.AddChild(...) calls:
|
||||
root.AddChild(scrollbar);
|
||||
// ... in the byId dictionary initializer add:
|
||||
// [ContentsScrollbar] = scrollbar,
|
||||
// ... and append scrollbar to the returned tuple.
|
||||
```
|
||||
|
||||
Then add the test:
|
||||
|
||||
```csharp
|
||||
[Fact]
|
||||
public void Contents_grid_scrollbar_binds_to_the_grid_scroll_model()
|
||||
{
|
||||
var (layout, grid, _, _, _, _, _, _, scrollbar) = BuildLayout();
|
||||
Bind(layout, new ClientObjectTable());
|
||||
Assert.Same(grid.Scroll, scrollbar.Model); // the bar drives the grid's scroll
|
||||
}
|
||||
```
|
||||
|
||||
(Update the other `BuildLayout()` call sites' tuple deconstruction to add a trailing `_` for the new element.)
|
||||
|
||||
- [ ] **Step 2: Run to verify it fails**
|
||||
|
||||
Run: `dotnet test tests/AcDream.App.Tests/AcDream.App.Tests.csproj --filter "FullyQualifiedName~InventoryControllerTests.Contents_grid_scrollbar"`
|
||||
Expected: FAIL — `scrollbar.Model` is null (controller doesn't bind it).
|
||||
|
||||
- [ ] **Step 3: Implement**
|
||||
|
||||
In `src/AcDream.App/UI/Layout/InventoryController.cs`:
|
||||
|
||||
(a) add the element-id + sprite-id constants (sprite ids copied from `ChatWindowController.cs:44-49` — both scrollbars inherit `0x2100003E`):
|
||||
|
||||
```csharp
|
||||
public const uint ContentsScrollbarId = 0x100001C7u; // gm3DItemsUI gutter scrollbar
|
||||
// Scrollbar chrome from base layout 0x2100003E (shared with the chat scrollbar).
|
||||
private const uint ScrollTrackSprite = 0x06004C5Fu;
|
||||
private const uint ScrollThumbSprite = 0x06004C63u;
|
||||
private const uint ScrollThumbTop = 0x06004C60u;
|
||||
private const uint ScrollThumbBot = 0x06004C66u;
|
||||
private const uint ScrollUpSprite = 0x06004C6Cu;
|
||||
private const uint ScrollDownSprite = 0x06004C69u;
|
||||
```
|
||||
|
||||
(b) in the constructor, after the `_contentsGrid` setup block (the `if (_contentsGrid is not null) { … }` around line 63-68), bind the scrollbar:
|
||||
|
||||
```csharp
|
||||
// Bind the gutter scrollbar to the contents grid's scroll model (the factory built
|
||||
// 0x100001C7 as a bare Type-11 UiScrollbar; wire it like ChatWindowController does).
|
||||
if (_contentsGrid is not null
|
||||
&& layout.FindElement(ContentsScrollbarId) is UiScrollbar bar)
|
||||
{
|
||||
bar.Model = _contentsGrid.Scroll;
|
||||
bar.SpriteResolve = _contentsGrid.SpriteResolve; // chrome resolve from the factory ctor
|
||||
bar.TrackSprite = ScrollTrackSprite;
|
||||
bar.ThumbSprite = ScrollThumbSprite;
|
||||
bar.ThumbTopSprite = ScrollThumbTop;
|
||||
bar.ThumbBotSprite = ScrollThumbBot;
|
||||
bar.UpSprite = ScrollUpSprite;
|
||||
bar.DownSprite = ScrollDownSprite;
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run to verify it passes**
|
||||
|
||||
Run: `dotnet test tests/AcDream.App.Tests/AcDream.App.Tests.csproj --filter "FullyQualifiedName~InventoryControllerTests"`
|
||||
Expected: PASS (the new test + all pre-existing InventoryController tests, incl. the B-Wire burden tests).
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add src/AcDream.App/UI/Layout/InventoryController.cs tests/AcDream.App.Tests/UI/Layout/InventoryControllerTests.cs
|
||||
git commit -F - <<'EOF'
|
||||
feat(ui): D.2b inventory finish — bind contents-grid gutter scrollbar
|
||||
|
||||
InventoryController binds 0x100001C7 (factory Type-11 UiScrollbar) to the
|
||||
contents grid's UiScrollable + the shared 0x2100003E scrollbar sprites,
|
||||
mirroring ChatWindowController. The grid now scrolls instead of overflowing.
|
||||
|
||||
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|
||||
EOF
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 4: `InventoryController` — side-bag column (36px pitch + empty-slot padding)
|
||||
|
||||
The side-bag column should show the player's bags + empty slot frames up to capacity, at the correct 36px pitch.
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/AcDream.App/UI/Layout/InventoryController.cs`
|
||||
- Test: `tests/AcDream.App.Tests/UI/Layout/InventoryControllerTests.cs` (append)
|
||||
|
||||
- [ ] **Step 1: Write the failing test**
|
||||
|
||||
Append to `InventoryControllerTests.cs`:
|
||||
|
||||
```csharp
|
||||
[Fact]
|
||||
public void Side_bag_column_pads_empty_slots_up_to_capacity()
|
||||
{
|
||||
var (layout, _, containers, _, _, _, _, _, _) = BuildLayout();
|
||||
var objects = new ClientObjectTable();
|
||||
objects.AddOrUpdate(new ClientObject { ObjectId = Player, ContainersCapacity = 3 });
|
||||
SeedContained(objects, 0xC, Player, slot: 0, type: ItemType.Container); // one side bag
|
||||
|
||||
Bind(layout, objects);
|
||||
|
||||
Assert.Equal(3, containers.GetNumUIItems()); // 1 bag + 2 empty = capacity 3
|
||||
Assert.Equal(0xCu, containers.GetItem(0)!.ItemId); // the bag
|
||||
Assert.Equal(0u, containers.GetItem(1)!.ItemId); // empty frame
|
||||
Assert.Equal(0u, containers.GetItem(2)!.ItemId); // empty frame
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run to verify it fails**
|
||||
|
||||
Run: `dotnet test tests/AcDream.App.Tests/AcDream.App.Tests.csproj --filter "FullyQualifiedName~InventoryControllerTests.Side_bag_column"`
|
||||
Expected: FAIL — only 1 cell (the bag); no empty padding.
|
||||
|
||||
- [ ] **Step 3: Implement**
|
||||
|
||||
In `src/AcDream.App/UI/Layout/InventoryController.cs`:
|
||||
|
||||
(a) split the cell pitch — replace the single `private const float CellPx = 32f;` with:
|
||||
|
||||
```csharp
|
||||
private const float ContentsCellPx = 32f; // gm3DItemsUI grid (192x96 = 6x3 of 32px)
|
||||
private const float BackpackCellPx = 36f; // gmBackpackUI column cells (0x100001C9/CA = 36px)
|
||||
private const int SideBagSlots = 7; // 0x100001CA is 36x252 = 7 slots
|
||||
```
|
||||
|
||||
Update the constructor's three cell-pitch assignments: `_contentsGrid` uses `ContentsCellPx` (was `CellPx`); `_containerList` and `_topContainer` use `BackpackCellPx`.
|
||||
|
||||
(b) in `Populate()`, after the `foreach (var guid in contents)` loop that adds side bags to `_containerList`, pad it with empty slots. Find where the loop ends (before the `_topContainer` block) and add:
|
||||
|
||||
```csharp
|
||||
// Side-bag column: pad with empty slot frames up to the player's container
|
||||
// capacity (clamped to the 7-slot column), so the column reads like retail
|
||||
// (bags on top, empty frames below) rather than one lone cell. Divergence AP-XX
|
||||
// if capacity is absent → default to the full 7-slot column.
|
||||
if (_containerList is not null)
|
||||
{
|
||||
int capacity = _objects.Get(p)?.ContainersCapacity ?? 0;
|
||||
int slots = capacity > 0 ? capacity : SideBagSlots;
|
||||
slots = Math.Clamp(slots, _containerList.GetNumUIItems(), SideBagSlots);
|
||||
while (_containerList.GetNumUIItems() < slots)
|
||||
_containerList.AddItem(new UiItemSlot { SpriteResolve = _containerList.SpriteResolve });
|
||||
}
|
||||
```
|
||||
|
||||
(Place this AFTER the `foreach` that adds bags and BEFORE the `if (_topContainer is not null)` block. The `_contentsGrid`/`_containerList` were already `Flush()`ed at the top of `Populate`, so the count here is exactly the bags added this pass.)
|
||||
|
||||
- [ ] **Step 4: Run to verify it passes**
|
||||
|
||||
Run: `dotnet test tests/AcDream.App.Tests/AcDream.App.Tests.csproj --filter "FullyQualifiedName~InventoryControllerTests"`
|
||||
Expected: PASS (all InventoryController tests).
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add src/AcDream.App/UI/Layout/InventoryController.cs tests/AcDream.App.Tests/UI/Layout/InventoryControllerTests.cs
|
||||
git commit -F - <<'EOF'
|
||||
feat(ui): D.2b inventory finish — side-bag column slots (36px pitch + empties)
|
||||
|
||||
The side-bag column (0x100001CA, 36x252 = 7 slots) pads empty slot frames up to
|
||||
the player's ContainersCapacity (clamped to 7), at the correct 36px pitch
|
||||
(split from the contents grid's 32px). Reads like retail's bag column.
|
||||
|
||||
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|
||||
EOF
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 5: Backdrop coverage — visual gate + conditional residual fix
|
||||
|
||||
The backdrop `0x100001D0` is already full-window (300×362); the tear is the unclipped grid overflowing below it, which Tasks 1+3 fix. This task verifies that at the running client and fixes any residual.
|
||||
|
||||
**Files:**
|
||||
- Modify (only if residual gap found): `src/AcDream.App/UI/UiDatElement.cs` (Alphablend draw) — exact change determined by the root cause.
|
||||
|
||||
- [ ] **Step 1: Build + launch**
|
||||
|
||||
Run: `dotnet build`
|
||||
Expected: Build succeeded.
|
||||
Then launch the client against ACE with `ACDREAM_RETAIL_UI=1` (per CLAUDE.md "Running the client"), get in-world with a full pack, press F12.
|
||||
|
||||
- [ ] **Step 2: Observe the backdrop**
|
||||
|
||||
With the grid now clipped to 3 rows + scrollable, check the dark backdrop covers the whole inventory window (no grass/world showing through anywhere) — compare to retail screenshot 2.
|
||||
|
||||
- [ ] **Step 3: If fully covered — done.** Record the observation; no code change. Skip to Task 6.
|
||||
|
||||
- [ ] **Step 4: If a residual gap remains — root-cause then fix.** Most likely candidate: `UiDatElement` draws the Alphablend backdrop sprite (`0x06004D0A`) at native size instead of stretched/tiled to the element's 300×362 bounds. Open `src/AcDream.App/UI/UiDatElement.cs`, find the Alphablend `DrawMode` draw path, and confirm whether it fills the element rect. If it draws at native sprite size, change it to fill the element bounds (stretch or tile, matching how the vitals chrome center fill tiles — see `UiNineSlicePanel`). Add a divergence row if the fill differs from retail's exact draw. Re-launch + re-verify. (Do not change the draw speculatively — only if Step 2 shows a gap.)
|
||||
|
||||
- [ ] **Step 5: Commit (only if a fix was made)**
|
||||
|
||||
```bash
|
||||
git add src/AcDream.App/UI/UiDatElement.cs
|
||||
git commit -F - <<'EOF'
|
||||
fix(ui): D.2b inventory finish — backdrop fills the full window
|
||||
|
||||
<describe the root cause found at the visual gate>
|
||||
|
||||
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|
||||
EOF
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 6: Full verification + bookkeeping
|
||||
|
||||
**Files:**
|
||||
- Modify: `docs/ISSUES.md` (close the contents-grid-overflow issue), `docs/architecture/retail-divergence-register.md` (add the side-bag-capacity-fallback row + any backdrop row), `claude-memory/project_d2b_retail_ui.md` + `MEMORY.md` (Stage 1 shipped entry).
|
||||
|
||||
- [ ] **Step 1: Full build + test**
|
||||
|
||||
Run: `dotnet build` then `dotnet test`
|
||||
Expected: all green (App count = the B-Wire baseline 534 + the new Stage-1 tests; Core/Core.Net/UI unchanged).
|
||||
|
||||
- [ ] **Step 2: Close the overflow issue + add divergence rows**
|
||||
|
||||
In `docs/ISSUES.md`: move the "Inventory 'Contents of Backpack' grid overflows (no scroll)" issue to DONE with the commit SHAs (Tasks 1-3 fixed it).
|
||||
In `docs/architecture/retail-divergence-register.md`: add a row for the side-bag slot count using the 7-slot fallback when `ContainersCapacity` is absent (replace the `AP-XX` placeholder in the Task-4 comment with the real id). Add a backdrop row only if Task 5 made a fill change that differs from retail.
|
||||
|
||||
- [ ] **Step 3: Update memory**
|
||||
|
||||
In `claude-memory/project_d2b_retail_ui.md`: add a Stage-1 SHIPPED entry (UiItemList clip+scroll via UiScrollable; gutter scrollbar 0x100001C7 bound like chat; side-bag column 36px + empty padding; backdrop coverage outcome). Update the `MEMORY.md` index line. Note Stage 2 (paperdoll) is next.
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add docs/ISSUES.md docs/architecture/retail-divergence-register.md
|
||||
git commit -F - <<'EOF'
|
||||
docs(D.2b): inventory window finish (Stage 1) shipped — close overflow issue
|
||||
|
||||
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|
||||
EOF
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Post-implementation: visual gate
|
||||
|
||||
After Task 6, Stage 1 is code-complete. The acceptance is visual (project model): open F12 with a full pack → scrollable 3-row grid with a working gutter scrollbar, solid backdrop edge-to-edge, side-bag column with bag(s) + empty frames — matching retail screenshot 2 minus the doll. Report the observation. Then Stage 2 (paperdoll) gets its own brainstorm → spec → plan.
|
||||
|
||||
---
|
||||
|
||||
## Self-review notes (coverage vs. spec)
|
||||
|
||||
- **A (contents-grid clip+scroll)** → Tasks 1 (clip layout) + 2 (wheel) + 3 (scrollbar binding). Reuses `UiScrollable` per decision §4.
|
||||
- **B (backdrop coverage)** → Task 5 (screenshot-gated; primary fix is A's clip per spec §B / decision §4).
|
||||
- **C (side-bag column)** → Task 4 (36px pitch + empty padding up to capacity per decision §4).
|
||||
- **Testing** → per-task unit tests (UiItemList scroll/wheel pure-ish via internal `LayoutCells`; controller scrollbar-bound + side-bag count via the `BuildLayout` harness); backdrop is visual-gate only per spec §5.
|
||||
- **Divergence** → side-bag capacity fallback row in Task 6; backdrop row only if Task 5 changes the draw.
|
||||
- **Out of scope** (paperdoll, B-Drag, side-bag scrollbar `0x100001CB`, stack overlays) — untouched.
|
||||
- Type consistency: `UiItemList.Scroll` (UiScrollable) / `RowCount` / internal `LayoutCells` defined in Task 1 and consumed in Tasks 2-3; `ContentsCellPx`/`BackpackCellPx`/`SideBagSlots` defined + used in Task 4; scrollbar `Model`/sprite setters match `UiScrollbar`'s public API.
|
||||
1432
docs/superpowers/plans/2026-06-21-d2b-inventory-wire.md
Normal file
1432
docs/superpowers/plans/2026-06-21-d2b-inventory-wire.md
Normal file
File diff suppressed because it is too large
Load diff
819
docs/superpowers/plans/2026-06-22-d2b-container-switching.md
Normal file
819
docs/superpowers/plans/2026-06-22-d2b-container-switching.md
Normal file
|
|
@ -0,0 +1,819 @@
|
|||
# D.2b inventory container-switching — 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:** Click a side bag in the inventory pack-selector → the grid shows that bag's contents (opened via a `Use 0x0036` → `ViewContents 0x0196` full-replace round-trip), the open bag shows the retail triangle, the selected cell shows the retail green/yellow square; click the main-pack cell → back.
|
||||
|
||||
**Architecture:** Add an authoritative full-replace path to the Core object table (`ReplaceContents`), reroute the `ViewContents` wiring through it, add a thin `WorldSession.SendUse` wire wrapper, give `UiItemSlot` two procedural indicator overlays (modeled on the existing DragAccept overlay), and give `InventoryController` `_openContainer`/`_selectedItem` state with per-cell click roles (grid→select, container→open+select). `GameWindow` injects the two send callbacks.
|
||||
|
||||
**Tech Stack:** C# .NET 10, xUnit. Layers: `AcDream.Core` (object table), `AcDream.Core.Net` (wire + wiring), `AcDream.App` (UI widgets + controller + window).
|
||||
|
||||
**Spec:** `docs/superpowers/specs/2026-06-22-d2b-container-switching-design.md`. Decomp anchors: `UpdateOpenContainerIndicator 0x004e3070`/`SetOpenContainerState 0x004e1200` (triangle `0x06005D9C`); `ItemList_SetSelectedItem 0x004e2fe0`/`SetSelectedState 0x004e1240` (square `0x06004D21`). Wire: ACE `GameEventViewContents.cs` (entries `OrderBy(PlacementPosition)`, no slot field → `ContainerSlot = index`).
|
||||
|
||||
**Build/test commands** (run from repo root `C:\Users\erikn\source\repos\acdream\.claude\worktrees\hopeful-maxwell-214a12`):
|
||||
- One project's tests: `dotnet test tests/AcDream.Core.Tests/AcDream.Core.Tests.csproj`
|
||||
- Full suite at the phase boundary: `dotnet test`
|
||||
|
||||
---
|
||||
|
||||
## Task 1: `ClientObjectTable.ReplaceContents` (full-replace membership)
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/AcDream.Core/Items/ClientObjectTable.cs` (add a method after `GetContents`, ~line 316)
|
||||
- Test: `tests/AcDream.Core.Tests/Items/ClientObjectTableTests.cs` (add after `GetContents_UnknownContainer_Empty`)
|
||||
|
||||
- [ ] **Step 1: Write the failing tests**
|
||||
|
||||
Add to `ClientObjectTableTests.cs`:
|
||||
|
||||
```csharp
|
||||
[Fact]
|
||||
public void ReplaceContents_RecordsAllInListOrder()
|
||||
{
|
||||
var table = new ClientObjectTable();
|
||||
table.ReplaceContents(0xC9u, new uint[] { 0xA01u, 0xA02u, 0xA03u });
|
||||
Assert.Equal(new[] { 0xA01u, 0xA02u, 0xA03u }, table.GetContents(0xC9u));
|
||||
Assert.Equal(0xC9u, table.Get(0xA01u)!.ContainerId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ReplaceContents_SecondSmallerList_DetachesDropped() // the full-REPLACE semantics (vs the old additive merge)
|
||||
{
|
||||
var table = new ClientObjectTable();
|
||||
table.ReplaceContents(0xC9u, new uint[] { 0xA01u, 0xA02u, 0xA03u });
|
||||
table.ReplaceContents(0xC9u, new uint[] { 0xA02u }); // A01 + A03 removed server-side
|
||||
Assert.Equal(new[] { 0xA02u }, table.GetContents(0xC9u));
|
||||
Assert.Equal(0u, table.Get(0xA01u)!.ContainerId); // detached, not lingering
|
||||
Assert.Equal(0u, table.Get(0xA03u)!.ContainerId);
|
||||
Assert.Equal(0xC9u, table.Get(0xA02u)!.ContainerId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ReplaceContents_ReordersToNewListOrder()
|
||||
{
|
||||
var table = new ClientObjectTable();
|
||||
table.ReplaceContents(0xC9u, new uint[] { 0xA01u, 0xA02u });
|
||||
table.ReplaceContents(0xC9u, new uint[] { 0xA02u, 0xA01u });
|
||||
Assert.Equal(new[] { 0xA02u, 0xA01u }, table.GetContents(0xC9u));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ReplaceContents_ZeroContainer_NoOp()
|
||||
{
|
||||
var table = new ClientObjectTable();
|
||||
table.ReplaceContents(0u, new uint[] { 0xA01u });
|
||||
Assert.Empty(table.GetContents(0u));
|
||||
Assert.Null(table.Get(0xA01u));
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run the tests to verify they fail**
|
||||
|
||||
Run: `dotnet test tests/AcDream.Core.Tests/AcDream.Core.Tests.csproj --filter "FullyQualifiedName~ReplaceContents"`
|
||||
Expected: FAIL — `'ClientObjectTable' does not contain a definition for 'ReplaceContents'` (compile error).
|
||||
|
||||
- [ ] **Step 3: Implement `ReplaceContents`**
|
||||
|
||||
In `ClientObjectTable.cs`, immediately after the `GetContents` method (~line 316), add:
|
||||
|
||||
```csharp
|
||||
/// <summary>
|
||||
/// Replace a container's entire membership with <paramref name="guids"/> (in order) — the
|
||||
/// authoritative full snapshot the server sends in ViewContents (0x0196) when you open a
|
||||
/// container. Members no longer present are detached (ContainerId → 0); new members are
|
||||
/// recorded with ContainerSlot = list index. ACE writes ViewContents entries
|
||||
/// OrderBy(PlacementPosition) with NO explicit slot field (GameEventViewContents.cs), so the
|
||||
/// list order IS the slot order. Fires ObjectAdded/ObjectMoved so bound UI repaints.
|
||||
/// Retail consumer: ClientUISystem::OnViewContents.
|
||||
/// </summary>
|
||||
public void ReplaceContents(uint containerId, IReadOnlyList<uint> guids)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(guids);
|
||||
if (containerId == 0) return;
|
||||
|
||||
var keep = new HashSet<uint>(guids);
|
||||
|
||||
// Detach prior members no longer present (they left the container server-side). GetContents
|
||||
// returns a snapshot, so mutating the index inside the loop is safe.
|
||||
foreach (var old in GetContents(containerId))
|
||||
{
|
||||
if (keep.Contains(old)) continue;
|
||||
if (!_objects.TryGetValue(old, out var o) || o.ContainerId != containerId) continue;
|
||||
o.ContainerId = 0;
|
||||
Reindex(o, containerId);
|
||||
ObjectMoved?.Invoke(o, containerId, 0);
|
||||
}
|
||||
|
||||
// Record new members in order; ContainerSlot = index reconstructs the server PlacementPosition.
|
||||
for (int i = 0; i < guids.Count; i++)
|
||||
{
|
||||
uint g = guids[i];
|
||||
bool existed = _objects.TryGetValue(g, out var obj);
|
||||
if (!existed || obj is null)
|
||||
{
|
||||
obj = new ClientObject { ObjectId = g };
|
||||
_objects[g] = obj;
|
||||
}
|
||||
uint oldContainer = obj.ContainerId;
|
||||
obj.ContainerId = containerId;
|
||||
obj.ContainerSlot = i;
|
||||
Reindex(obj, oldContainer);
|
||||
if (!existed) ObjectAdded?.Invoke(obj);
|
||||
else ObjectMoved?.Invoke(obj, oldContainer, containerId);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run the tests to verify they pass**
|
||||
|
||||
Run: `dotnet test tests/AcDream.Core.Tests/AcDream.Core.Tests.csproj --filter "FullyQualifiedName~ReplaceContents"`
|
||||
Expected: PASS (4 tests).
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add src/AcDream.Core/Items/ClientObjectTable.cs tests/AcDream.Core.Tests/Items/ClientObjectTableTests.cs
|
||||
git commit -m "feat(D.2b): ClientObjectTable.ReplaceContents — authoritative full-replace for ViewContents
|
||||
|
||||
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 2: route `ViewContents` wiring through `ReplaceContents`
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/AcDream.Core.Net/GameEventWiring.cs:253-266` (the ViewContents handler)
|
||||
- Test: `tests/AcDream.Core.Net.Tests/GameEventWiringTests.cs` (add after `WireAll_ViewContents_RecordsMembershipInClientObjectTable`)
|
||||
|
||||
- [ ] **Step 1: Write the failing test**
|
||||
|
||||
Add to `GameEventWiringTests.cs` (the existing `WireAll_ViewContents_RecordsMembershipInClientObjectTable` stays — it still passes under replace):
|
||||
|
||||
```csharp
|
||||
[Fact]
|
||||
public void WireAll_ViewContents_IsFullReplace_DropsRemovedItems()
|
||||
{
|
||||
var (d, items, _, _, _) = MakeAll();
|
||||
|
||||
// First view of container 0xC9: two items.
|
||||
DispatchViewContents(d, 0x500000C9u, new uint[] { 0x50000A01u, 0x50000A02u });
|
||||
Assert.Equal(2, items.GetContents(0x500000C9u).Count);
|
||||
|
||||
// Second view: only one item — the other was removed server-side. Additive merge would keep both.
|
||||
DispatchViewContents(d, 0x500000C9u, new uint[] { 0x50000A02u });
|
||||
Assert.Equal(new[] { 0x50000A02u }, items.GetContents(0x500000C9u));
|
||||
Assert.Equal(0u, items.Get(0x50000A01u)!.ContainerId);
|
||||
}
|
||||
|
||||
private static void DispatchViewContents(GameEventDispatcher d, uint containerGuid, uint[] itemGuids)
|
||||
{
|
||||
byte[] payload = new byte[8 + itemGuids.Length * 8];
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(payload.AsSpan(0), containerGuid);
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(payload.AsSpan(4), (uint)itemGuids.Length);
|
||||
for (int i = 0; i < itemGuids.Length; i++)
|
||||
{
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(payload.AsSpan(8 + i * 8), itemGuids[i]);
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(payload.AsSpan(12 + i * 8), 0u); // containerType
|
||||
}
|
||||
var env = GameEventEnvelope.TryParse(WrapEnvelope(GameEventType.ViewContents, payload));
|
||||
d.Dispatch(env!.Value);
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run the test to verify it fails**
|
||||
|
||||
Run: `dotnet test tests/AcDream.Core.Net.Tests/AcDream.Core.Net.Tests.csproj --filter "FullyQualifiedName~ViewContents"`
|
||||
Expected: FAIL — `WireAll_ViewContents_IsFullReplace_DropsRemovedItems` asserts `GetContents` has 1 entry, but the current additive `RecordMembership` loop leaves both (count 2) and `0x50000A01` keeps `ContainerId == 0xC9`.
|
||||
|
||||
- [ ] **Step 3: Replace the handler body**
|
||||
|
||||
In `GameEventWiring.cs`, replace the block at `:253-266` (the comment + handler) with:
|
||||
|
||||
```csharp
|
||||
// ViewContents (0x0196) — the server's AUTHORITATIVE full contents list for a container you
|
||||
// opened (Use 0x0036). Treat it as a full REPLACE: flush the container's prior membership and
|
||||
// record the new entries in order (ContainerSlot = index — ACE writes entries
|
||||
// OrderBy(PlacementPosition) with no slot field). The container-open UI (InventoryController)
|
||||
// repaints the grid off the resulting ObjectAdded/Moved events. Retail: ClientUISystem::OnViewContents.
|
||||
dispatcher.Register(GameEventType.ViewContents, e =>
|
||||
{
|
||||
var p = GameEvents.ParseViewContents(e.Payload.Span);
|
||||
if (p is null) return;
|
||||
var guids = new uint[p.Value.Items.Count];
|
||||
for (int i = 0; i < guids.Length; i++) guids[i] = p.Value.Items[i].Guid;
|
||||
items.ReplaceContents(p.Value.ContainerGuid, guids);
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run the test to verify it passes**
|
||||
|
||||
Run: `dotnet test tests/AcDream.Core.Net.Tests/AcDream.Core.Net.Tests.csproj --filter "FullyQualifiedName~ViewContents"`
|
||||
Expected: PASS (both the existing membership test and the new full-replace test).
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add src/AcDream.Core.Net/GameEventWiring.cs tests/AcDream.Core.Net.Tests/GameEventWiringTests.cs
|
||||
git commit -m "feat(D.2b): ViewContents wiring is a full REPLACE (consumes the GameEventWiring TODO)
|
||||
|
||||
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 3: `WorldSession.SendUse` (thin wire wrapper)
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/AcDream.Core.Net/WorldSession.cs` (add after `SendNoLongerViewingContents`, ~line 1216)
|
||||
|
||||
**Test note:** no new unit test. The wire contract (`Use 0x0036` + target guid) is already locked by `tests/AcDream.Core.Net.Tests/Messages/InteractRequestsTests.cs::BuildUse_WritesOpcode0x0036AndTarget`. `SendUse` is a trivial pass-through that mirrors the adjacent, test-free wrappers (`SendNoLongerViewingContents`, `SendDropItem`, `SendAddShortcut`) — there is no send-capture seam on `WorldSession` to assert against, and the round-trip is confirmed at the WireMCP visual gate (Task 8).
|
||||
|
||||
- [ ] **Step 1: Add the wrapper**
|
||||
|
||||
In `WorldSession.cs`, directly after `SendNoLongerViewingContents` (~line 1216), add:
|
||||
|
||||
```csharp
|
||||
/// <summary>Send Use (0x0036) — open/use an object by guid (e.g. open a container in your
|
||||
/// inventory). A direct wire send: opening a pack you already hold needs no autowalk, unlike
|
||||
/// GameWindow.SendUse (the world-interaction path). Retail: CM_Physics::Event_Use.</summary>
|
||||
public void SendUse(uint targetGuid)
|
||||
{
|
||||
uint seq = NextGameActionSequence();
|
||||
SendGameAction(InteractRequests.BuildUse(seq, targetGuid));
|
||||
}
|
||||
```
|
||||
|
||||
(`InteractRequests` is in `AcDream.Core.Net.Messages`; confirm the `using AcDream.Core.Net.Messages;` is present at the top of `WorldSession.cs` — it is, used by the other Send wrappers.)
|
||||
|
||||
- [ ] **Step 2: Verify it compiles**
|
||||
|
||||
Run: `dotnet build src/AcDream.Core.Net/AcDream.Core.Net.csproj`
|
||||
Expected: Build succeeded.
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add src/AcDream.Core.Net/WorldSession.cs
|
||||
git commit -m "feat(D.2b): WorldSession.SendUse — thin Use 0x0036 wire wrapper for container-open
|
||||
|
||||
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 4: `UiItemSlot` indicator overlays (triangle + square)
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/AcDream.App/UI/UiItemSlot.cs` (add properties near `DragAcceptSprite` ~line 36; add draw in `OnDraw` ~line 226)
|
||||
- Test: `tests/AcDream.App.Tests/UI/UiItemSlotTests.cs` (add after the DefaultEmptySprite test)
|
||||
|
||||
- [ ] **Step 1: Write the failing tests**
|
||||
|
||||
Add to `UiItemSlotTests.cs`:
|
||||
|
||||
```csharp
|
||||
// ── Container-switching indicator overlays (decomp SetOpenContainerState 0x004e1200 /
|
||||
// SetSelectedState 0x004e1240) ───────────────────────────────────────────────────────
|
||||
[Fact]
|
||||
public void Selected_defaultsFalse() => Assert.False(new UiItemSlot().Selected);
|
||||
|
||||
[Fact]
|
||||
public void IsOpenContainer_defaultsFalse() => Assert.False(new UiItemSlot().IsOpenContainer);
|
||||
|
||||
[Fact]
|
||||
public void SelectedSprite_default_isGreenYellowSquare()
|
||||
=> Assert.Equal(0x06004D21u, new UiItemSlot().SelectedSprite);
|
||||
|
||||
[Fact]
|
||||
public void OpenContainerSprite_default_isTriangle()
|
||||
=> Assert.Equal(0x06005D9Cu, new UiItemSlot().OpenContainerSprite);
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run the tests to verify they fail**
|
||||
|
||||
Run: `dotnet test tests/AcDream.App.Tests/AcDream.App.Tests.csproj --filter "FullyQualifiedName~UiItemSlotTests"`
|
||||
Expected: FAIL — `'UiItemSlot' does not contain a definition for 'Selected'` (compile error).
|
||||
|
||||
- [ ] **Step 3: Add the properties**
|
||||
|
||||
In `UiItemSlot.cs`, after the `DragRejectSprite` property (~line 39), add:
|
||||
|
||||
```csharp
|
||||
/// <summary>True when this cell is the OPEN container (its contents fill the grid). Draws the
|
||||
/// open-container triangle. Port of UIElement_ItemList::UpdateOpenContainerIndicator
|
||||
/// (0x004e3070) → SetOpenContainerState (0x004e1200): shown when item.itemID == openContainerId.</summary>
|
||||
public bool IsOpenContainer { get; set; }
|
||||
/// <summary>Open-container triangle sprite (element 0x10000450 on the container prototype
|
||||
/// 0x1000033F). Configurable; guard id != 0 before resolving.</summary>
|
||||
public uint OpenContainerSprite { get; set; } = 0x06005D9Cu;
|
||||
|
||||
/// <summary>True when this cell is the SELECTED item. Draws the green/yellow selection square.
|
||||
/// Port of UIElement_ItemList::ItemList_SetSelectedItem (0x004e2fe0) → SetSelectedState
|
||||
/// (0x004e1240): shown when item.itemID == selectedItemId. Uniform across item + container cells.</summary>
|
||||
public bool Selected { get; set; }
|
||||
/// <summary>Selected-item square sprite (element 0x10000342 on the 32×32 item prototype
|
||||
/// 0x10000341; pixel-confirmed green/yellow frame). Drawn as a procedural overlay so it renders
|
||||
/// on the 36×36 container cell too (whose prototype lacks the square child).</summary>
|
||||
public uint SelectedSprite { get; set; } = 0x06004D21u;
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Add the draw calls**
|
||||
|
||||
In `UiItemSlot.cs` `OnDraw`, immediately BEFORE the drag-rollover block (the `if (_dragAccept != DragAcceptState.None …`, ~line 226), add:
|
||||
|
||||
```csharp
|
||||
// Open-container triangle (retail SetOpenContainerState 0x004e1200) + selected-item square
|
||||
// (retail SetSelectedState 0x004e1240). Drawn procedurally like the digit/drag overlays;
|
||||
// guard id != 0 BEFORE resolving (feedback_ui_resolve_zero_magenta). Triangle under the
|
||||
// square; both under the transient drag overlay below.
|
||||
if (IsOpenContainer && SpriteResolve is not null && OpenContainerSprite != 0)
|
||||
{
|
||||
var (tex, _, _) = SpriteResolve(OpenContainerSprite);
|
||||
if (tex != 0)
|
||||
ctx.DrawSprite(tex, 0f, 0f, Width, Height, 0f, 0f, 1f, 1f, Vector4.One);
|
||||
}
|
||||
if (Selected && SpriteResolve is not null && SelectedSprite != 0)
|
||||
{
|
||||
var (tex, _, _) = SpriteResolve(SelectedSprite);
|
||||
if (tex != 0)
|
||||
ctx.DrawSprite(tex, 0f, 0f, Width, Height, 0f, 0f, 1f, 1f, Vector4.One);
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Run the tests to verify they pass**
|
||||
|
||||
Run: `dotnet test tests/AcDream.App.Tests/AcDream.App.Tests.csproj --filter "FullyQualifiedName~UiItemSlotTests"`
|
||||
Expected: PASS.
|
||||
|
||||
- [ ] **Step 6: Commit**
|
||||
|
||||
```bash
|
||||
git add src/AcDream.App/UI/UiItemSlot.cs tests/AcDream.App.Tests/UI/UiItemSlotTests.cs
|
||||
git commit -m "feat(D.2b): UiItemSlot open-container triangle + selected-item square overlays
|
||||
|
||||
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 5: `InventoryController` container-switching + indicators
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/AcDream.App/UI/Layout/InventoryController.cs` (state, `Bind` signature, `Populate`, click handlers, `ApplyIndicators`, `Concerns`, caption)
|
||||
- Test: `tests/AcDream.App.Tests/UI/Layout/InventoryControllerTests.cs`
|
||||
|
||||
- [ ] **Step 1: Extend the test `Bind` helper + write the failing tests**
|
||||
|
||||
In `InventoryControllerTests.cs`, replace the private `Bind` helper (lines 53-57) with:
|
||||
|
||||
```csharp
|
||||
private static InventoryController Bind(ImportedLayout layout, ClientObjectTable objects,
|
||||
int? strength = 100, List<uint>? uses = null, List<uint>? closes = null)
|
||||
=> InventoryController.Bind(layout, objects, () => Player,
|
||||
iconIds: (_, _, _, _, _) => 0u, // no real GL upload in tests
|
||||
strength: () => strength, datFont: null,
|
||||
sendUse: uses is null ? null : g => uses.Add(g),
|
||||
sendNoLongerViewing: closes is null ? null : g => closes.Add(g));
|
||||
|
||||
// Seed a side bag (a container) in the player's pack, plus optionally its own contents.
|
||||
private static void SeedBag(ClientObjectTable t, uint bag, int slot, int itemsCapacity = 24)
|
||||
{
|
||||
t.AddOrUpdate(new ClientObject { ObjectId = bag, Type = ItemType.Container, ItemsCapacity = itemsCapacity });
|
||||
t.MoveItem(bag, Player, slot);
|
||||
}
|
||||
```
|
||||
|
||||
Then add these tests at the end of the class (before the closing brace):
|
||||
|
||||
```csharp
|
||||
[Fact]
|
||||
public void ClickSideBag_sendsUse_andSwapsGridToBagContents()
|
||||
{
|
||||
var (layout, grid, containers, _, _, _, _, _) = BuildLayout();
|
||||
var objects = new ClientObjectTable();
|
||||
SeedContained(objects, 0xA, Player, slot: 0); // a loose main-pack item
|
||||
SeedBag(objects, 0xC, slot: 1); // side bag (player slot 1)
|
||||
SeedContained(objects, 0xB1, 0xC, slot: 0); // a thing already known to be in the bag
|
||||
var uses = new List<uint>();
|
||||
Bind(layout, objects, uses: uses);
|
||||
|
||||
// The side-bag column's first cell holds the bag guid; click it.
|
||||
containers.GetItem(0)!.Clicked!();
|
||||
|
||||
Assert.Contains(0xCu, uses); // Use(bag) sent
|
||||
Assert.Equal(0xB1u, grid.GetItem(0)!.ItemId); // grid swapped to the bag's contents
|
||||
Assert.Equal(24, grid.GetNumUIItems()); // padded to the bag's ItemsCapacity
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ClickMainPackCell_afterBag_closesBag_returnsToMainPack()
|
||||
{
|
||||
var (layout, grid, containers, top, _, _, _, _) = BuildLayout();
|
||||
var objects = new ClientObjectTable();
|
||||
SeedContained(objects, 0xA, Player, slot: 0); // loose main-pack item
|
||||
SeedBag(objects, 0xC, slot: 1);
|
||||
SeedContained(objects, 0xB1, 0xC, slot: 0);
|
||||
var uses = new List<uint>();
|
||||
var closes = new List<uint>();
|
||||
var ctrl = Bind(layout, objects, uses: uses, closes: closes);
|
||||
|
||||
containers.GetItem(0)!.Clicked!(); // open the bag
|
||||
top.GetItem(0)!.Clicked!(); // click the main-pack cell
|
||||
|
||||
Assert.Contains(0xCu, closes); // NoLongerViewingContents(bag) sent
|
||||
Assert.DoesNotContain(Player, uses); // no Use for the main pack
|
||||
Assert.Equal(0xAu, grid.GetItem(0)!.ItemId); // grid back to the main pack
|
||||
Assert.Equal(102, grid.GetNumUIItems());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SwitchBetweenTwoBags_closesPrevious_opensNext()
|
||||
{
|
||||
var (layout, _, containers, _, _, _, _, _) = BuildLayout();
|
||||
var objects = new ClientObjectTable();
|
||||
SeedBag(objects, 0xC1, slot: 0);
|
||||
SeedBag(objects, 0xC2, slot: 1);
|
||||
var uses = new List<uint>();
|
||||
var closes = new List<uint>();
|
||||
Bind(layout, objects, uses: uses, closes: closes);
|
||||
|
||||
containers.GetItem(0)!.Clicked!(); // open bag A (column index 0 → 0xC1)
|
||||
containers.GetItem(1)!.Clicked!(); // open bag B (column index 1 → 0xC2)
|
||||
|
||||
Assert.Equal(new[] { 0xC1u, 0xC2u }, uses.ToArray()); // Use(A) then Use(B)
|
||||
Assert.Equal(new[] { 0xC1u }, closes.ToArray()); // close A when switching to B (not the main pack)
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void OpenBag_marksTriangleAndSquare_onTheBagCell()
|
||||
{
|
||||
var (layout, _, containers, _, _, _, _, _) = BuildLayout();
|
||||
var objects = new ClientObjectTable();
|
||||
SeedBag(objects, 0xC, slot: 0);
|
||||
Bind(layout, objects, uses: new List<uint>());
|
||||
|
||||
containers.GetItem(0)!.Clicked!();
|
||||
|
||||
Assert.True(containers.GetItem(0)!.IsOpenContainer); // triangle on the open bag
|
||||
Assert.True(containers.GetItem(0)!.Selected); // square — the bag is also the selected item
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ClickGridItem_movesSquareOnly_noWire_keepsOpenContainer()
|
||||
{
|
||||
var (layout, grid, _, top, _, _, _, _) = BuildLayout();
|
||||
var objects = new ClientObjectTable();
|
||||
SeedContained(objects, 0xA, Player, slot: 0);
|
||||
var uses = new List<uint>();
|
||||
var closes = new List<uint>();
|
||||
Bind(layout, objects, uses: uses, closes: closes);
|
||||
|
||||
grid.GetItem(0)!.Clicked!(); // select the loose item
|
||||
|
||||
Assert.True(grid.GetItem(0)!.Selected); // square on the selected grid item
|
||||
Assert.True(top.GetItem(0)!.IsOpenContainer); // main pack still the open container (unchanged)
|
||||
Assert.Empty(uses); // selection sends no wire
|
||||
Assert.Empty(closes);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Default_mainPackCell_isOpenContainer()
|
||||
{
|
||||
var (layout, _, _, top, _, _, _, _) = BuildLayout();
|
||||
var objects = new ClientObjectTable();
|
||||
Bind(layout, objects);
|
||||
Assert.True(top.GetItem(0)!.IsOpenContainer); // default open container = main pack
|
||||
}
|
||||
```
|
||||
|
||||
Note: `BuildLayout()` returns `top` as the 4th tuple element (the `_topContainer` UiItemList); the existing tests discard it with `_`. The new tests above bind it where needed.
|
||||
|
||||
- [ ] **Step 2: Run the tests to verify they fail**
|
||||
|
||||
Run: `dotnet test tests/AcDream.App.Tests/AcDream.App.Tests.csproj --filter "FullyQualifiedName~InventoryControllerTests"`
|
||||
Expected: FAIL — `InventoryController.Bind` has no `sendUse`/`sendNoLongerViewing` parameters (compile error).
|
||||
|
||||
- [ ] **Step 3: Add controller state + new ctor/Bind parameters**
|
||||
|
||||
In `InventoryController.cs`:
|
||||
|
||||
(a) Add constant near `MainPackSlots` (~line 39):
|
||||
```csharp
|
||||
private const int SidePackSlots = 24; // standard side-pack capacity (reference_retail_inventory_paperdoll)
|
||||
```
|
||||
|
||||
(b) Add fields near `_burdenPercent` (~line 52):
|
||||
```csharp
|
||||
private uint _openContainer; // 0 = the main pack (the player); else the open side bag's guid
|
||||
private uint _selectedItem; // 0 = none; the panel-wide selected item (green square)
|
||||
private readonly Action<uint>? _sendUse;
|
||||
private readonly Action<uint>? _sendNoLongerViewing;
|
||||
```
|
||||
|
||||
(c) Add ctor parameters (append to the existing private ctor parameter list, after `mainPackEmptySprite`):
|
||||
```csharp
|
||||
uint mainPackEmptySprite,
|
||||
Action<uint>? sendUse,
|
||||
Action<uint>? sendNoLongerViewing)
|
||||
```
|
||||
and assign them at the top of the ctor body (after `_strength = strength;`):
|
||||
```csharp
|
||||
_sendUse = sendUse;
|
||||
_sendNoLongerViewing = sendNoLongerViewing;
|
||||
```
|
||||
|
||||
(d) Add the same two parameters to the public `Bind` (after `mainPackEmptySprite = 0u`), and pass them through:
|
||||
```csharp
|
||||
uint mainPackEmptySprite = 0u,
|
||||
Action<uint>? sendUse = null,
|
||||
Action<uint>? sendNoLongerViewing = null)
|
||||
=> new InventoryController(layout, objects, playerGuid, iconIds, strength, datFont,
|
||||
contentsEmptySprite, sideBagEmptySprite, mainPackEmptySprite,
|
||||
sendUse, sendNoLongerViewing);
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Replace `Populate` + add the new private methods**
|
||||
|
||||
Replace the entire `Populate` method body with:
|
||||
|
||||
```csharp
|
||||
public void Populate()
|
||||
{
|
||||
uint p = _playerGuid();
|
||||
uint open = EffectiveOpen();
|
||||
|
||||
_contentsGrid?.Flush();
|
||||
_containerList?.Flush();
|
||||
|
||||
// Side-bag column: ALWAYS the player's bags (constant across container switches; only the
|
||||
// open/selected indicators move). Equipped items never appear here.
|
||||
foreach (var guid in _objects.GetContents(p))
|
||||
{
|
||||
var item = _objects.Get(guid);
|
||||
if (item is null || item.CurrentlyEquippedLocation != EquipMask.None) continue;
|
||||
bool isBag = item.Type.HasFlag(ItemType.Container) || item.ItemsCapacity > 0;
|
||||
if (isBag) AddCell(_containerList, guid, isContainer: true);
|
||||
}
|
||||
|
||||
// Contents grid: the OPEN container's loose items. (Bags live in the column; a side bag has
|
||||
// no sub-bags, so the isBag skip is a no-op when a bag is open.)
|
||||
foreach (var guid in _objects.GetContents(open))
|
||||
{
|
||||
var item = _objects.Get(guid);
|
||||
if (item is null || item.CurrentlyEquippedLocation != EquipMask.None) continue;
|
||||
bool isBag = item.Type.HasFlag(ItemType.Container) || item.ItemsCapacity > 0;
|
||||
if (!isBag) AddCell(_contentsGrid, guid, isContainer: false);
|
||||
}
|
||||
|
||||
// Pad the grid to the OPEN container's capacity (main pack 102 / side bag 24).
|
||||
if (_contentsGrid is not null)
|
||||
{
|
||||
int cap = _objects.Get(open)?.ItemsCapacity ?? 0;
|
||||
int slots = cap > 0 ? cap : (open == p ? MainPackSlots : SidePackSlots);
|
||||
while (_contentsGrid.GetNumUIItems() < slots) AddEmptyCell(_contentsGrid);
|
||||
}
|
||||
|
||||
// Pad the side-bag column to capacity, clamped to the 7-slot column (AP-52).
|
||||
if (_containerList is not null)
|
||||
{
|
||||
int capacity = _objects.Get(p)?.ContainersCapacity ?? 0;
|
||||
int bags = _containerList.GetNumUIItems();
|
||||
int slots = capacity > 0 ? capacity : SideBagSlots;
|
||||
slots = Math.Max(slots, bags);
|
||||
slots = Math.Min(slots, SideBagSlots);
|
||||
while (_containerList.GetNumUIItems() < slots) AddEmptyCell(_containerList);
|
||||
}
|
||||
|
||||
// Main-pack cell: the player's own container — clicking it opens/selects the main pack.
|
||||
if (_topContainer is not null)
|
||||
{
|
||||
_topContainer.Flush();
|
||||
var main = new UiItemSlot { SpriteResolve = _topContainer.SpriteResolve };
|
||||
main.SetItem(p, 0u);
|
||||
main.Clicked = () => OpenContainer(p);
|
||||
_topContainer.AddItem(main);
|
||||
}
|
||||
|
||||
ApplyIndicators();
|
||||
RefreshBurden();
|
||||
}
|
||||
|
||||
/// <summary>The effective open container — the explicit one, or the player (main pack) by default.
|
||||
/// Resolved live (not cached at ctor) so a late-arriving player guid is handled.</summary>
|
||||
private uint EffectiveOpen() => _openContainer != 0 ? _openContainer : _playerGuid();
|
||||
|
||||
/// <summary>Add a populated cell wired to its click role: container cell → open+select,
|
||||
/// item cell → select-only.</summary>
|
||||
private void AddCell(UiItemList? list, uint guid, bool isContainer)
|
||||
{
|
||||
if (list is null) return;
|
||||
var item = _objects.Get(guid);
|
||||
uint tex = item is null ? 0u
|
||||
: _iconIds(item.Type, item.IconId, item.IconUnderlayId, item.IconOverlayId, item.Effects);
|
||||
var cell = new UiItemSlot { SpriteResolve = list.SpriteResolve };
|
||||
cell.SetItem(guid, tex);
|
||||
if (isContainer) cell.Clicked = () => OpenContainer(guid);
|
||||
else cell.Clicked = () => SelectItem(guid);
|
||||
list.AddItem(cell);
|
||||
}
|
||||
|
||||
private static void AddEmptyCell(UiItemList list)
|
||||
=> list.AddItem(new UiItemSlot { SpriteResolve = list.SpriteResolve });
|
||||
|
||||
/// <summary>Select an item (panel-wide green square) without changing the open container or
|
||||
/// touching the wire. Retail: UIElement_ItemList::ItemList_SetSelectedItem (0x004e2fe0).</summary>
|
||||
private void SelectItem(uint guid)
|
||||
{
|
||||
if (guid == 0) return;
|
||||
_selectedItem = guid;
|
||||
ApplyIndicators();
|
||||
}
|
||||
|
||||
/// <summary>Open a container (side bag or main pack): it becomes both the selected item and the
|
||||
/// open container. Sends Use to open a side bag server-side (ViewContents arrives async), and
|
||||
/// NoLongerViewingContents when switching away from a previously-open side bag. Retail:
|
||||
/// gmBackpackUI container-selection + CM_Physics::Event_Use.</summary>
|
||||
private void OpenContainer(uint guid)
|
||||
{
|
||||
if (guid == 0) return;
|
||||
_selectedItem = guid; // the container cell is also the selected item
|
||||
uint open = EffectiveOpen();
|
||||
if (guid == open) { ApplyIndicators(); return; } // already open — just move the square
|
||||
|
||||
uint p = _playerGuid();
|
||||
if (open != p && open != 0)
|
||||
_sendNoLongerViewing?.Invoke(open); // close the previously-open side bag
|
||||
_openContainer = guid;
|
||||
if (guid != p)
|
||||
_sendUse?.Invoke(guid); // open the side bag (ViewContents will land)
|
||||
Populate(); // repaint the grid for the new open container
|
||||
}
|
||||
|
||||
/// <summary>Stamp the open-container triangle + selected-item square onto every cell per the
|
||||
/// retail keying (item.itemID == openContainerId / selectedItemId).</summary>
|
||||
private void ApplyIndicators()
|
||||
{
|
||||
SetIndicators(_contentsGrid);
|
||||
SetIndicators(_containerList);
|
||||
SetIndicators(_topContainer);
|
||||
}
|
||||
|
||||
private void SetIndicators(UiItemList? list)
|
||||
{
|
||||
if (list is null) return;
|
||||
uint open = EffectiveOpen();
|
||||
for (int i = 0; i < list.GetNumUIItems(); i++)
|
||||
{
|
||||
var cell = list.GetItem(i);
|
||||
if (cell is null) continue;
|
||||
cell.Selected = cell.ItemId != 0 && cell.ItemId == _selectedItem;
|
||||
cell.IsOpenContainer = cell.ItemId != 0 && cell.ItemId == open;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Extend `Concerns` + the caption**
|
||||
|
||||
(a) In `Concerns`, add the open-container clause. Replace the return with:
|
||||
```csharp
|
||||
uint p = _playerGuid();
|
||||
return o.ObjectId == p // the player object IS the burden source
|
||||
|| o.ContainerId == p || o.WielderId == p
|
||||
|| o.ContainerId == EffectiveOpen() // the OPEN container's contents drive the grid
|
||||
|| (o.ContainerId != 0 && _objects.Get(o.ContainerId)?.ContainerId == p); // 2-deep
|
||||
```
|
||||
|
||||
(b) In the ctor, change the contents-caption attach (currently `() => "Contents of Backpack"`) to follow the open container:
|
||||
```csharp
|
||||
AttachCaption(layout.FindElement(ContentsCaptionId), () => "Contents of " + OpenContainerName(), datFont);
|
||||
```
|
||||
and add the helper near `EffectiveOpen`:
|
||||
```csharp
|
||||
/// <summary>The open container's display name for the contents caption.</summary>
|
||||
private string OpenContainerName()
|
||||
{
|
||||
uint open = EffectiveOpen();
|
||||
return open == _playerGuid() ? "Backpack" : (_objects.Get(open)?.Name ?? "Backpack");
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 6: Run the tests to verify they pass**
|
||||
|
||||
Run: `dotnet test tests/AcDream.App.Tests/AcDream.App.Tests.csproj --filter "FullyQualifiedName~InventoryControllerTests"`
|
||||
Expected: PASS (the 6 new tests + all existing InventoryController tests).
|
||||
|
||||
- [ ] **Step 7: Commit**
|
||||
|
||||
```bash
|
||||
git add src/AcDream.App/UI/Layout/InventoryController.cs tests/AcDream.App.Tests/UI/Layout/InventoryControllerTests.cs
|
||||
git commit -m "feat(D.2b): InventoryController container-switching + open/selected indicators
|
||||
|
||||
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 6: wire the send callbacks in `GameWindow`
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/AcDream.App/Rendering/GameWindow.cs:2231-2241` (the `InventoryController.Bind` call)
|
||||
|
||||
**Test note:** `GameWindow` is integration-wired, not unit-tested; verified by build + the visual gate (Task 8).
|
||||
|
||||
- [ ] **Step 1: Add the two callbacks to the Bind call**
|
||||
|
||||
In `GameWindow.cs`, in the `InventoryController.Bind(...)` call (~line 2231), append two arguments after `mainPackEmptySprite: mainPackEmpty`:
|
||||
|
||||
```csharp
|
||||
contentsEmptySprite: contentsEmpty,
|
||||
sideBagEmptySprite: sideBagEmpty,
|
||||
mainPackEmptySprite: mainPackEmpty,
|
||||
sendUse: g => _liveSession?.SendUse(g),
|
||||
sendNoLongerViewing: g => _liveSession?.SendNoLongerViewingContents(g));
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Verify it compiles**
|
||||
|
||||
Run: `dotnet build src/AcDream.App/AcDream.App.csproj`
|
||||
Expected: Build succeeded.
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add src/AcDream.App/Rendering/GameWindow.cs
|
||||
git commit -m "feat(D.2b): wire InventoryController container-open callbacks to the live session
|
||||
|
||||
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 7: divergence register + docs
|
||||
|
||||
**Files:**
|
||||
- Modify: `docs/architecture/retail-divergence-register.md` (retire AP-56; reword AP-53; add new rows)
|
||||
|
||||
- [ ] **Step 1: Retire AP-56**
|
||||
|
||||
Delete the AP-56 row (line ~158) — both indicators are now drawn.
|
||||
|
||||
- [ ] **Step 2: Reword AP-53**
|
||||
|
||||
Change AP-53's text from "the grid always shows the main pack (container-switch to a side pack's 24 not wired)" / "selecting a side pack doesn't show its 24-slot contents yet" to note that container-switching is now wired and the row covers only the capacity *default* (102/24 when `ItemsCapacity`/`ContainersCapacity` is absent).
|
||||
|
||||
- [ ] **Step 3: Add the new rows** (use the next free AP-numbers after the current max; e.g. AP-57/AP-58):
|
||||
|
||||
- A row: the open-container triangle (`0x06005D9C`) + selected-item square (`0x06004D21`) are drawn as **procedural `UiItemSlot` overlays** keyed by controller state (`_openContainer`/`_selectedItem`), reproducing the retail keying (`item.itemID == openContainerId / selectedItemId` per `UpdateOpenContainerIndicator 0x004e3070` / `ItemList_SetSelectedItem 0x004e2fe0`) but not via the dat prototype's `m_elem_Icon_OpenContainer`/`m_elem_Icon_Selected` state elements + `SetOpenContainerState`/`SetSelectedState` calls. File: `src/AcDream.App/UI/UiItemSlot.cs`, `src/AcDream.App/UI/Layout/InventoryController.cs`. Risk: a cell that should show an indicator via a dat-state path retail uses but we don't would be missed; outcome currently matches retail (the 36×36 container prototype lacks the square child, so the procedural overlay is the only way to show the square on a selected bag).
|
||||
- A row: inventory item **selection is panel-local + visual-only** — not wired to the selected-object bar (`SelectedObjectController`) or to global 3D-world selection. File: `src/AcDream.App/UI/Layout/InventoryController.cs`. Risk: selecting an inventory item doesn't populate the selected-object strip as retail does; deferred to the selection follow-up.
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add docs/architecture/retail-divergence-register.md
|
||||
git commit -m "docs(D.2b): divergence register — retire AP-56, reword AP-53, add overlay/selection rows
|
||||
|
||||
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 8: full-suite gate + visual verification
|
||||
|
||||
**Files:** none (verification only).
|
||||
|
||||
- [ ] **Step 1: Full build + full test suite**
|
||||
|
||||
Run: `dotnet build` then `dotnet test`
|
||||
Expected: Build succeeded; ALL tests pass (run the FULL suite, not a filtered subset — the B-Wire process lesson: a filtered batch hid a cross-file regression).
|
||||
|
||||
- [ ] **Step 2: Launch the client (plain `dotnet run`, DO NOT pre-kill any running client)**
|
||||
|
||||
If a rebuild is locked by a running client, ASK the user to close it — do not Stop-Process. Launch (PowerShell, background, per CLAUDE.md):
|
||||
|
||||
```powershell
|
||||
$env:ACDREAM_DAT_DIR="$env:USERPROFILE\Documents\Asheron's Call"; $env:ACDREAM_LIVE="1"; $env:ACDREAM_TEST_HOST="127.0.0.1"; $env:ACDREAM_TEST_PORT="9000"; $env:ACDREAM_TEST_USER="testaccount"; $env:ACDREAM_TEST_PASS="testpassword"; $env:ACDREAM_RETAIL_UI="1"
|
||||
dotnet run --project src\AcDream.App\AcDream.App.csproj --no-build -c Debug 2>&1 | Tee-Object -FilePath "launch.log"
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Capture the Use → ViewContents round-trip** (settles lazy-load-on-open)
|
||||
|
||||
While the client is in-world with F12 open, capture loopback with WireMCP (`capture_packets` on the `127.0.0.1:9000` conversation) and/or grep `launch.log` for the ViewContents handler, while the user clicks a side bag. Confirm a `Use 0x0036` goes out and a `ViewContents 0x0196` comes back, and note whether the bag's contents arrived only on open.
|
||||
|
||||
- [ ] **Step 4: Visual gate (the oracle — user confirms)**
|
||||
|
||||
The user, with F12 open: clicks a side bag → grid swaps to its contents; the open bag shows the triangle; the clicked cell shows the green/yellow square; clicks another bag → grid + indicators follow + the previous bag is closed; clicks the main-pack cell → grid returns to the main pack. Iterate on any sprite/positioning mismatch (the visual gate is the oracle for WHICH sprite, per the empty-slot-art lesson).
|
||||
|
||||
- [ ] **Step 5: Update SSOT + roadmap after the gate passes**
|
||||
|
||||
Append a "container-switching SHIPPED" entry to `claude-memory/project_d2b_retail_ui.md` (key facts + any DO-NOT-RETRY from the gate), update the roadmap/milestones if a milestone marker moved, and record the lazy-load finding. Commit.
|
||||
|
||||
---
|
||||
|
||||
## Self-review
|
||||
|
||||
**Spec coverage:** ReplaceContents (T1) ↔ spec §Components.1; ViewContents full-replace (T2) ↔ §Components.2; SendUse (T3) ↔ §Components.3; UiItemSlot overlays (T4) ↔ §Components.4; InventoryController state/Populate/clicks/indicators/Concerns/caption (T5) ↔ §Components.5; GameWindow wiring (T6) ↔ §Components.6; divergence rows (T7) ↔ §Divergence register; full suite + visual gate + lazy-load capture (T8) ↔ §Acceptance + §Open verification. All spec sections covered.
|
||||
|
||||
**Placeholder scan:** every code step shows complete code; no TBD/TODO; the only "as appropriate" is T7's next-free-AP-number, which is mechanical (read the current max in the register).
|
||||
|
||||
**Type consistency:** `ReplaceContents(uint, IReadOnlyList<uint>)` consistent T1↔T2. `SendUse(uint)` T3↔T6. `IsOpenContainer`/`OpenContainerSprite`/`Selected`/`SelectedSprite` consistent T4↔T5. `Bind(..., Action<uint>? sendUse, Action<uint>? sendNoLongerViewing)` consistent T5↔T6 (test helper)↔T6 (GameWindow). `EffectiveOpen()`/`OpenContainer()`/`SelectItem()`/`ApplyIndicators()`/`SetIndicators()`/`AddCell()`/`AddEmptyCell()`/`OpenContainerName()` all defined in T5. `SidePackSlots` const defined T5 step 3a.
|
||||
556
docs/superpowers/plans/2026-06-22-d2b-empty-slot-art.md
Normal file
556
docs/superpowers/plans/2026-06-22-d2b-empty-slot-art.md
Normal file
|
|
@ -0,0 +1,556 @@
|
|||
# D.2b Inventory empty-slot art — 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 the inventory contents grid, side-bag column, and main-pack cell render their correct per-list empty-slot background — resolved from the dat exactly as retail does — instead of the hardcoded generic toolbar square.
|
||||
|
||||
**Architecture:** Port retail's `UIElement_ItemList::InternalCreateItem` resolver into a small static helper (`ItemListCellTemplate.ResolveEmptySprite`): read attribute `0x1000000e` off the list's ElementDesc → a cell-template element id → look it up in catalog LayoutDesc `0x21000037` → take that prototype's empty-slot media. A new `UiItemList.CellEmptySprite` carries the resolved sprite to the cells; `InventoryController` sets it for the three lists; `GameWindow` resolves and passes the three values (mirroring the existing `0x21000037` digit-array read + `ToolbarController.Bind`). `UiItemSlot` and the toolbar are untouched.
|
||||
|
||||
**Tech Stack:** C# / .NET 10, xUnit, `DatReaderWriter` (dat reader), the existing `AcDream.App.UI.Layout` importer.
|
||||
|
||||
**Spec:** `docs/superpowers/specs/2026-06-22-d2b-empty-slot-art-design.md`.
|
||||
|
||||
---
|
||||
|
||||
## File Structure
|
||||
|
||||
| File | Create/Modify | Responsibility |
|
||||
|---|---|---|
|
||||
| `src/AcDream.App/UI/Layout/ItemListCellTemplate.cs` | **Create** | The pure dat→sprite resolver (port of `InternalCreateItem`'s template lookup). |
|
||||
| `tests/AcDream.App.Tests/UI/Layout/ItemListCellTemplateTests.cs` | **Create** | Real-dat smoke + the "pin the exact asset" printout (skips without the dat). |
|
||||
| `src/AcDream.App/UI/UiItemList.cs` | Modify | Add `CellEmptySprite` (stamps cells). |
|
||||
| `tests/AcDream.App.Tests/UI/UiItemListTests.cs` | Modify | Unit test for `CellEmptySprite` (no dat). |
|
||||
| `src/AcDream.App/UI/Layout/InventoryController.cs` | Modify | `Bind`/ctor gain the three sprites; apply to the three lists. |
|
||||
| `tests/AcDream.App.Tests/UI/Layout/InventoryControllerTests.cs` | Modify | Test the three lists receive the sprites. |
|
||||
| `src/AcDream.App/Rendering/GameWindow.cs` | Modify (near `:2217`) | Resolve the three sprites; pass into `InventoryController.Bind`. |
|
||||
| `docs/architecture/retail-divergence-register.md` | Modify | Two AP rows (toolbar hardcode; flat-cell approximation). |
|
||||
| `docs/ISSUES.md` | Modify | Move the empty-slot-art issue to *Recently closed* (inventory portion). |
|
||||
|
||||
`UiItemSlot.cs` is **not** modified — its `0x060074CF` default stays (correct for the toolbar); cells receive the per-list value via `UiItemList.CellEmptySprite`.
|
||||
|
||||
---
|
||||
|
||||
### Task 1: The resolver + real-dat pinning test
|
||||
|
||||
**Files:**
|
||||
- Create: `src/AcDream.App/UI/Layout/ItemListCellTemplate.cs`
|
||||
- Test: `tests/AcDream.App.Tests/UI/Layout/ItemListCellTemplateTests.cs`
|
||||
|
||||
- [ ] **Step 1: Write the failing real-dat test**
|
||||
|
||||
Create `tests/AcDream.App.Tests/UI/Layout/ItemListCellTemplateTests.cs`:
|
||||
|
||||
```csharp
|
||||
using System;
|
||||
using System.IO;
|
||||
using AcDream.App.UI.Layout;
|
||||
using DatReaderWriter;
|
||||
using DatReaderWriter.Options;
|
||||
using Xunit;
|
||||
using Xunit.Abstractions;
|
||||
|
||||
namespace AcDream.App.Tests.UI.Layout;
|
||||
|
||||
/// <summary>
|
||||
/// Real-dat test for <see cref="ItemListCellTemplate.ResolveEmptySprite"/>: the inventory
|
||||
/// item-lists must resolve their empty-slot sprite from the dat cell template (attribute
|
||||
/// 0x1000000e -> catalog 0x21000037 prototype's ItemSlot_Empty), NOT the generic toolbar
|
||||
/// square 0x060074CF. Also PRINTS the resolved ids — the "pin the exact asset" record.
|
||||
/// Skips when the live dat directory is absent (CI).
|
||||
/// </summary>
|
||||
public class ItemListCellTemplateTests
|
||||
{
|
||||
private const uint Generic = 0x060074CFu; // generic toolbar-shortcut empty square (the bug)
|
||||
|
||||
private const uint ContentsLayout = 0x21000021u; private const uint ContentsGrid = 0x100001C6u;
|
||||
private const uint BackpackLayout = 0x21000022u; private const uint SideBag = 0x100001CAu;
|
||||
private const uint MainPack = 0x100001C9u;
|
||||
|
||||
private readonly ITestOutputHelper _out;
|
||||
public ItemListCellTemplateTests(ITestOutputHelper o) => _out = o;
|
||||
|
||||
private static string? DatDir()
|
||||
{
|
||||
var d = Environment.GetEnvironmentVariable("ACDREAM_DAT_DIR")
|
||||
?? Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile),
|
||||
"Documents", "Asheron's Call");
|
||||
return Directory.Exists(d) ? d : null;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Inventory_lists_resolve_a_real_nongeneric_empty_sprite()
|
||||
{
|
||||
var datDir = DatDir();
|
||||
if (datDir is null) return; // CI: no live dat — skip (smoke test)
|
||||
|
||||
using var dats = new DatCollection(datDir, DatAccessType.Read);
|
||||
|
||||
foreach (var (name, layout, elem) in new[]
|
||||
{
|
||||
("contents", ContentsLayout, ContentsGrid),
|
||||
("side-bag", BackpackLayout, SideBag),
|
||||
("main-pack", BackpackLayout, MainPack),
|
||||
})
|
||||
{
|
||||
uint sprite = ItemListCellTemplate.ResolveEmptySprite(dats, layout, elem);
|
||||
_out.WriteLine($"{name}: list 0x{elem:X8} (layout 0x{layout:X8}) -> empty sprite 0x{sprite:X8}");
|
||||
Assert.True(sprite != 0u, $"{name}: resolved 0 (no 0x1000000e attr / prototype / media)");
|
||||
Assert.True(sprite != Generic, $"{name}: resolved the generic 0x060074CF — the bug, not the fix");
|
||||
Assert.True((sprite & 0xFF000000u) == 0x06000000u, $"{name}: 0x{sprite:X8} is not a 0x06 RenderSurface id");
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run the test to verify it fails**
|
||||
|
||||
Run: `dotnet test tests/AcDream.App.Tests/AcDream.App.Tests.csproj --filter "FullyQualifiedName~ItemListCellTemplateTests"`
|
||||
Expected: **compile error** — `ItemListCellTemplate` does not exist.
|
||||
|
||||
- [ ] **Step 3: Implement the resolver**
|
||||
|
||||
Create `src/AcDream.App/UI/Layout/ItemListCellTemplate.cs`:
|
||||
|
||||
```csharp
|
||||
using DatReaderWriter;
|
||||
using DatReaderWriter.DBObjs;
|
||||
using DatReaderWriter.Types;
|
||||
|
||||
namespace AcDream.App.UI.Layout;
|
||||
|
||||
/// <summary>
|
||||
/// Resolves the empty-slot background sprite for a UIElement_ItemList's cells the way retail
|
||||
/// does. Retail ref: UIElement_ItemList::InternalCreateItem (acclient_2013_pseudo_c.txt:231486 /
|
||||
/// 0x004e3570): GetAttribute_Enum(this, 0x1000000e, &id) -> GetByEnum(0x10000038,5,0x23) [the
|
||||
/// shared UIItem catalog LayoutDesc, = 0x21000037] -> CreateChildElement(catalog, id); the cloned
|
||||
/// prototype's ItemSlot_Empty (state 0x1000001c) media is the empty-cell background.
|
||||
/// </summary>
|
||||
public static class ItemListCellTemplate
|
||||
{
|
||||
/// <summary>The shared UIItem cell-template catalog. Hardcoded: retail resolves it via
|
||||
/// GetByEnum(0x10000038,5,0x23) through a master enum-map DAT object (no code literal);
|
||||
/// 0x21000037 is the only LayoutDesc that is a catalog of standalone UIItem (0x10000032)
|
||||
/// prototypes. Validated by the real-dat test (the resolved sprite must be a real 0x06 surface).</summary>
|
||||
public const uint CatalogLayoutId = 0x21000037u;
|
||||
|
||||
private const uint CellTemplateAttr = 0x1000000eu; // UIElement attribute: cell-template element id
|
||||
private const uint IconChildId = 0x1000033Bu; // m_elem_Icon sub-element (carries ItemSlot_Empty)
|
||||
private const string ItemSlotEmpty = "ItemSlot_Empty"; // UIStateId.ToString() for state 0x1000001c
|
||||
|
||||
/// <summary>
|
||||
/// Resolve the empty-slot sprite (a 0x06xxxxxx RenderSurface id) for the cells of item-list
|
||||
/// element <paramref name="listElementId"/> in LayoutDesc <paramref name="listLayoutId"/>.
|
||||
/// Returns 0 when the layout/element/attribute/prototype/media is absent (the caller keeps
|
||||
/// the <see cref="UiItemSlot"/> default).
|
||||
/// </summary>
|
||||
public static uint ResolveEmptySprite(DatCollection dats, uint listLayoutId, uint listElementId)
|
||||
{
|
||||
var listLd = dats.Get<LayoutDesc>(listLayoutId);
|
||||
if (listLd is null) return 0;
|
||||
var listElem = FindDesc(listLd, listElementId);
|
||||
if (listElem is null) return 0;
|
||||
|
||||
uint protoId = ReadCellTemplateId(listElem); // attr 0x1000000e
|
||||
if (protoId == 0) return 0;
|
||||
|
||||
var catalog = dats.Get<LayoutDesc>(CatalogLayoutId);
|
||||
if (catalog is null) return 0;
|
||||
var proto = FindDesc(catalog, protoId);
|
||||
if (proto is null) return 0;
|
||||
|
||||
// Priority: a child's DirectState background frame (the 36x36 backpack cell's recessed
|
||||
// border, e.g. 0x06005D9C); else the m_elem_Icon child's ItemSlot_Empty media (the 32x32
|
||||
// contents cell, e.g. 0x06004D20). See the spec's explicit single-sprite contract.
|
||||
uint frame = FirstChildDirectStateImage(proto);
|
||||
return frame != 0 ? frame : IconChildEmptyImage(proto);
|
||||
}
|
||||
|
||||
// ── attribute 0x1000000e (read like the font DID 0x1A: bare DataIdBaseProperty or an array) ──
|
||||
private static uint ReadCellTemplateId(ElementDesc elem)
|
||||
{
|
||||
uint id = ReadIdFromState(elem.StateDesc);
|
||||
if (id != 0) return id;
|
||||
foreach (var s in elem.States)
|
||||
{
|
||||
id = ReadIdFromState(s.Value);
|
||||
if (id != 0) return id;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
private static uint ReadIdFromState(StateDesc? sd)
|
||||
{
|
||||
if (sd?.Properties is null) return 0;
|
||||
return sd.Properties.TryGetValue(CellTemplateAttr, out var raw) ? ReadId(raw) : 0;
|
||||
}
|
||||
|
||||
private static uint ReadId(BaseProperty raw)
|
||||
{
|
||||
if (raw is ArrayBaseProperty arr && arr.Value.Count > 0) return ReadId(arr.Value[0]);
|
||||
if (raw is DataIdBaseProperty did) return did.Value;
|
||||
return 0;
|
||||
}
|
||||
|
||||
// ── prototype media ──
|
||||
private static uint FirstChildDirectStateImage(ElementDesc proto)
|
||||
{
|
||||
foreach (var kv in proto.Children)
|
||||
{
|
||||
uint f = DirectStateImage(kv.Value);
|
||||
if (f != 0) return f;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
private static uint IconChildEmptyImage(ElementDesc proto)
|
||||
{
|
||||
var icon = FindDescIn(proto, IconChildId); // recursive: handles inner wrappers (e.g. 0x10000340)
|
||||
if (icon is null) return 0;
|
||||
foreach (var s in icon.States)
|
||||
if (s.Key.ToString() == ItemSlotEmpty)
|
||||
{
|
||||
uint f = StateImage(s.Value);
|
||||
if (f != 0) return f;
|
||||
}
|
||||
return DirectStateImage(icon); // some prototypes carry empty media on the DirectState
|
||||
}
|
||||
|
||||
private static uint DirectStateImage(ElementDesc d)
|
||||
=> d.StateDesc is null ? 0u : StateImage(d.StateDesc);
|
||||
|
||||
private static uint StateImage(StateDesc sd)
|
||||
{
|
||||
foreach (var m in sd.Media)
|
||||
if (m is MediaDescImage img && img.File != 0) return img.File;
|
||||
return 0;
|
||||
}
|
||||
|
||||
// ── depth-first element search by id (LayoutImporter.FindDesc is private there) ──
|
||||
private static ElementDesc? FindDesc(LayoutDesc ld, uint id)
|
||||
{
|
||||
foreach (var kv in ld.Elements)
|
||||
{
|
||||
var f = FindDescIn(kv.Value, id);
|
||||
if (f is not null) return f;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private static ElementDesc? FindDescIn(ElementDesc d, uint id)
|
||||
{
|
||||
if (d.ElementId == id) return d;
|
||||
foreach (var kv in d.Children)
|
||||
{
|
||||
var f = FindDescIn(kv.Value, id);
|
||||
if (f is not null) return f;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run the test to verify it passes (and PIN the values)**
|
||||
|
||||
Run (this machine has the live dat at `~/Documents/Asheron's Call`):
|
||||
`dotnet test tests/AcDream.App.Tests/AcDream.App.Tests.csproj --filter "FullyQualifiedName~ItemListCellTemplateTests" -v n`
|
||||
Expected: **PASS**. In the test output, **record the three printed sprite ids** (the "pin the exact asset" deliverable), e.g. `contents: ... -> empty sprite 0x06004D20`. If any line prints `0x00000000` or fails the `!= Generic` assert, the `0x1000000e` attribute was not found where expected — fall back to the cdb route in the spec §6 (break at `InternalCreateItem` `004e3616`, read arg4 + the catalog DID) and extend `ReadId`/the priority before continuing.
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add src/AcDream.App/UI/Layout/ItemListCellTemplate.cs tests/AcDream.App.Tests/UI/Layout/ItemListCellTemplateTests.cs
|
||||
git commit -m "feat(ui): D.2b empty-slot art — ItemListCellTemplate resolver (0x1000000e -> 0x21000037)
|
||||
|
||||
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: `UiItemList.CellEmptySprite`
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/AcDream.App/UI/UiItemList.cs`
|
||||
- Test: `tests/AcDream.App.Tests/UI/UiItemListTests.cs`
|
||||
|
||||
- [ ] **Step 1: Write the failing unit test**
|
||||
|
||||
Add to `tests/AcDream.App.Tests/UI/UiItemListTests.cs` (inside the existing test class):
|
||||
|
||||
```csharp
|
||||
[Fact]
|
||||
public void CellEmptySprite_stamps_existing_and_future_cells()
|
||||
{
|
||||
var list = new UiItemList(); // ctor adds one default cell
|
||||
Assert.Equal(0x060074CFu, list.GetItem(0)!.EmptySprite); // UiItemSlot default before set
|
||||
|
||||
list.CellEmptySprite = 0x06004D20u; // a pack-slot sprite
|
||||
Assert.Equal(0x06004D20u, list.GetItem(0)!.EmptySprite); // existing cell re-stamped
|
||||
|
||||
list.AddItem(new UiItemSlot());
|
||||
Assert.Equal(0x06004D20u, list.GetItem(1)!.EmptySprite); // new cell stamped
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CellEmptySprite_zero_leaves_the_UiItemSlot_default()
|
||||
{
|
||||
var list = new UiItemList();
|
||||
list.CellEmptySprite = 0u;
|
||||
Assert.Equal(0x060074CFu, list.GetItem(0)!.EmptySprite); // unchanged default
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run the test to verify it fails**
|
||||
|
||||
Run: `dotnet test tests/AcDream.App.Tests/AcDream.App.Tests.csproj --filter "FullyQualifiedName~UiItemListTests.CellEmptySprite"`
|
||||
Expected: **compile error** — `UiItemList` has no `CellEmptySprite`.
|
||||
|
||||
- [ ] **Step 3: Implement `CellEmptySprite`**
|
||||
|
||||
In `src/AcDream.App/UI/UiItemList.cs`, add the property after the `SpriteResolve` property (around line 29):
|
||||
|
||||
```csharp
|
||||
private uint _cellEmptySprite;
|
||||
/// <summary>Empty-slot sprite for THIS list's cells, resolved from the dat cell template
|
||||
/// (retail attribute 0x1000000e -> catalog 0x21000037 prototype's ItemSlot_Empty; see
|
||||
/// <see cref="Layout.ItemListCellTemplate.ResolveEmptySprite"/>). 0 = leave the
|
||||
/// <see cref="UiItemSlot.EmptySprite"/> default (0x060074CF). Applied to every existing
|
||||
/// AND future cell.</summary>
|
||||
public uint CellEmptySprite
|
||||
{
|
||||
get => _cellEmptySprite;
|
||||
set
|
||||
{
|
||||
_cellEmptySprite = value;
|
||||
if (value != 0)
|
||||
foreach (var c in _cells) c.EmptySprite = value;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
In `AddItem`, after the existing `cell.SpriteResolve ??= SpriteResolve;` line, add:
|
||||
|
||||
```csharp
|
||||
if (_cellEmptySprite != 0) cell.EmptySprite = _cellEmptySprite;
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run the test to verify it passes**
|
||||
|
||||
Run: `dotnet test tests/AcDream.App.Tests/AcDream.App.Tests.csproj --filter "FullyQualifiedName~UiItemListTests.CellEmptySprite"`
|
||||
Expected: **PASS** (both tests).
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add src/AcDream.App/UI/UiItemList.cs tests/AcDream.App.Tests/UI/UiItemListTests.cs
|
||||
git commit -m "feat(ui): D.2b empty-slot art — UiItemList.CellEmptySprite stamps cells
|
||||
|
||||
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3: `InventoryController` applies the three sprites
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/AcDream.App/UI/Layout/InventoryController.cs`
|
||||
- Test: `tests/AcDream.App.Tests/UI/Layout/InventoryControllerTests.cs`
|
||||
|
||||
- [ ] **Step 1: Write the failing test**
|
||||
|
||||
Add to `tests/AcDream.App.Tests/UI/Layout/InventoryControllerTests.cs`:
|
||||
|
||||
```csharp
|
||||
[Fact]
|
||||
public void Empty_sprites_are_applied_to_the_three_lists()
|
||||
{
|
||||
var (layout, grid, containers, top, _, _, _, _) = BuildLayout();
|
||||
InventoryController.Bind(layout, new ClientObjectTable(), () => Player,
|
||||
iconIds: (_, _, _, _, _) => 0u, strength: () => 100, datFont: null,
|
||||
contentsEmptySprite: 0x06004D20u, sideBagEmptySprite: 0x06005D9Cu, mainPackEmptySprite: 0x06005D9Cu);
|
||||
|
||||
Assert.Equal(0x06004D20u, grid.GetItem(0)!.EmptySprite); // a padded empty contents cell
|
||||
Assert.Equal(0x06005D9Cu, containers.GetItem(0)!.EmptySprite); // a padded empty side-bag cell
|
||||
Assert.Equal(0x06005D9Cu, top.GetItem(0)!.EmptySprite); // the main-pack cell
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run the test to verify it fails**
|
||||
|
||||
Run: `dotnet test tests/AcDream.App.Tests/AcDream.App.Tests.csproj --filter "FullyQualifiedName~InventoryControllerTests.Empty_sprites"`
|
||||
Expected: **compile error** — `Bind` has no `contentsEmptySprite`/`sideBagEmptySprite`/`mainPackEmptySprite` parameters.
|
||||
|
||||
- [ ] **Step 3: Add the parameters and apply them**
|
||||
|
||||
In `src/AcDream.App/UI/Layout/InventoryController.cs`:
|
||||
|
||||
(a) Add three parameters to the **private constructor** signature (after `UiDatFont? datFont`):
|
||||
|
||||
```csharp
|
||||
UiDatFont? datFont,
|
||||
uint contentsEmptySprite,
|
||||
uint sideBagEmptySprite,
|
||||
uint mainPackEmptySprite)
|
||||
```
|
||||
|
||||
(b) Inside the constructor, after the existing `if (_containerList is not null) { ... }` block that sets `Columns`/`CellWidth`/`CellHeight` (and before the burden-meter block), add:
|
||||
|
||||
```csharp
|
||||
// Per-list empty-slot art, resolved from the dat cell template (retail attr 0x1000000e
|
||||
// -> catalog 0x21000037 prototype's ItemSlot_Empty). 0 = leave the UiItemSlot default.
|
||||
if (_contentsGrid is not null) _contentsGrid.CellEmptySprite = contentsEmptySprite;
|
||||
if (_containerList is not null) _containerList.CellEmptySprite = sideBagEmptySprite;
|
||||
if (_topContainer is not null) _topContainer.CellEmptySprite = mainPackEmptySprite;
|
||||
```
|
||||
|
||||
(c) Update the public **`Bind`** factory to take + forward the three values (defaulted to 0 so every existing caller and test still compiles):
|
||||
|
||||
```csharp
|
||||
public static InventoryController Bind(
|
||||
ImportedLayout layout,
|
||||
ClientObjectTable objects,
|
||||
Func<uint> playerGuid,
|
||||
Func<ItemType, uint, uint, uint, uint, uint> iconIds,
|
||||
Func<int?> strength,
|
||||
UiDatFont? datFont,
|
||||
uint contentsEmptySprite = 0u,
|
||||
uint sideBagEmptySprite = 0u,
|
||||
uint mainPackEmptySprite = 0u)
|
||||
=> new InventoryController(layout, objects, playerGuid, iconIds, strength, datFont,
|
||||
contentsEmptySprite, sideBagEmptySprite, mainPackEmptySprite);
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run the test to verify it passes**
|
||||
|
||||
Run: `dotnet test tests/AcDream.App.Tests/AcDream.App.Tests.csproj --filter "FullyQualifiedName~InventoryControllerTests"`
|
||||
Expected: **PASS** (the new test + all existing `InventoryControllerTests` — the defaults keep them green).
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add src/AcDream.App/UI/Layout/InventoryController.cs tests/AcDream.App.Tests/UI/Layout/InventoryControllerTests.cs
|
||||
git commit -m "feat(ui): D.2b empty-slot art — InventoryController applies per-list empty sprites
|
||||
|
||||
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 4: GameWindow wiring + divergence rows
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/AcDream.App/Rendering/GameWindow.cs` (around the `InventoryController.Bind` call, `:2217`)
|
||||
- Modify: `docs/architecture/retail-divergence-register.md`
|
||||
|
||||
- [ ] **Step 1: Resolve the three sprites and pass them into `Bind`**
|
||||
|
||||
In `src/AcDream.App/Rendering/GameWindow.cs`, immediately **before** the `_inventoryController = AcDream.App.UI.Layout.InventoryController.Bind(` call (currently at ~line 2217), insert:
|
||||
|
||||
```csharp
|
||||
// Resolve each inventory list's empty-slot art from the dat cell template
|
||||
// (retail UIElement_ItemList::InternalCreateItem 0x004e3570: attr 0x1000000e ->
|
||||
// catalog 0x21000037 prototype's ItemSlot_Empty). The contents grid lives in
|
||||
// gm3DItemsUI (0x21000021); the side-bag + main-pack in gmBackpackUI (0x21000022).
|
||||
uint contentsEmpty, sideBagEmpty, mainPackEmpty;
|
||||
lock (_datLock)
|
||||
{
|
||||
contentsEmpty = AcDream.App.UI.Layout.ItemListCellTemplate.ResolveEmptySprite(_dats!, 0x21000021u, 0x100001C6u);
|
||||
sideBagEmpty = AcDream.App.UI.Layout.ItemListCellTemplate.ResolveEmptySprite(_dats!, 0x21000022u, 0x100001CAu);
|
||||
mainPackEmpty = AcDream.App.UI.Layout.ItemListCellTemplate.ResolveEmptySprite(_dats!, 0x21000022u, 0x100001C9u);
|
||||
}
|
||||
Console.WriteLine($"[D.2b empty-slot] contents=0x{contentsEmpty:X8} sideBag=0x{sideBagEmpty:X8} mainPack=0x{mainPackEmpty:X8}");
|
||||
```
|
||||
|
||||
Then extend the existing `Bind(...)` call's argument list — after `datFont: vitalsDatFont` (the current last argument, line ~2224) change the trailing `);` so the call ends:
|
||||
|
||||
```csharp
|
||||
datFont: vitalsDatFont,
|
||||
contentsEmptySprite: contentsEmpty,
|
||||
sideBagEmptySprite: sideBagEmpty,
|
||||
mainPackEmptySprite: mainPackEmpty);
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Add the two divergence-register rows**
|
||||
|
||||
In `docs/architecture/retail-divergence-register.md`, in the AP (Approximation/Adaptation) table (after the `AP-54` row, line ~156), add two rows (renumber if `AP-55`/`AP-56` are taken — use the next free ids):
|
||||
|
||||
```markdown
|
||||
| AP-55 | The toolbar's item slots keep the hardcoded `UiItemSlot.EmptySprite = 0x060074CF` rather than resolving their cell template via attribute `0x1000000e`. Correct in outcome (the toolbar's `0x1000000e` resolves to a generic prototype whose empty media is `0x060074CF`) but not dat-resolved. | `src/AcDream.App/UI/UiItemSlot.cs` (`EmptySprite` default); `src/AcDream.App/UI/Layout/ToolbarController.cs` | The inventory lists now port the retail resolver (`ItemListCellTemplate`); the toolbar was left on its hardcoded default to avoid regressing frozen, working art. Retire when the toolbar is routed through `ItemListCellTemplate`. | If a future toolbar layout's `0x1000000e` points at a non-generic prototype, the toolbar would show stale art. | `UIElement_ItemList::InternalCreateItem` 0x004e3570; catalog `0x21000037` |
|
||||
| AP-56 | `UiItemSlot` is a flat single-sprite cell; for a prototype with both a frame child and an inner icon (the 36×36 backpack prototype `0x1000033F`: frame `0x06005D9C` + inner `0x06000F6E`), only the dominant background (the frame) is drawn. Retail clones the full prototype subtree (frame + inner layered). | `src/AcDream.App/UI/UiItemSlot.cs`; `src/AcDream.App/UI/Layout/ItemListCellTemplate.cs` (`ResolveEmptySprite` single-sprite contract) | acdream's cell draws one empty sprite; replicating retail's multi-layer prototype needs a layered cell or a subtree clone. Frame-first chosen as the dominant visible art. Revisit at the visual gate / build a layered cell if the backpack slots read wrong. | The 36×36 backpack cells may lack the inner placeholder layer vs retail. | `UIElement_ItemList::InternalCreateItem` 0x004e3570; prototype `0x1000033F` |
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Build and run the full inventory test slice**
|
||||
|
||||
Run: `dotnet build src/AcDream.App/AcDream.App.csproj -c Debug`
|
||||
Expected: **build succeeds**.
|
||||
|
||||
Run: `dotnet test tests/AcDream.App.Tests/AcDream.App.Tests.csproj --filter "FullyQualifiedName~InventoryControllerTests|FullyQualifiedName~ItemListCellTemplateTests|FullyQualifiedName~UiItemListTests"`
|
||||
Expected: **PASS**.
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add src/AcDream.App/Rendering/GameWindow.cs docs/architecture/retail-divergence-register.md
|
||||
git commit -m "feat(ui): D.2b empty-slot art — GameWindow resolves + wires per-list empty sprites
|
||||
|
||||
Inventory contents grid / side-bag / main-pack cells now render the retail
|
||||
pack-slot empty art (dat cell template via ItemListCellTemplate) instead of the
|
||||
generic toolbar square. Divergence rows AP-55 (toolbar still hardcoded) + AP-56
|
||||
(flat single-sprite cell vs retail's layered prototype).
|
||||
|
||||
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 5: Full verification, ISSUES, and the visual gate
|
||||
|
||||
**Files:**
|
||||
- Modify: `docs/ISSUES.md`
|
||||
|
||||
- [ ] **Step 1: Run the FULL test suite (all four projects)**
|
||||
|
||||
Run: `dotnet test`
|
||||
Expected: **0 failures** across Core.Net / App / UI / Core. (Run the full suite — a filtered batch hid a cross-file regression during B-Wire.)
|
||||
|
||||
- [ ] **Step 2: Move the ISSUES entry to *Recently closed* (inventory portion)**
|
||||
|
||||
In `docs/ISSUES.md`, find the OPEN issue *"Inventory + equipment slots show the wrong empty-slot background art"*. Edit it so the inventory portion is closed and only the paperdoll equip-slot silhouettes remain (Sub-phase C):
|
||||
|
||||
- Change its status line to: `**Status:** PARTIALLY CLOSED — inventory contents grid + side-bag + main-pack empty art now resolved from the dat cell template (`ItemListCellTemplate`), commit <Task-4 SHA>. REMAINING: paperdoll equip-slot silhouettes (Sub-phase C — needs the 0x10000032 UiItemSlot registration + UiViewport).`
|
||||
- If the repo convention is to fully close + open a follow-up, instead move the whole entry to *Recently closed* with the SHA and add a one-line OPEN issue *"Paperdoll equip slots show a generic blue border, not per-slot silhouettes (Sub-phase C)"*. Match whichever pattern the surrounding ISSUES entries use.
|
||||
|
||||
- [ ] **Step 3: Commit the ISSUES update**
|
||||
|
||||
```bash
|
||||
git add docs/ISSUES.md
|
||||
git commit -m "docs: D.2b empty-slot art — close inventory portion of the empty-slot-art issue
|
||||
|
||||
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>"
|
||||
```
|
||||
|
||||
- [ ] **Step 4: STOP — visual gate (user)**
|
||||
|
||||
This is the acceptance test and requires the user's eyes. Launch the client (plain `dotnet run --no-build`, **do not auto-kill** a running client — ask the user to close it if a rebuild is locked), open the inventory (F12, `ACDREAM_RETAIL_UI=1`), and have the user confirm:
|
||||
- The contents-grid empty cells show the retail pack-slot background (not the generic toolbar square).
|
||||
- The side-bag column empty cells show the correct backpack-slot art.
|
||||
|
||||
If the 36×36 backpack cells read wrong (frame-only vs retail's frame+inner), that is AP-56 — escalate to a layered `UiItemSlot` empty (out of this plan's scope) or flip the `ResolveEmptySprite` priority to icon-first; confirm with the user before changing.
|
||||
|
||||
---
|
||||
|
||||
## Self-Review
|
||||
|
||||
**Spec coverage:**
|
||||
- §2 retail mechanism → Task 1 resolver (ports `InternalCreateItem`'s `0x1000000e` → catalog → `ItemSlot_Empty`). ✓
|
||||
- §4.1 components (helper, `CellEmptySprite`, controller, GameWindow) → Tasks 1–4. ✓
|
||||
- §4.2 `CellEmptySprite` (stamps existing + future cells) → Task 2. ✓
|
||||
- §4.3 resolver contract (frame-first → icon-empty) → Task 1 `ResolveEmptySprite`. ✓
|
||||
- §4.4 data flow (per-list resolution from `0x21000021`/`0x21000022` → `Bind` → cells) → Tasks 3–4. ✓
|
||||
- §5 divergence rows (toolbar hardcode + flat-cell) → Task 4 Step 2. ✓
|
||||
- §6 step-1 validation (pin `0x1000000e` values) → Task 1 Step 4 (the test prints them). ✓
|
||||
- §7 testing (resolver smoke non-zero/≠generic/0x06; `CellEmptySprite` unit; controller applies; `DefaultEmptySprite_isToolbarBorder` untouched) → Tasks 1–3 + Task 5 full suite. ✓
|
||||
- §8 acceptance (build+test green; rows + ISSUES; visual gate) → Tasks 4–5. ✓
|
||||
|
||||
**Placeholder scan:** No "TBD"/"TODO"/"implement later". The recorded `0x1000000e` values (Task 1 Step 4) are an output to capture at run time, not a code placeholder — the resolver is value-agnostic; the test asserts structural properties (non-zero, ≠ generic, 0x06-prefixed), so no magic literal is required for the suite to pass. The `0x06004D20`/`0x06005D9C` literals in the Task 2/3 *unit* tests are illustrative pack-slot ids (any non-zero values exercise the stamping logic); they are not asserted against the dat.
|
||||
|
||||
**Type consistency:** `ResolveEmptySprite(DatCollection, uint, uint) → uint` is used identically in Task 1 (test), Task 4 (GameWindow). `UiItemList.CellEmptySprite` (uint) set in Task 3, defined in Task 2. `InventoryController.Bind(..., uint contentsEmptySprite=0, uint sideBagEmptySprite=0, uint mainPackEmptySprite=0)` defined in Task 3, called with named args in Task 4. `UiItemSlot.EmptySprite` (existing, uint) read in Tasks 2–3 tests. Consistent throughout.
|
||||
577
docs/superpowers/plans/2026-06-22-d2b-inventory-drag-drop.md
Normal file
577
docs/superpowers/plans/2026-06-22-d2b-inventory-drag-drop.md
Normal file
|
|
@ -0,0 +1,577 @@
|
|||
# D.2b inventory drag-drop (item moving) — 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:** Drag an inventory item and drop it to move it instantly — empty grid slot → first empty; on an item → insert before; on a side-bag cell → into that container — with a green insert-arrow (valid) / red circle (full) overlay and a server-rollback if the move bounces.
|
||||
|
||||
**Architecture:** `InventoryController` implements `IItemListDragHandler` (like `ToolbarController`) on the contents grid + side-bag list + main-pack cell. Drop does an **optimistic** local `MoveItem` (instant repaint) + `PutItemInContainer 0x0019` wire; the server's `0x0022` echo confirms and `0x00A0` rolls back. Pending-move tracking lives in `ClientObjectTable` so both layers reach it.
|
||||
|
||||
**Tech Stack:** C# .NET 10, xUnit. Layers: `AcDream.Core` (table + pending moves), `AcDream.Core.Net` (wire + wiring), `AcDream.App` (the drag handler).
|
||||
|
||||
**Spec:** `docs/superpowers/specs/2026-06-22-d2b-inventory-drag-drop-design.md`. Sprites (export-confirmed): green insert-arrow `0x060011F7`, red circle `0x060011F8`. Retail: `InqDropIconInfo 0x004e26f0`, `ItemList_InsertItem`, `HandleDropRelease`, `ServerSaysMoveItem`; wire `PutItemInContainer 0x0019` (`item, container, placement`).
|
||||
|
||||
**Build/test:** from repo root. Per-project e.g. `dotnet test tests/AcDream.Core.Tests/AcDream.Core.Tests.csproj`. Full (phase boundary): `dotnet test` (use `--no-build` if a client is running, to avoid the App.dll lock).
|
||||
|
||||
---
|
||||
|
||||
## Task 1: `ClientObjectTable` optimistic move + rollback
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/AcDream.Core/Items/ClientObjectTable.cs` (add a pending-move map field near the other fields ~line 45, and three methods after `MoveItem` ~line 131)
|
||||
- Test: `tests/AcDream.Core.Tests/Items/ClientObjectTableTests.cs`
|
||||
|
||||
- [ ] **Step 1: Write the failing tests**
|
||||
|
||||
```csharp
|
||||
[Fact]
|
||||
public void MoveItemOptimistic_movesAndRemembersPreMove()
|
||||
{
|
||||
var table = new ClientObjectTable();
|
||||
table.Ingest(FullWeenie(0x700u, container: 0xC0u)); table.MoveItem(0x700u, 0xC0u, newSlot: 3);
|
||||
table.MoveItemOptimistic(0x700u, 0xC1u, 0);
|
||||
Assert.Equal(0xC1u, table.Get(0x700u)!.ContainerId); // moved now (instant)
|
||||
Assert.True(table.RollbackMove(0x700u)); // pre-move was remembered
|
||||
Assert.Equal(0xC0u, table.Get(0x700u)!.ContainerId); // snapped back to the original container
|
||||
Assert.Equal(3, table.Get(0x700u)!.ContainerSlot); // and slot
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ConfirmMove_clearsPending_soRollbackIsNoOp()
|
||||
{
|
||||
var table = new ClientObjectTable();
|
||||
table.Ingest(FullWeenie(0x701u, container: 0xC0u));
|
||||
table.MoveItemOptimistic(0x701u, 0xC1u, 0);
|
||||
table.ConfirmMove(0x701u);
|
||||
Assert.False(table.RollbackMove(0x701u)); // nothing pending → no rollback
|
||||
Assert.Equal(0xC1u, table.Get(0x701u)!.ContainerId); // stays at the confirmed container
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MoveItemOptimistic_twice_keepsTheOriginalPreMove()
|
||||
{
|
||||
var table = new ClientObjectTable();
|
||||
table.Ingest(FullWeenie(0x702u, container: 0xC0u));
|
||||
table.MoveItemOptimistic(0x702u, 0xC1u, 0);
|
||||
table.MoveItemOptimistic(0x702u, 0xC2u, 0); // a second move before any confirm
|
||||
table.RollbackMove(0x702u);
|
||||
Assert.Equal(0xC0u, table.Get(0x702u)!.ContainerId); // rolls back to the FIRST pre-move
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RollbackMove_unknownItem_false()
|
||||
=> Assert.False(new ClientObjectTable().RollbackMove(0xDEADu));
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run to verify they fail**
|
||||
|
||||
Run: `dotnet test tests/AcDream.Core.Tests/AcDream.Core.Tests.csproj --filter "FullyQualifiedName~Optimistic|FullyQualifiedName~ConfirmMove|FullyQualifiedName~RollbackMove"`
|
||||
Expected: FAIL — `'ClientObjectTable' does not contain a definition for 'MoveItemOptimistic'`.
|
||||
|
||||
- [ ] **Step 3: Add the field + methods**
|
||||
|
||||
Add the field near the other private fields (after `_containerIndex`, ~line 45):
|
||||
```csharp
|
||||
// B-Drag: pre-move snapshots for optimistic inventory moves. itemId → (container, slot) BEFORE
|
||||
// the optimistic MoveItem; restored by RollbackMove on InventoryServerSaveFailed (0x00A0),
|
||||
// cleared by ConfirmMove on the InventoryPutObjInContainer (0x0022) echo.
|
||||
private readonly Dictionary<uint, (uint container, int slot)> _pendingMoves = new();
|
||||
```
|
||||
|
||||
Add the methods after `MoveItem` (~line 131):
|
||||
```csharp
|
||||
/// <summary>
|
||||
/// Optimistic (instant) move: snapshot the item's current (ContainerId, ContainerSlot) the FIRST
|
||||
/// time it moves, then <see cref="MoveItem"/> (immediate repaint via ObjectMoved). The wire
|
||||
/// PutItemInContainer is sent by the caller; <see cref="ConfirmMove"/>/<see cref="RollbackMove"/>
|
||||
/// reconcile against the server. Retail: local ItemList_InsertItem + ServerSaysMoveItem reconcile.
|
||||
/// </summary>
|
||||
public bool MoveItemOptimistic(uint itemId, uint newContainerId, int newSlot)
|
||||
{
|
||||
if (!_objects.TryGetValue(itemId, out var item)) return false;
|
||||
if (!_pendingMoves.ContainsKey(itemId))
|
||||
_pendingMoves[itemId] = (item.ContainerId, item.ContainerSlot); // snapshot the ORIGINAL once
|
||||
return MoveItem(itemId, newContainerId, newSlot);
|
||||
}
|
||||
|
||||
/// <summary>The server confirmed the move (InventoryPutObjInContainer 0x0022 echo) — drop the
|
||||
/// pending snapshot. No-op for a server-initiated move we never tracked.</summary>
|
||||
public void ConfirmMove(uint itemId) => _pendingMoves.Remove(itemId);
|
||||
|
||||
/// <summary>The server rejected the move (InventoryServerSaveFailed 0x00A0) — restore the item to
|
||||
/// its pre-move (container, slot) and drop the snapshot. False if nothing was pending.</summary>
|
||||
public bool RollbackMove(uint itemId)
|
||||
{
|
||||
if (!_pendingMoves.TryGetValue(itemId, out var pre)) return false;
|
||||
_pendingMoves.Remove(itemId);
|
||||
return MoveItem(itemId, pre.container, pre.slot);
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run to verify they pass**
|
||||
|
||||
Run: `dotnet test tests/AcDream.Core.Tests/AcDream.Core.Tests.csproj --filter "FullyQualifiedName~Optimistic|FullyQualifiedName~ConfirmMove|FullyQualifiedName~RollbackMove"`
|
||||
Expected: PASS (4 tests).
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add src/AcDream.Core/Items/ClientObjectTable.cs tests/AcDream.Core.Tests/Items/ClientObjectTableTests.cs
|
||||
git commit -m "feat(D.2b): ClientObjectTable optimistic move + confirm/rollback for inventory drag
|
||||
|
||||
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 2: `WorldSession.SendPutItemInContainer`
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/AcDream.Core.Net/WorldSession.cs` (add after `SendUse`, ~line 1226)
|
||||
|
||||
**Test note:** no new unit test — the wire is locked by `tests/AcDream.Core.Net.Tests/Messages/InteractRequestsTests.cs` (`BuildPickUp` opcode `0x0019` + fields); the wrapper mirrors the test-free `SendUse`/`SendNoLongerViewingContents`. Round-trip confirmed at the WireMCP gate (Task 7).
|
||||
|
||||
- [ ] **Step 1: Add the wrapper**
|
||||
|
||||
After `SendUse` (~line 1226) add:
|
||||
```csharp
|
||||
/// <summary>Send PutItemInContainer (0x0019) — move an item into a container at a slot. placement
|
||||
/// = the target slot (server packs/shifts); the drag-drop drop handler computes it. Retail:
|
||||
/// CM_Inventory::Event_PutItemInContainer → ACE Player.HandleActionPutItemInContainer.</summary>
|
||||
public void SendPutItemInContainer(uint itemGuid, uint containerGuid, int placement)
|
||||
{
|
||||
uint seq = NextGameActionSequence();
|
||||
SendGameAction(InteractRequests.BuildPickUp(seq, itemGuid, containerGuid, placement));
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Verify it builds**
|
||||
|
||||
Run: `dotnet build src/AcDream.Core.Net/AcDream.Core.Net.csproj`
|
||||
Expected: Build succeeded.
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add src/AcDream.Core.Net/WorldSession.cs
|
||||
git commit -m "feat(D.2b): WorldSession.SendPutItemInContainer (0x0019) for inventory moves
|
||||
|
||||
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 3: wire the confirm/rollback handlers
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/AcDream.Core.Net/GameEventWiring.cs` (the `InventoryPutObjInContainer` handler ~line 246-251 and the `InventoryServerSaveFailed` handler ~line 280-285)
|
||||
- Test: `tests/AcDream.Core.Net.Tests/GameEventWiringTests.cs`
|
||||
|
||||
- [ ] **Step 1: Write the failing test**
|
||||
|
||||
```csharp
|
||||
[Fact]
|
||||
public void WireAll_InventoryServerSaveFailed_RollsBackOptimisticMove()
|
||||
{
|
||||
var (d, items, _, _, _) = MakeAll();
|
||||
// An item known to be in container 0xC0 at slot 2, then optimistically moved to 0xC1.
|
||||
items.Ingest(FullWeenieAt(0x50000B01u, 0x500000C0u, slot: 2));
|
||||
items.MoveItemOptimistic(0x50000B01u, 0x500000C1u, 0);
|
||||
Assert.Equal(0x500000C1u, items.Get(0x50000B01u)!.ContainerId); // moved (optimistic)
|
||||
|
||||
// Server bounces it: InventoryServerSaveFailed (itemGuid, weenieError).
|
||||
byte[] payload = new byte[8];
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(payload.AsSpan(0), 0x50000B01u);
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(payload.AsSpan(4), 0x1Au); // some WeenieError
|
||||
var env = GameEventEnvelope.TryParse(WrapEnvelope(GameEventType.InventoryServerSaveFailed, payload));
|
||||
d.Dispatch(env!.Value);
|
||||
|
||||
Assert.Equal(0x500000C0u, items.Get(0x50000B01u)!.ContainerId); // snapped back
|
||||
Assert.Equal(2, items.Get(0x50000B01u)!.ContainerSlot);
|
||||
}
|
||||
```
|
||||
|
||||
Add this helper to the test class if `FullWeenieAt` isn't present (place near the other helpers):
|
||||
```csharp
|
||||
private static WeenieData FullWeenieAt(uint guid, uint container, int slot)
|
||||
{
|
||||
var d = new WeenieData { Guid = guid, ContainerId = container };
|
||||
// Ingest sets ContainerId from the wire; the slot is set by the follow-up MoveItem.
|
||||
return d;
|
||||
}
|
||||
```
|
||||
(If the test file already constructs `WeenieData` inline elsewhere, match that style instead and set `ContainerId`. After `Ingest`, call `items.MoveItem(guid, container, slot)` to pin the slot — adjust the test's first two lines to: `items.Ingest(new WeenieData{Guid=0x50000B01u, ContainerId=0x500000C0u}); items.MoveItem(0x50000B01u, 0x500000C0u, 2);`.)
|
||||
|
||||
- [ ] **Step 2: Run to verify it fails**
|
||||
|
||||
Run: `dotnet test tests/AcDream.Core.Net.Tests/AcDream.Core.Net.Tests.csproj --filter "FullyQualifiedName~RollsBackOptimisticMove"`
|
||||
Expected: FAIL — the item stays at `0x500000C1` (the handler only logs today; no rollback).
|
||||
|
||||
- [ ] **Step 3: Wire confirm + rollback**
|
||||
|
||||
In `GameEventWiring.cs`, the `InventoryPutObjInContainer` handler — add `ConfirmMove` after the existing `MoveItem`:
|
||||
```csharp
|
||||
dispatcher.Register(GameEventType.InventoryPutObjInContainer, e =>
|
||||
{
|
||||
var p = GameEvents.ParsePutObjInContainer(e.Payload.Span);
|
||||
if (p is null) return;
|
||||
items.MoveItem(p.Value.ItemGuid, p.Value.ContainerGuid, newSlot: (int)p.Value.Placement);
|
||||
items.ConfirmMove(p.Value.ItemGuid); // B-Drag: the server confirmed our optimistic move
|
||||
});
|
||||
```
|
||||
|
||||
Replace the `InventoryServerSaveFailed` handler body (the `Console.WriteLine` log) with the rollback:
|
||||
```csharp
|
||||
dispatcher.Register(GameEventType.InventoryServerSaveFailed, e =>
|
||||
{
|
||||
var p = GameEvents.ParseInventoryServerSaveFailed(e.Payload.Span);
|
||||
if (p is null) return;
|
||||
// B-Drag: the server rejected an optimistic move — snap the item back to its pre-move slot.
|
||||
if (!items.RollbackMove(p.Value.ItemGuid))
|
||||
Console.WriteLine($"[B-Drag] InventoryServerSaveFailed guid=0x{p.Value.ItemGuid:X8} err=0x{p.Value.WeenieError:X} (no pending move)");
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run to verify it passes**
|
||||
|
||||
Run: `dotnet test tests/AcDream.Core.Net.Tests/AcDream.Core.Net.Tests.csproj --filter "FullyQualifiedName~RollsBackOptimisticMove"`
|
||||
Expected: PASS. Also run the existing inventory wiring tests to confirm no regression: `--filter "FullyQualifiedName~Inventory"`.
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add src/AcDream.Core.Net/GameEventWiring.cs tests/AcDream.Core.Net.Tests/GameEventWiringTests.cs
|
||||
git commit -m "feat(D.2b): wire ConfirmMove (0x0022 echo) + RollbackMove (0x00A0) for optimistic inventory moves
|
||||
|
||||
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 4: `InventoryController` drag-drop handler
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/AcDream.App/UI/Layout/InventoryController.cs` (implement `IItemListDragHandler`; register on the 3 lists; set drag sprites + `SlotIndex` on cells; add `sendPutItemInContainer` ctor/Bind param)
|
||||
- Test: `tests/AcDream.App.Tests/UI/Layout/InventoryControllerTests.cs`
|
||||
|
||||
- [ ] **Step 1: Extend the test harness + write the failing tests**
|
||||
|
||||
In `InventoryControllerTests.cs`, replace the `Bind` helper with one that captures the new callback:
|
||||
```csharp
|
||||
private static InventoryController Bind(ImportedLayout layout, ClientObjectTable objects,
|
||||
int? strength = 100, List<uint>? uses = null, List<uint>? closes = null,
|
||||
List<(uint item, uint container, int placement)>? puts = null)
|
||||
=> InventoryController.Bind(layout, objects, () => Player,
|
||||
iconIds: (_, _, _, _, _) => 0u,
|
||||
strength: () => strength, datFont: null,
|
||||
sendUse: uses is null ? null : g => uses.Add(g),
|
||||
sendNoLongerViewing: closes is null ? null : g => closes.Add(g),
|
||||
sendPutItemInContainer: puts is null ? null : (i, c, p) => puts.Add((i, c, p)));
|
||||
```
|
||||
|
||||
Add these tests at the end of the class (before the `CaptionText` helper):
|
||||
```csharp
|
||||
private static ItemDragPayload Payload(uint obj) => new(obj, ItemDragSource.Inventory, 0, new UiItemSlot());
|
||||
|
||||
[Fact]
|
||||
public void Drop_onOccupiedGridCell_insertsBefore_andMovesLocally()
|
||||
{
|
||||
var (layout, grid, _, _, _, _, _, _) = BuildLayout();
|
||||
var objects = new ClientObjectTable();
|
||||
SeedContained(objects, 0xA, Player, slot: 0);
|
||||
SeedContained(objects, 0xB, Player, slot: 1);
|
||||
var puts = new List<(uint, uint, int)>();
|
||||
var ctrl = Bind(layout, objects, puts: puts);
|
||||
|
||||
// Drag a fresh item 0xZ from elsewhere; drop on the grid cell holding 0xB (slot 1).
|
||||
objects.AddOrUpdate(new ClientObject { ObjectId = 0xZ });
|
||||
var bCell = grid.GetItem(1)!; // ItemId == 0xB, SlotIndex 1
|
||||
((IItemListDragHandler)ctrl).HandleDropRelease(grid, bCell, Payload(0xZ));
|
||||
|
||||
Assert.Contains((0xZu, Player, 1), puts); // insert-before slot 1, into the open container
|
||||
Assert.Equal(Player, objects.Get(0xZ)!.ContainerId); // moved locally (instant)
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Drop_onEmptyGridCell_appendsToFirstEmpty()
|
||||
{
|
||||
var (layout, grid, _, _, _, _, _, _) = BuildLayout();
|
||||
var objects = new ClientObjectTable();
|
||||
SeedContained(objects, 0xA, Player, slot: 0); // 1 loose item → first empty = slot 1
|
||||
var puts = new List<(uint, uint, int)>();
|
||||
var ctrl = Bind(layout, objects, puts: puts);
|
||||
|
||||
objects.AddOrUpdate(new ClientObject { ObjectId = 0xZ });
|
||||
var emptyCell = grid.GetItem(5)!; // an empty padded cell (ItemId 0)
|
||||
((IItemListDragHandler)ctrl).HandleDropRelease(grid, emptyCell, Payload(0xZ));
|
||||
|
||||
Assert.Contains((0xZu, Player, 1), puts); // placement = occupied count (1) = first empty
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Drop_onSideBagCell_movesIntoThatBag()
|
||||
{
|
||||
var (layout, _, containers, _, _, _, _, _) = BuildLayout();
|
||||
var objects = new ClientObjectTable();
|
||||
SeedBag(objects, 0xC, slot: 0, itemsCapacity: 24);
|
||||
var puts = new List<(uint, uint, int)>();
|
||||
var ctrl = Bind(layout, objects, puts: puts);
|
||||
|
||||
objects.AddOrUpdate(new ClientObject { ObjectId = 0xZ });
|
||||
var bagCell = containers.GetItem(0)!; // ItemId == 0xC (the bag)
|
||||
((IItemListDragHandler)ctrl).HandleDropRelease(containers, bagCell, Payload(0xZ));
|
||||
|
||||
Assert.Contains((0xZu, 0xCu, 0), puts); // into the bag, append (placement 0)
|
||||
Assert.Equal(0xCu, objects.Get(0xZ)!.ContainerId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void OnDragOver_fullSideBag_rejects_butGridAccepts()
|
||||
{
|
||||
var (layout, grid, containers, _, _, _, _, _) = BuildLayout();
|
||||
var objects = new ClientObjectTable();
|
||||
SeedBag(objects, 0xC, slot: 0, itemsCapacity: 1); // capacity 1...
|
||||
SeedContained(objects, 0xB0, 0xC, slot: 0); // ...and already full
|
||||
objects.AddOrUpdate(new ClientObject { ObjectId = 0xZ });
|
||||
var ctrl = (IItemListDragHandler)Bind(layout, objects);
|
||||
|
||||
Assert.False(ctrl.OnDragOver(containers, containers.GetItem(0)!, Payload(0xZ))); // full bag → red
|
||||
Assert.True(ctrl.OnDragOver(grid, grid.GetItem(0)!, Payload(0xZ))); // grid → green
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void OnDragLift_isNoOp_itemStaysUntilServerConfirms()
|
||||
{
|
||||
var (layout, grid, _, _, _, _, _, _) = BuildLayout();
|
||||
var objects = new ClientObjectTable();
|
||||
SeedContained(objects, 0xA, Player, slot: 0);
|
||||
var ctrl = (IItemListDragHandler)Bind(layout, objects);
|
||||
((IItemListDragHandler)ctrl).OnDragLift(grid, grid.GetItem(0)!, Payload(0xA));
|
||||
Assert.Equal(Player, objects.Get(0xA)!.ContainerId); // NOT removed on lift (unlike the toolbar)
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run to verify they fail**
|
||||
|
||||
Run: `dotnet test tests/AcDream.App.Tests/AcDream.App.Tests.csproj --filter "FullyQualifiedName~InventoryControllerTests"`
|
||||
Expected: FAIL — `Bind` has no `sendPutItemInContainer`; `InventoryController` doesn't implement `IItemListDragHandler` (compile errors).
|
||||
|
||||
- [ ] **Step 3: Make `InventoryController` the drag handler**
|
||||
|
||||
(a) Class declaration — add the interface:
|
||||
```csharp
|
||||
public sealed class InventoryController : IItemListDragHandler
|
||||
```
|
||||
|
||||
(b) Add the field + ctor/Bind param. Add field near `_sendNoLongerViewing`:
|
||||
```csharp
|
||||
private readonly Action<uint, uint, int>? _sendPutItemInContainer; // (item, container, placement)
|
||||
```
|
||||
Append to the private ctor params (after `sendNoLongerViewing`):
|
||||
```csharp
|
||||
Action<uint>? sendNoLongerViewing,
|
||||
Action<uint, uint, int>? sendPutItemInContainer)
|
||||
```
|
||||
Assign in the ctor body (after `_sendNoLongerViewing = ...`):
|
||||
```csharp
|
||||
_sendPutItemInContainer = sendPutItemInContainer;
|
||||
```
|
||||
Register the handler on the three lists in the ctor (after the lists are resolved, near the other `_contentsGrid` setup):
|
||||
```csharp
|
||||
_contentsGrid?.RegisterDragHandler(this);
|
||||
_containerList?.RegisterDragHandler(this);
|
||||
_topContainer?.RegisterDragHandler(this);
|
||||
```
|
||||
Append to the public `Bind` params (after `sendNoLongerViewing = null`) and thread it through:
|
||||
```csharp
|
||||
Action<uint>? sendNoLongerViewing = null,
|
||||
Action<uint, uint, int>? sendPutItemInContainer = null)
|
||||
=> new InventoryController(layout, objects, playerGuid, iconIds, strength, datFont,
|
||||
contentsEmptySprite, sideBagEmptySprite, mainPackEmptySprite,
|
||||
sendUse, sendNoLongerViewing, sendPutItemInContainer);
|
||||
```
|
||||
|
||||
(c) Set drag sprites + `SlotIndex` on cells. In `AddCell`, after `cell.SetItem(...)`:
|
||||
```csharp
|
||||
cell.SlotIndex = list.GetNumUIItems(); // index it will occupy (== its slot in a packed list)
|
||||
cell.DragAcceptSprite = 0x060011F7u; // green insert arrow (export-confirmed)
|
||||
cell.DragRejectSprite = 0x060011F8u; // red circle
|
||||
```
|
||||
In `AddEmptyCell`, change it to set the same so empty grid cells are valid drop targets:
|
||||
```csharp
|
||||
private static void AddEmptyCell(UiItemList list)
|
||||
{
|
||||
var cell = new UiItemSlot { SpriteResolve = list.SpriteResolve, SlotIndex = list.GetNumUIItems() };
|
||||
cell.DragAcceptSprite = 0x060011F7u;
|
||||
cell.DragRejectSprite = 0x060011F8u;
|
||||
list.AddItem(cell);
|
||||
}
|
||||
```
|
||||
In the main-pack cell block (`Populate`), after `main.SetItem(...)`:
|
||||
```csharp
|
||||
main.DragAcceptSprite = 0x060011F7u; main.DragRejectSprite = 0x060011F8u;
|
||||
```
|
||||
|
||||
(d) Add the handler methods (place near the other private helpers, e.g. after `SetCapacityBar`):
|
||||
```csharp
|
||||
// ── IItemListDragHandler (B-Drag) — drop an item to move it (optimistic + wire) ──────────────
|
||||
/// <summary>Inventory items do NOT lift-remove (unlike the toolbar): the item stays in its slot
|
||||
/// until the server confirms the move. Retail dims the source; we leave it + the floating ghost.</summary>
|
||||
public void OnDragLift(UiItemList sourceList, UiItemSlot sourceCell, ItemDragPayload payload) { }
|
||||
|
||||
/// <summary>Advisory accept/reject overlay (green insert-arrow / red circle). Grid drops are always
|
||||
/// valid (reorder/insert); a side-bag/main-pack drop is valid unless that container is KNOWN-full.</summary>
|
||||
public bool OnDragOver(UiItemList targetList, UiItemSlot targetCell, ItemDragPayload payload)
|
||||
{
|
||||
if (payload.ObjId == 0) return false;
|
||||
if (targetList == _contentsGrid) return true;
|
||||
if (targetList == _containerList || targetList == _topContainer)
|
||||
{
|
||||
if (targetCell.ItemId == 0 || targetCell.ItemId == payload.ObjId) return false;
|
||||
return !IsContainerFull(targetCell.ItemId);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/// <summary>Perform the move: resolve (container, placement) from the target, optimistically move
|
||||
/// locally (instant), and send PutItemInContainer. Retail: HandleDropRelease + ItemList_InsertItem.</summary>
|
||||
public void HandleDropRelease(UiItemList targetList, UiItemSlot targetCell, ItemDragPayload payload)
|
||||
{
|
||||
uint item = payload.ObjId;
|
||||
if (item == 0) return;
|
||||
|
||||
uint container; int placement;
|
||||
if (targetList == _contentsGrid)
|
||||
{
|
||||
container = EffectiveOpen();
|
||||
placement = targetCell.ItemId != 0
|
||||
? (_objects.Get(targetCell.ItemId)?.ContainerSlot ?? targetCell.SlotIndex) // insert-before
|
||||
: _objects.GetContents(container).Count; // first empty = append
|
||||
}
|
||||
else if (targetList == _containerList || targetList == _topContainer)
|
||||
{
|
||||
if (targetCell.ItemId == 0 || targetCell.ItemId == item) return;
|
||||
container = targetCell.ItemId; // the bag / main pack
|
||||
if (IsContainerFull(container)) return; // red already shown
|
||||
placement = _objects.GetContents(container).Count; // append into it
|
||||
}
|
||||
else return;
|
||||
|
||||
if (container == item) return; // never into itself
|
||||
_objects.MoveItemOptimistic(item, container, placement); // instant local move
|
||||
_sendPutItemInContainer?.Invoke(item, container, placement);
|
||||
}
|
||||
|
||||
/// <summary>True only when we KNOW the container is full (capacity known + contents indexed). A
|
||||
/// closed bag (unknown count) returns false → advisory accept; the server is authoritative.</summary>
|
||||
private bool IsContainerFull(uint container)
|
||||
{
|
||||
int cap = _objects.Get(container)?.ItemsCapacity ?? 0;
|
||||
if (cap <= 0) return false;
|
||||
return _objects.GetContents(container).Count >= cap;
|
||||
}
|
||||
```
|
||||
|
||||
(e) Add the `using` if missing at the top of the file: `using AcDream.App.UI;` (for `IItemListDragHandler`/`ItemDragPayload`/`UiItemSlot` — likely already present since the file uses `UiItemSlot`). Confirm the file compiles.
|
||||
|
||||
- [ ] **Step 4: Run to verify they pass**
|
||||
|
||||
Run: `dotnet test tests/AcDream.App.Tests/AcDream.App.Tests.csproj --filter "FullyQualifiedName~InventoryControllerTests"`
|
||||
Expected: PASS (5 new + all existing).
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add src/AcDream.App/UI/Layout/InventoryController.cs tests/AcDream.App.Tests/UI/Layout/InventoryControllerTests.cs
|
||||
git commit -m "feat(D.2b): InventoryController drag-drop handler (optimistic move + green-arrow/red-circle)
|
||||
|
||||
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 5: wire the send callback in `GameWindow`
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/AcDream.App/Rendering/GameWindow.cs` (the `InventoryController.Bind(...)` call ~line 2231-2242)
|
||||
|
||||
**Test note:** integration-wired; verified by build + the visual gate (Task 7).
|
||||
|
||||
- [ ] **Step 1: Add the callback**
|
||||
|
||||
Append after `sendNoLongerViewing: g => _liveSession?.SendNoLongerViewingContents(g)`:
|
||||
```csharp
|
||||
sendNoLongerViewing: g => _liveSession?.SendNoLongerViewingContents(g),
|
||||
sendPutItemInContainer: (item, container, placement) =>
|
||||
_liveSession?.SendPutItemInContainer(item, container, placement));
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Verify it builds**
|
||||
|
||||
Run: `dotnet build src/AcDream.App/AcDream.App.csproj`
|
||||
Expected: Build succeeded.
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add src/AcDream.App/Rendering/GameWindow.cs
|
||||
git commit -m "feat(D.2b): wire InventoryController drag-drop to the live session
|
||||
|
||||
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 6: divergence register
|
||||
|
||||
**Files:**
|
||||
- Modify: `docs/architecture/retail-divergence-register.md` (add rows after AP-59; use the next free numbers AP-60/AP-61)
|
||||
|
||||
- [ ] **Step 1: Add the rows**
|
||||
|
||||
- **AP-60** — Inventory drag `OnDragLift` is a no-op + the source cell is not dimmed: retail dims the lifted item's source cell; acdream leaves the item in place during the drag + the floating ghost. File `src/AcDream.App/UI/Layout/InventoryController.cs`. Risk: a drag in progress doesn't visually mark its origin (cosmetic). Anchor: `RecvNotice_ItemListBeginDrag`.
|
||||
- **AP-61** — Drop on a CLOSED side bag is advisory-accept: the client can't know a closed bag's item count (contents aren't indexed until opened), so `OnDragOver` shows green + relies on the server's `InventoryServerSaveFailed` reject + the rollback; retail knows the count when loaded. File `src/AcDream.App/UI/Layout/InventoryController.cs` (`IsContainerFull`). Risk: a drop into a closed-but-full bag shows green then snaps back (a flicker) instead of pre-showing red. Anchor: `UIElement_ItemList::InqDropIconInfo 0x004e26f0`.
|
||||
|
||||
- [ ] **Step 2: Commit**
|
||||
|
||||
```bash
|
||||
git add docs/architecture/retail-divergence-register.md
|
||||
git commit -m "docs(D.2b): divergence rows AP-60 (lift no-op) + AP-61 (closed-bag advisory accept)
|
||||
|
||||
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 7: full-suite gate + visual verification
|
||||
|
||||
**Files:** none.
|
||||
|
||||
- [ ] **Step 1: Full build + full test**
|
||||
|
||||
Run: `dotnet build` then `dotnet test` (use `dotnet test --no-build` if a client is running — avoids the App.dll lock; the implementer built above). Expected: all green, 0 failures (run the FULL suite — the B-Wire cross-file-regression lesson).
|
||||
|
||||
- [ ] **Step 2: Launch (plain `dotnet run`, DO NOT pre-kill a running client)**
|
||||
|
||||
If a rebuild is locked by a running client, ASK the user to close it. Launch (PowerShell, background):
|
||||
```powershell
|
||||
$env:ACDREAM_DAT_DIR="$env:USERPROFILE\Documents\Asheron's Call"; $env:ACDREAM_LIVE="1"; $env:ACDREAM_TEST_HOST="127.0.0.1"; $env:ACDREAM_TEST_PORT="9000"; $env:ACDREAM_TEST_USER="testaccount"; $env:ACDREAM_TEST_PASS="testpassword"; $env:ACDREAM_RETAIL_UI="1"
|
||||
dotnet run --project src\AcDream.App\AcDream.App.csproj --no-build -c Debug 2>&1 | Tee-Object -FilePath "launch.log"
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Visual gate (the oracle — user confirms)**
|
||||
|
||||
With F12 open, the user: drags an item onto an **empty slot** → it lands at the first empty slot the instant they release; onto an **item** → inserts before it; onto a **side bag** → goes in; hovering a **full bag** shows the **red circle** and the drop bounces (snaps back); a valid target shows the **green insert-arrow**. WireMCP (`127.0.0.1:9000`) confirms `PutItemInContainer 0x0019` out + the `0x0022` echo (and `0x00A0` on a full-bag bounce). Note any sprite/placement mismatch and iterate (the gate is the oracle).
|
||||
|
||||
- [ ] **Step 4: Update SSOT + roadmap**
|
||||
|
||||
Append a "B-Drag SHIPPED" entry to `claude-memory/project_d2b_retail_ui.md` (key facts + any DO-NOT-RETRY) + update the NEXT pointer. Commit.
|
||||
|
||||
---
|
||||
|
||||
## Self-review
|
||||
|
||||
**Spec coverage:** optimistic move + rollback (T1) ↔ §Components.3; SendPutItemInContainer (T2) ↔ §Components.1; confirm/rollback wiring (T3) ↔ §Components.3; the drag handler — OnDragLift/OnDragOver/HandleDropRelease + the 3 placements + overlays (T4) ↔ §Components.2 + §Scope goals 1–3; GameWindow wiring (T5) ↔ §Components; divergence (T6) ↔ §Divergence register; full suite + visual gate (T7) ↔ §Acceptance. All covered.
|
||||
|
||||
**Placeholder scan:** every code step is complete; no TBD. The overlay ids are export-confirmed literals (`0x060011F7`/`0x060011F8`); the AP-60/61 numbers are mechanical (next free in the register).
|
||||
|
||||
**Type consistency:** `MoveItemOptimistic(uint,uint,int)`/`ConfirmMove(uint)`/`RollbackMove(uint)` consistent T1↔T3↔T4. `SendPutItemInContainer(uint,uint,int)` consistent T2↔T5↔T4 (capture). `Action<uint,uint,int>? sendPutItemInContainer` consistent T4 (ctor/Bind/test harness)↔T5. `OnDragLift`/`OnDragOver`/`HandleDropRelease`/`IsContainerFull` all defined in T4; `EffectiveOpen()` pre-exists. Drag sprites `0x060011F7`/`0x060011F8` consistent across T4.
|
||||
|
|
@ -0,0 +1,862 @@
|
|||
# Paperdoll Slice 1 (Equip Slots) 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:** Bind the ~21 mounted paperdoll equip slots to live equipped-item data and make them drag-drop wield/unwield targets (no 3D doll — that's Slice 2).
|
||||
|
||||
**Architecture:** A new `PaperdollController` (mirrors the shipped `InventoryController`) maps each equip-slot element-id → `EquipMask`, populates each single-cell `UiItemList` from the item whose `CurrentlyEquippedLocation` intersects the slot mask, and is the slots' `IItemListDragHandler` (drop = wield via the existing `GetAndWieldItem 0x001A` wire + a new optimistic-equip Core method). Two Core prerequisites: correct the misaligned `EquipMask` enum to canonical ACE/retail values, and add `WieldItemOptimistic` + an equip-aware rollback snapshot. Unwield is already handled by `InventoryController` (dragging an equipped item onto the grid).
|
||||
|
||||
**Tech Stack:** C# / .NET 10, xUnit, the acdream `UiHost` retained-mode toolkit, the `ClientObjectTable` data model.
|
||||
|
||||
**Spec:** `docs/superpowers/specs/2026-06-22-d2b-paperdoll-slice1-equip-slots-design.md`
|
||||
|
||||
---
|
||||
|
||||
## Task 1: De-risk probe — equip slots resolve to `UiItemList`
|
||||
|
||||
The whole plan assumes the imported paperdoll subtree materializes each equip slot as a `UiItemList`
|
||||
(factory `0x10000031`). Verify against the live dat FIRST. If it fails, STOP and report — the importer
|
||||
would need to factory-build the slots (out of this plan's scope).
|
||||
|
||||
**Files:**
|
||||
- Modify: `tests/AcDream.App.Tests/UI/Layout/InventoryFrameImportProbe.cs`
|
||||
|
||||
- [ ] **Step 1: Add the using + the probe test**
|
||||
|
||||
At the top of the file, ensure `using AcDream.App.UI;` is present (add it after `using AcDream.App.UI.Layout;`). Then add this method inside the `InventoryFrameImportProbe` class:
|
||||
|
||||
```csharp
|
||||
[Fact]
|
||||
public void Paperdoll_equip_slots_resolve_to_item_lists()
|
||||
{
|
||||
var datDir = DatDir();
|
||||
if (datDir is null) return; // CI: no live dat — skip
|
||||
|
||||
using var dats = new DatCollection(datDir, DatAccessType.Read);
|
||||
var layout = LayoutImporter.Import(dats, Frame, _ => (0u, 0, 0), null);
|
||||
Assert.NotNull(layout);
|
||||
|
||||
// A representative spread across the slot grid (head, shield, the weapon composite, cloak,
|
||||
// trinket, a finger). All inherit base 0x100001E4 → 0x2100003D (Type 0x10000031 = UIElement_ItemList).
|
||||
foreach (uint id in new[] { 0x100005ABu, 0x100001E1u, 0x100001DFu, 0x100005E9u, 0x1000058Eu, 0x100001DCu })
|
||||
{
|
||||
var el = layout!.FindElement(id);
|
||||
Assert.True(el is UiItemList,
|
||||
$"equip slot 0x{id:X8} resolved to {el?.GetType().Name ?? "null"}, expected UiItemList");
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run the probe against the live dat**
|
||||
|
||||
Run (PowerShell, the dat dir is the user's):
|
||||
|
||||
```
|
||||
$env:ACDREAM_DAT_DIR = "$env:USERPROFILE\Documents\Asheron's Call"
|
||||
dotnet test tests/AcDream.App.Tests/AcDream.App.Tests.csproj --filter "FullyQualifiedName~Paperdoll_equip_slots_resolve_to_item_lists"
|
||||
```
|
||||
|
||||
Expected: **PASS** (1 test). If it reports 0 run, the env var didn't take — confirm the dat dir exists.
|
||||
**If it FAILS** (a slot resolved to `UiDatElement`/null), STOP: the importer does not factory-build the
|
||||
paperdoll slots. Report this — the plan needs an importer fix prepended before continuing.
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add tests/AcDream.App.Tests/UI/Layout/InventoryFrameImportProbe.cs
|
||||
git commit -m "test(D.2b): probe — paperdoll equip slots resolve to UiItemList"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 2: Correct the `EquipMask` enum (Core) + numeric-pin test
|
||||
|
||||
acdream's `EquipMask` diverges from canonical AC (`acclient.h:3193` `INVENTORY_LOC`) from bit `0x2000` up
|
||||
(phantom `HandArmor`/`FootArmor`). Replace it with the verbatim retail values and lock them with a test.
|
||||
|
||||
**Files:**
|
||||
- Create: `tests/AcDream.Core.Tests/Items/EquipMaskTests.cs`
|
||||
- Modify: `src/AcDream.Core/Items/ClientObject.cs:60-100` (the `EquipMask` enum)
|
||||
|
||||
- [ ] **Step 1: Write the failing numeric-pin test**
|
||||
|
||||
Create `tests/AcDream.Core.Tests/Items/EquipMaskTests.cs`:
|
||||
|
||||
```csharp
|
||||
using AcDream.Core.Items;
|
||||
using Xunit;
|
||||
|
||||
namespace AcDream.Core.Tests.Items;
|
||||
|
||||
/// <summary>
|
||||
/// Pins every EquipMask member to the verbatim retail INVENTORY_LOC value
|
||||
/// (docs/research/named-retail/acclient.h:3193). The wire delivers these exact bits
|
||||
/// (ACE EquipMask == retail INVENTORY_LOC); any drift desyncs the paperdoll element-id→mask
|
||||
/// map and the GetAndWieldItem wire. This test is the anti-regression lock.
|
||||
/// </summary>
|
||||
public sealed class EquipMaskTests
|
||||
{
|
||||
[Theory]
|
||||
[InlineData(0x00000000u, EquipMask.None)]
|
||||
[InlineData(0x00000001u, EquipMask.HeadWear)]
|
||||
[InlineData(0x00000002u, EquipMask.ChestWear)]
|
||||
[InlineData(0x00000004u, EquipMask.AbdomenWear)]
|
||||
[InlineData(0x00000008u, EquipMask.UpperArmWear)]
|
||||
[InlineData(0x00000010u, EquipMask.LowerArmWear)]
|
||||
[InlineData(0x00000020u, EquipMask.HandWear)]
|
||||
[InlineData(0x00000040u, EquipMask.UpperLegWear)]
|
||||
[InlineData(0x00000080u, EquipMask.LowerLegWear)]
|
||||
[InlineData(0x00000100u, EquipMask.FootWear)]
|
||||
[InlineData(0x00000200u, EquipMask.ChestArmor)]
|
||||
[InlineData(0x00000400u, EquipMask.AbdomenArmor)]
|
||||
[InlineData(0x00000800u, EquipMask.UpperArmArmor)]
|
||||
[InlineData(0x00001000u, EquipMask.LowerArmArmor)]
|
||||
[InlineData(0x00002000u, EquipMask.UpperLegArmor)]
|
||||
[InlineData(0x00004000u, EquipMask.LowerLegArmor)]
|
||||
[InlineData(0x00008000u, EquipMask.NeckWear)]
|
||||
[InlineData(0x00010000u, EquipMask.WristWearLeft)]
|
||||
[InlineData(0x00020000u, EquipMask.WristWearRight)]
|
||||
[InlineData(0x00040000u, EquipMask.FingerWearLeft)]
|
||||
[InlineData(0x00080000u, EquipMask.FingerWearRight)]
|
||||
[InlineData(0x00100000u, EquipMask.MeleeWeapon)]
|
||||
[InlineData(0x00200000u, EquipMask.Shield)]
|
||||
[InlineData(0x00400000u, EquipMask.MissileWeapon)]
|
||||
[InlineData(0x00800000u, EquipMask.MissileAmmo)]
|
||||
[InlineData(0x01000000u, EquipMask.Held)]
|
||||
[InlineData(0x02000000u, EquipMask.TwoHanded)]
|
||||
[InlineData(0x04000000u, EquipMask.TrinketOne)]
|
||||
[InlineData(0x08000000u, EquipMask.Cloak)]
|
||||
[InlineData(0x10000000u, EquipMask.SigilOne)]
|
||||
[InlineData(0x20000000u, EquipMask.SigilTwo)]
|
||||
[InlineData(0x40000000u, EquipMask.SigilThree)]
|
||||
public void Member_has_canonical_retail_value(uint expected, EquipMask member)
|
||||
=> Assert.Equal(expected, (uint)member);
|
||||
|
||||
[Fact]
|
||||
public void Weapon_ready_slot_composite_is_0x3500000() // acclient.h:3235 WEAPON_READY_SLOT_LOC
|
||||
=> Assert.Equal(0x3500000u,
|
||||
(uint)(EquipMask.MeleeWeapon | EquipMask.MissileWeapon | EquipMask.Held | EquipMask.TwoHanded));
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run it to verify it fails**
|
||||
|
||||
```
|
||||
dotnet test tests/AcDream.Core.Tests/AcDream.Core.Tests.csproj --filter "FullyQualifiedName~EquipMaskTests"
|
||||
```
|
||||
|
||||
Expected: **FAIL** — e.g. `EquipMask.UpperLegArmor` is `0x4000` but the test expects `0x2000`; `Shield` is `0x800000` but expected `0x200000`. (Several `Member_has_canonical_retail_value` cases fail; some won't even compile if a member name is gone — proceed to Step 3.)
|
||||
|
||||
- [ ] **Step 3: Replace the enum body with the canonical values**
|
||||
|
||||
In `src/AcDream.Core/Items/ClientObject.cs`, replace the entire `EquipMask` enum (the `[Flags] public enum EquipMask : uint { … }` block, currently lines ~64-100) and its doc comment with:
|
||||
|
||||
```csharp
|
||||
/// <summary>
|
||||
/// Equipment slot bitmask — the verbatim retail <c>INVENTORY_LOC</c> enum
|
||||
/// (docs/research/named-retail/acclient.h:3193; identical to ACE's EquipMask).
|
||||
/// The wire (ValidLocations / CurrentWieldedLocation / WieldObject EquipLoc) delivers
|
||||
/// these exact bits. Pinned by EquipMaskTests — do NOT renumber.
|
||||
/// </summary>
|
||||
[Flags]
|
||||
public enum EquipMask : uint
|
||||
{
|
||||
None = 0x00000000,
|
||||
HeadWear = 0x00000001,
|
||||
ChestWear = 0x00000002,
|
||||
AbdomenWear = 0x00000004,
|
||||
UpperArmWear = 0x00000008,
|
||||
LowerArmWear = 0x00000010,
|
||||
HandWear = 0x00000020,
|
||||
UpperLegWear = 0x00000040,
|
||||
LowerLegWear = 0x00000080,
|
||||
FootWear = 0x00000100,
|
||||
ChestArmor = 0x00000200,
|
||||
AbdomenArmor = 0x00000400,
|
||||
UpperArmArmor = 0x00000800,
|
||||
LowerArmArmor = 0x00001000,
|
||||
UpperLegArmor = 0x00002000,
|
||||
LowerLegArmor = 0x00004000,
|
||||
NeckWear = 0x00008000,
|
||||
WristWearLeft = 0x00010000,
|
||||
WristWearRight = 0x00020000,
|
||||
FingerWearLeft = 0x00040000,
|
||||
FingerWearRight= 0x00080000,
|
||||
MeleeWeapon = 0x00100000,
|
||||
Shield = 0x00200000,
|
||||
MissileWeapon = 0x00400000,
|
||||
MissileAmmo = 0x00800000,
|
||||
Held = 0x01000000,
|
||||
TwoHanded = 0x02000000,
|
||||
TrinketOne = 0x04000000,
|
||||
Cloak = 0x08000000,
|
||||
SigilOne = 0x10000000,
|
||||
SigilTwo = 0x20000000,
|
||||
SigilThree = 0x40000000,
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run the pin test + the full Core suite**
|
||||
|
||||
```
|
||||
dotnet test tests/AcDream.Core.Tests/AcDream.Core.Tests.csproj
|
||||
```
|
||||
|
||||
Expected: **PASS** (EquipMaskTests green; the existing `ClientObjectTableTests` round-trip tests using `EquipMask.MeleeWeapon` still pass — they're value-agnostic). If a NON-test file fails to compile because it used a removed member (`HandArmor`/`Necklace`/`LeftRing`/`AetheriaRed`/etc.), that is a real consumer the §3 blast-radius scan missed — STOP and report it (do not invent a replacement).
|
||||
|
||||
- [ ] **Step 5: Run the Core.Net suite too** (the wire round-trip lives there)
|
||||
|
||||
```
|
||||
dotnet test tests/AcDream.Core.Net.Tests/AcDream.Core.Net.Tests.csproj
|
||||
```
|
||||
|
||||
Expected: **PASS** (`GameEventWiringTests` WieldObject round-trips are value-agnostic).
|
||||
|
||||
- [ ] **Step 6: Commit**
|
||||
|
||||
```bash
|
||||
git add src/AcDream.Core/Items/ClientObject.cs tests/AcDream.Core.Tests/Items/EquipMaskTests.cs
|
||||
git commit -m "fix(D.2b): correct EquipMask to canonical retail INVENTORY_LOC + numeric-pin test"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 3: `WieldItemOptimistic` + equip-aware rollback snapshot (Core)
|
||||
|
||||
Add the wield sibling to `MoveItemOptimistic` and extend the pending-move snapshot to remember the
|
||||
pre-move equip location (so rollback is faithful in both directions).
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/AcDream.Core/Items/ClientObjectTable.cs`
|
||||
- Modify: `tests/AcDream.Core.Tests/Items/ClientObjectTableTests.cs`
|
||||
|
||||
- [ ] **Step 1: Write the failing tests**
|
||||
|
||||
Add to `ClientObjectTableTests.cs` (the class already has a `FullWeenie` helper; these tests use the simpler `AddOrUpdate`+`MoveItem` seed path like the existing optimistic tests):
|
||||
|
||||
```csharp
|
||||
[Fact]
|
||||
public void WieldItemOptimistic_equipsInstantly_andSetsWielderAndContainer()
|
||||
{
|
||||
var table = new ClientObjectTable();
|
||||
const uint player = 0x50000001u, pack = 0x40000005u;
|
||||
table.AddOrUpdate(new ClientObject { ObjectId = 0x940u });
|
||||
table.MoveItem(0x940u, pack, newSlot: 2); // a pack item
|
||||
table.WieldItemOptimistic(0x940u, player, EquipMask.HeadWear);
|
||||
var o = table.Get(0x940u)!;
|
||||
Assert.Equal(EquipMask.HeadWear, o.CurrentlyEquippedLocation); // equipped now (instant)
|
||||
Assert.Equal(player, o.WielderId);
|
||||
Assert.Equal(player, o.ContainerId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void WieldItemOptimistic_rollback_unequips_andReturnsToPack()
|
||||
{
|
||||
var table = new ClientObjectTable();
|
||||
const uint player = 0x50000001u, pack = 0x40000005u;
|
||||
table.AddOrUpdate(new ClientObject { ObjectId = 0x941u });
|
||||
table.MoveItem(0x941u, pack, newSlot: 2);
|
||||
table.WieldItemOptimistic(0x941u, player, EquipMask.HeadWear);
|
||||
Assert.True(table.RollbackMove(0x941u)); // server rejected the wield
|
||||
var o = table.Get(0x941u)!;
|
||||
Assert.Equal(EquipMask.None, o.CurrentlyEquippedLocation); // un-equipped
|
||||
Assert.Equal(pack, o.ContainerId); // back in the pack
|
||||
Assert.Equal(2, o.ContainerSlot); // at its original slot
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RollbackMove_restoresPreMoveEquipLocation() // unwield-reject must snap back to EQUIPPED
|
||||
{
|
||||
var table = new ClientObjectTable();
|
||||
const uint player = 0x50000001u;
|
||||
table.AddOrUpdate(new ClientObject { ObjectId = 0x942u });
|
||||
table.MoveItem(0x942u, player, newSlot: -1, newEquipLocation: EquipMask.Shield); // equipped
|
||||
table.MoveItemOptimistic(0x942u, player, 0); // optimistic UNWIELD into the pack (clears equip)
|
||||
Assert.Equal(EquipMask.None, table.Get(0x942u)!.CurrentlyEquippedLocation);
|
||||
Assert.True(table.RollbackMove(0x942u)); // server rejected the unwield
|
||||
Assert.Equal(EquipMask.Shield, table.Get(0x942u)!.CurrentlyEquippedLocation); // restored to equipped
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void WieldItemOptimistic_unknownItem_false()
|
||||
=> Assert.False(new ClientObjectTable().WieldItemOptimistic(0xDEADu, 0x1u, EquipMask.HeadWear));
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run to verify they fail**
|
||||
|
||||
```
|
||||
dotnet test tests/AcDream.Core.Tests/AcDream.Core.Tests.csproj --filter "FullyQualifiedName~ClientObjectTableTests"
|
||||
```
|
||||
|
||||
Expected: **FAIL to compile** (`WieldItemOptimistic` does not exist) — that counts as red; proceed.
|
||||
|
||||
- [ ] **Step 3: Extend the snapshot tuple + add the helper + `WieldItemOptimistic`**
|
||||
|
||||
In `src/AcDream.Core/Items/ClientObjectTable.cs`:
|
||||
|
||||
(a) Change the `_pendingMoves` field (line ~50) to carry the equip location:
|
||||
|
||||
```csharp
|
||||
private readonly Dictionary<uint, (uint container, int slot, EquipMask equip, int outstanding)> _pendingMoves = new();
|
||||
```
|
||||
|
||||
(b) In `MoveItemOptimistic`, replace the inline snapshot block (the `if (_pendingMoves.TryGetValue(itemId, out var p)) … else …` that currently records `(item.ContainerId, item.ContainerSlot, 1)`) with a single call:
|
||||
|
||||
```csharp
|
||||
RecordPending(itemId, item);
|
||||
```
|
||||
|
||||
(c) Add the shared helper (place it just above `MoveItemOptimistic`):
|
||||
|
||||
```csharp
|
||||
/// <summary>Snapshot the item's pre-move (container, slot, equip) the FIRST time it moves, and
|
||||
/// bump the OUTSTANDING count on subsequent in-flight moves of the same item (so an early confirm
|
||||
/// can't clear a still-pending later move — the I1 hardening). Shared by MoveItemOptimistic (unwield/
|
||||
/// move) and WieldItemOptimistic (wield).</summary>
|
||||
private void RecordPending(uint itemId, ClientObject item)
|
||||
{
|
||||
if (_pendingMoves.TryGetValue(itemId, out var p))
|
||||
_pendingMoves[itemId] = (p.container, p.slot, p.equip, p.outstanding + 1);
|
||||
else
|
||||
_pendingMoves[itemId] = (item.ContainerId, item.ContainerSlot,
|
||||
item.CurrentlyEquippedLocation, 1);
|
||||
}
|
||||
```
|
||||
|
||||
(d) In `ConfirmMove`, fix the decrement line to carry `equip`:
|
||||
|
||||
```csharp
|
||||
else _pendingMoves[itemId] = (p.container, p.slot, p.equip, p.outstanding - 1);
|
||||
```
|
||||
|
||||
(e) In `RollbackMove`, restore the equip location too:
|
||||
|
||||
```csharp
|
||||
return MoveItem(itemId, pre.container, pre.slot, pre.equip);
|
||||
```
|
||||
|
||||
(f) Add `WieldItemOptimistic` (place it just after `MoveItemOptimistic`):
|
||||
|
||||
```csharp
|
||||
/// <summary>Optimistic (instant) wield: snapshot the pre-wield (container, slot, equip), then set
|
||||
/// ContainerId = WielderId = wielderGuid and CurrentlyEquippedLocation = equipMask (matching the
|
||||
/// server's WieldObject 0x0023 confirm), firing ObjectMoved for an immediate repaint. The caller
|
||||
/// sends GetAndWieldItem; ConfirmMove (on the 0x0023 echo) / RollbackMove (on 0x00A0) reconcile.</summary>
|
||||
public bool WieldItemOptimistic(uint itemId, uint wielderGuid, EquipMask equipMask)
|
||||
{
|
||||
if (!_objects.TryGetValue(itemId, out var item)) return false;
|
||||
RecordPending(itemId, item);
|
||||
item.WielderId = wielderGuid; // unambiguously the player's gear during the optimistic window
|
||||
return MoveItem(itemId, wielderGuid, newSlot: -1, newEquipLocation: equipMask);
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run the full Core suite**
|
||||
|
||||
```
|
||||
dotnet test tests/AcDream.Core.Tests/AcDream.Core.Tests.csproj
|
||||
```
|
||||
|
||||
Expected: **PASS** — the 4 new tests + all existing optimistic-move tests (`MoveItemOptimistic_*`, `ConfirmMove_*`, the I1/I2 tests) stay green (the tuple gained a field but their behavior is unchanged).
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add src/AcDream.Core/Items/ClientObjectTable.cs tests/AcDream.Core.Tests/Items/ClientObjectTableTests.cs
|
||||
git commit -m "feat(D.2b): WieldItemOptimistic + equip-aware rollback snapshot (Core)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 4: Confirm an optimistic wield on the `WieldObject 0x0023` echo (Core.Net)
|
||||
|
||||
The `WieldObject` handler updates state but never calls `ConfirmMove`, so an optimistic wield's snapshot
|
||||
would linger. Add the confirm (mirrors the `InventoryPutObjInContainer` handler).
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/AcDream.Core.Net/GameEventWiring.cs` (the `WieldObject` handler, ~line 238)
|
||||
- Modify: `tests/AcDream.Core.Net.Tests/GameEventWiringTests.cs`
|
||||
|
||||
- [ ] **Step 1: Write the failing test**
|
||||
|
||||
Add to `GameEventWiringTests.cs` (mirror the existing `WireAll_WieldObject_RoutesToClientObjectTable` payload format — itemGuid, EquipLoc, WielderGuid):
|
||||
|
||||
```csharp
|
||||
[Fact]
|
||||
public void WireAll_WieldObject_ConfirmsOptimisticWield()
|
||||
{
|
||||
var (d, items, _, _, _) = MakeAll();
|
||||
const uint player = 0x2000u;
|
||||
items.AddOrUpdate(new ClientObject { ObjectId = 0x1500, WeenieClassId = 1 });
|
||||
items.MoveItem(0x1500, 0x9999u, newSlot: 0); // start in a pack
|
||||
items.WieldItemOptimistic(0x1500, player, EquipMask.MeleeWeapon); // optimistic wield → snapshot pending
|
||||
|
||||
byte[] payload = new byte[12];
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(payload, 0x1500);
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(payload.AsSpan(4), (uint)EquipMask.MeleeWeapon);
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(payload.AsSpan(8), player);
|
||||
var env = GameEventEnvelope.TryParse(WrapEnvelope(GameEventType.WieldObject, payload));
|
||||
d.Dispatch(env!.Value); // server confirms the wield
|
||||
|
||||
Assert.False(items.RollbackMove(0x1500)); // pending snapshot was cleared by ConfirmMove
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run to verify it fails**
|
||||
|
||||
```
|
||||
dotnet test tests/AcDream.Core.Net.Tests/AcDream.Core.Net.Tests.csproj --filter "FullyQualifiedName~WireAll_WieldObject_ConfirmsOptimisticWield"
|
||||
```
|
||||
|
||||
Expected: **FAIL** — `RollbackMove` returns `true` (the snapshot was never confirmed), so the assertion fails.
|
||||
|
||||
- [ ] **Step 3: Add the `ConfirmMove` call**
|
||||
|
||||
In `src/AcDream.Core.Net/GameEventWiring.cs`, the `WieldObject` handler currently reads:
|
||||
|
||||
```csharp
|
||||
dispatcher.Register(GameEventType.WieldObject, e =>
|
||||
{
|
||||
var p = GameEvents.ParseWieldObject(e.Payload.Span);
|
||||
if (p is not null) items.MoveItem(
|
||||
p.Value.ItemGuid,
|
||||
newContainerId: p.Value.WielderGuid,
|
||||
newEquipLocation: (AcDream.Core.Items.EquipMask)p.Value.EquipLoc);
|
||||
});
|
||||
```
|
||||
|
||||
Change the body so it confirms an optimistic wield after applying the move:
|
||||
|
||||
```csharp
|
||||
dispatcher.Register(GameEventType.WieldObject, e =>
|
||||
{
|
||||
var p = GameEvents.ParseWieldObject(e.Payload.Span);
|
||||
if (p is null) return;
|
||||
items.MoveItem(
|
||||
p.Value.ItemGuid,
|
||||
newContainerId: p.Value.WielderGuid,
|
||||
newEquipLocation: (AcDream.Core.Items.EquipMask)p.Value.EquipLoc);
|
||||
items.ConfirmMove(p.Value.ItemGuid); // Slice 1: confirm an optimistic wield (mirrors the 0x0022 handler)
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run the full Core.Net suite**
|
||||
|
||||
```
|
||||
dotnet test tests/AcDream.Core.Net.Tests/AcDream.Core.Net.Tests.csproj
|
||||
```
|
||||
|
||||
Expected: **PASS** (the new test + the existing `WireAll_WieldObject_RoutesToClientObjectTable` both green).
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add src/AcDream.Core.Net/GameEventWiring.cs tests/AcDream.Core.Net.Tests/GameEventWiringTests.cs
|
||||
git commit -m "fix(D.2b): ConfirmMove on the WieldObject 0x0023 echo (clears optimistic wield)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 5: `PaperdollController` + tests + divergence rows (App)
|
||||
|
||||
The controller: element-id→mask map, bind each slot, populate icons, be the wield drag handler.
|
||||
|
||||
**Files:**
|
||||
- Create: `src/AcDream.App/UI/Layout/PaperdollController.cs`
|
||||
- Create: `tests/AcDream.App.Tests/UI/Layout/PaperdollControllerTests.cs`
|
||||
- Modify: `docs/architecture/retail-divergence-register.md`
|
||||
|
||||
- [ ] **Step 1: Write the failing tests**
|
||||
|
||||
Create `tests/AcDream.App.Tests/UI/Layout/PaperdollControllerTests.cs`:
|
||||
|
||||
```csharp
|
||||
using System.Collections.Generic;
|
||||
using AcDream.App.UI;
|
||||
using AcDream.App.UI.Layout;
|
||||
using AcDream.Core.Items;
|
||||
using Xunit;
|
||||
|
||||
namespace AcDream.App.Tests.UI.Layout;
|
||||
|
||||
public class PaperdollControllerTests
|
||||
{
|
||||
private const uint Player = 0x50000001u;
|
||||
private const uint Pack = 0x40000005u;
|
||||
private const uint HeadSlot = 0x100005ABu; // HeadWear 0x1
|
||||
private const uint ShieldSlot = 0x100001E1u; // Shield 0x200000
|
||||
private const uint WeaponSlot = 0x100001DFu; // composite 0x3500000
|
||||
private const uint FingerLSlot= 0x100001DCu; // FingerWearLeft 0x40000
|
||||
|
||||
private sealed class RootElement : UiElement { }
|
||||
|
||||
private static (ImportedLayout layout, Dictionary<uint, UiItemList> lists) BuildLayout()
|
||||
{
|
||||
var ids = new[] { HeadSlot, ShieldSlot, WeaponSlot, FingerLSlot };
|
||||
var lists = new Dictionary<uint, UiItemList>();
|
||||
var byId = new Dictionary<uint, UiElement>();
|
||||
var root = new RootElement { Width = 224, Height = 214 };
|
||||
foreach (var id in ids)
|
||||
{
|
||||
var list = new UiItemList { Width = 32, Height = 32 };
|
||||
lists[id] = list; byId[id] = list; root.AddChild(list);
|
||||
}
|
||||
return (new ImportedLayout(root, byId), lists);
|
||||
}
|
||||
|
||||
private static PaperdollController Bind(ImportedLayout layout, ClientObjectTable objects,
|
||||
List<(uint item, uint mask)>? wields = null)
|
||||
=> PaperdollController.Bind(layout, objects, () => Player,
|
||||
iconIds: (_, _, _, _, _) => 0x1234u,
|
||||
sendWield: wields is null ? null : (i, m) => wields.Add((i, m)));
|
||||
|
||||
private static void SeedEquipped(ClientObjectTable t, uint guid, EquipMask loc)
|
||||
{
|
||||
t.AddOrUpdate(new ClientObject { ObjectId = guid, WielderId = Player });
|
||||
t.MoveItem(guid, Player, newSlot: -1, newEquipLocation: loc);
|
||||
}
|
||||
|
||||
private static void SeedPackItem(ClientObjectTable t, uint guid, EquipMask validLocations)
|
||||
{
|
||||
t.AddOrUpdate(new ClientObject { ObjectId = guid, ValidLocations = validLocations });
|
||||
t.MoveItem(guid, Pack, newSlot: 0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Populate_shows_equipped_item_in_its_slot()
|
||||
{
|
||||
var (layout, lists) = BuildLayout();
|
||||
var objects = new ClientObjectTable();
|
||||
SeedEquipped(objects, 0xA01u, EquipMask.HeadWear);
|
||||
Bind(layout, objects);
|
||||
Assert.Equal(0xA01u, lists[HeadSlot].Cell.ItemId); // helm in the head slot
|
||||
Assert.Equal(0u, lists[ShieldSlot].Cell.ItemId); // shield slot empty
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Populate_matches_a_weapon_into_the_composite_slot()
|
||||
{
|
||||
var (layout, lists) = BuildLayout();
|
||||
var objects = new ClientObjectTable();
|
||||
SeedEquipped(objects, 0xB01u, EquipMask.MeleeWeapon); // a sword's actual equip loc
|
||||
Bind(layout, objects);
|
||||
Assert.Equal(0xB01u, lists[WeaponSlot].Cell.ItemId); // matched via (loc & composite) != 0
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void OnDragOver_accepts_only_valid_locations()
|
||||
{
|
||||
var (layout, lists) = BuildLayout();
|
||||
var objects = new ClientObjectTable();
|
||||
SeedPackItem(objects, 0xC01u, EquipMask.HeadWear); // a helm in the pack
|
||||
var ctrl = Bind(layout, objects);
|
||||
var payload = new ItemDragPayload(0xC01u, ItemDragSource.Inventory, 0, lists[HeadSlot].Cell);
|
||||
Assert.True(ctrl.OnDragOver(lists[HeadSlot], lists[HeadSlot].Cell, payload)); // helm → head OK
|
||||
Assert.False(ctrl.OnDragOver(lists[ShieldSlot], lists[ShieldSlot].Cell, payload)); // helm → shield NO
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void HandleDropRelease_wields_optimistically_and_sends_wire()
|
||||
{
|
||||
var (layout, lists) = BuildLayout();
|
||||
var objects = new ClientObjectTable();
|
||||
SeedPackItem(objects, 0xD01u, EquipMask.HeadWear);
|
||||
var wields = new List<(uint item, uint mask)>();
|
||||
var ctrl = Bind(layout, objects, wields);
|
||||
var payload = new ItemDragPayload(0xD01u, ItemDragSource.Inventory, 0, lists[HeadSlot].Cell);
|
||||
ctrl.HandleDropRelease(lists[HeadSlot], lists[HeadSlot].Cell, payload);
|
||||
Assert.Equal(EquipMask.HeadWear, objects.Get(0xD01u)!.CurrentlyEquippedLocation); // equipped instantly
|
||||
Assert.Equal(Player, objects.Get(0xD01u)!.ContainerId); // contained-by-wielder (the optimistic wield is ContainerId-based; it does NOT write WielderId)
|
||||
Assert.Single(wields);
|
||||
Assert.Equal((0xD01u, (uint)EquipMask.HeadWear), wields[0]); // GetAndWieldItem wire
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void HandleDropRelease_resolves_the_finger_bit_for_a_dual_finger_ring()
|
||||
{
|
||||
var (layout, lists) = BuildLayout();
|
||||
var objects = new ClientObjectTable();
|
||||
SeedPackItem(objects, 0xE01u, EquipMask.FingerWearLeft | EquipMask.FingerWearRight);
|
||||
var wields = new List<(uint item, uint mask)>();
|
||||
var ctrl = Bind(layout, objects, wields);
|
||||
var payload = new ItemDragPayload(0xE01u, ItemDragSource.Inventory, 0, lists[FingerLSlot].Cell);
|
||||
ctrl.HandleDropRelease(lists[FingerLSlot], lists[FingerLSlot].Cell, payload);
|
||||
Assert.Equal((uint)EquipMask.FingerWearLeft, wields[0].mask); // ValidLocations & slotMask = left finger only
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Empty_equip_slot_is_transparent() // EmptySprite=0 ⇒ nothing drawn (no doll behind it in Slice 1)
|
||||
{
|
||||
var (layout, lists) = BuildLayout();
|
||||
Bind(layout, new ClientObjectTable());
|
||||
Assert.Equal(0u, lists[HeadSlot].Cell.EmptySprite);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run to verify they fail**
|
||||
|
||||
```
|
||||
dotnet test tests/AcDream.App.Tests/AcDream.App.Tests.csproj --filter "FullyQualifiedName~PaperdollControllerTests"
|
||||
```
|
||||
|
||||
Expected: **FAIL to compile** (`PaperdollController` does not exist) — counts as red; proceed.
|
||||
|
||||
- [ ] **Step 3: Implement `PaperdollController`**
|
||||
|
||||
Create `src/AcDream.App/UI/Layout/PaperdollController.cs`:
|
||||
|
||||
```csharp
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using AcDream.App.UI;
|
||||
using AcDream.Core.Items;
|
||||
|
||||
namespace AcDream.App.UI.Layout;
|
||||
|
||||
/// <summary>
|
||||
/// Binds the ~21 equip slots mounted under the paperdoll (gmPaperDollUI 0x21000024, nested in the
|
||||
/// inventory frame 0x21000023) to live equipped-item data and makes them drag-drop WIELD targets.
|
||||
/// The acdream analogue of gmPaperDollUI::PostInit + GetLocationInfoFromElementID (named-retail decomp
|
||||
/// 175480 / 173620). Slice 1: equip slots only — no 3D doll viewport (that's Slice 2).
|
||||
/// Unwield is handled by InventoryController (dragging an equipped item onto the pack grid).
|
||||
/// </summary>
|
||||
public sealed class PaperdollController : IItemListDragHandler
|
||||
{
|
||||
// WEAPON_READY_SLOT_LOC (acclient.h:3235): the weapon-hand doll slot accepts any wieldable weapon.
|
||||
private const EquipMask WeaponSlotMask =
|
||||
EquipMask.MeleeWeapon | EquipMask.MissileWeapon | EquipMask.Held | EquipMask.TwoHanded;
|
||||
|
||||
// element-id → EquipMask (verified: dump paperdoll-0x21000024.txt ↔ deep-dive §3a ↔ acclient.h:3193).
|
||||
// The 3 Aetheria sigil slots (0x10000595/96/97) are SetVisible(0) in retail — skipped (Slice 1 scope).
|
||||
private static readonly (uint Element, EquipMask Mask)[] SlotMap =
|
||||
{
|
||||
(0x100005ABu, EquipMask.HeadWear), // 0x1
|
||||
(0x100001E2u, EquipMask.ChestWear), // 0x2
|
||||
(0x100001E3u, EquipMask.UpperLegWear), // 0x40
|
||||
(0x100005B0u, EquipMask.HandWear), // 0x20
|
||||
(0x100005B3u, EquipMask.FootWear), // 0x100
|
||||
(0x100005ACu, EquipMask.ChestArmor), // 0x200
|
||||
(0x100005ADu, EquipMask.AbdomenArmor), // 0x400
|
||||
(0x100005AEu, EquipMask.UpperArmArmor), // 0x800
|
||||
(0x100005AFu, EquipMask.LowerArmArmor), // 0x1000
|
||||
(0x100005B1u, EquipMask.UpperLegArmor), // 0x2000
|
||||
(0x100005B2u, EquipMask.LowerLegArmor), // 0x4000
|
||||
(0x100001DAu, EquipMask.NeckWear), // 0x8000
|
||||
(0x100001DBu, EquipMask.WristWearLeft), // 0x10000
|
||||
(0x100001DDu, EquipMask.WristWearRight), // 0x20000
|
||||
(0x100001DCu, EquipMask.FingerWearLeft), // 0x40000
|
||||
(0x100001DEu, EquipMask.FingerWearRight), // 0x80000
|
||||
(0x100001E1u, EquipMask.Shield), // 0x200000
|
||||
(0x100001E0u, EquipMask.MissileAmmo), // 0x800000 (LIKELY — gate-verify, AP row)
|
||||
(0x100001DFu, WeaponSlotMask), // 0x3500000 composite
|
||||
(0x1000058Eu, EquipMask.TrinketOne), // 0x4000000
|
||||
(0x100005E9u, EquipMask.Cloak), // 0x8000000
|
||||
};
|
||||
|
||||
private readonly ClientObjectTable _objects;
|
||||
private readonly Func<uint> _playerGuid;
|
||||
private readonly Func<ItemType, uint, uint, uint, uint, uint> _iconIds;
|
||||
private readonly Action<uint, uint>? _sendWield; // (itemGuid, equipMask) → GetAndWieldItem 0x001A
|
||||
private readonly List<(EquipMask Mask, UiItemList List)> _slots = new();
|
||||
|
||||
private PaperdollController(
|
||||
ImportedLayout layout, ClientObjectTable objects, Func<uint> playerGuid,
|
||||
Func<ItemType, uint, uint, uint, uint, uint> iconIds, Action<uint, uint>? sendWield)
|
||||
{
|
||||
_objects = objects; _playerGuid = playerGuid; _iconIds = iconIds; _sendWield = sendWield;
|
||||
|
||||
int index = 0;
|
||||
foreach (var (element, mask) in SlotMap)
|
||||
{
|
||||
if (layout.FindElement(element) is not UiItemList list) { index++; continue; }
|
||||
list.RegisterDragHandler(this);
|
||||
list.Cell.SourceKind = ItemDragSource.Equipment;
|
||||
list.Cell.SlotIndex = index++; // a stable per-slot index (routing uses the list, not this)
|
||||
list.Cell.EmptySprite = 0u; // TRANSPARENT empty slot (brainstorm decision; no doll behind it)
|
||||
_slots.Add((mask, list));
|
||||
}
|
||||
|
||||
_objects.ObjectAdded += OnObjectChanged;
|
||||
_objects.ObjectMoved += OnObjectMoved;
|
||||
_objects.ObjectRemoved += OnObjectChanged;
|
||||
_objects.ObjectUpdated += OnObjectChanged;
|
||||
|
||||
Populate();
|
||||
}
|
||||
|
||||
public static PaperdollController Bind(
|
||||
ImportedLayout layout, ClientObjectTable objects, Func<uint> playerGuid,
|
||||
Func<ItemType, uint, uint, uint, uint, uint> iconIds, Action<uint, uint>? sendWield = null)
|
||||
=> new PaperdollController(layout, objects, playerGuid, iconIds, sendWield);
|
||||
|
||||
private void OnObjectChanged(ClientObject o) { if (Concerns(o)) Populate(); }
|
||||
private void OnObjectMoved(ClientObject o, uint from, uint to)
|
||||
{ if (Concerns(o) || from == _playerGuid() || to == _playerGuid()) Populate(); }
|
||||
|
||||
/// <summary>The object is (or just became / ceased to be) the player's equipped gear.</summary>
|
||||
private bool Concerns(ClientObject o)
|
||||
{
|
||||
uint p = _playerGuid();
|
||||
return o.CurrentlyEquippedLocation != EquipMask.None || o.WielderId == p || o.ContainerId == p;
|
||||
}
|
||||
|
||||
public void Populate()
|
||||
{
|
||||
uint p = _playerGuid();
|
||||
|
||||
// The player's equipped gear (one pass). Scoped to the player so a vendor's/NPC's wielded item
|
||||
// (which can also carry CurrentWieldedLocation) can't leak into the doll.
|
||||
var equipped = new List<ClientObject>();
|
||||
foreach (var o in _objects.Objects)
|
||||
if (o.CurrentlyEquippedLocation != EquipMask.None && (o.WielderId == p || o.ContainerId == p))
|
||||
equipped.Add(o);
|
||||
|
||||
foreach (var (mask, list) in _slots)
|
||||
{
|
||||
ClientObject? worn = null;
|
||||
foreach (var o in equipped)
|
||||
if ((o.CurrentlyEquippedLocation & mask) != EquipMask.None) { worn = o; break; }
|
||||
|
||||
if (worn is null) { list.Cell.Clear(); continue; }
|
||||
uint tex = _iconIds(worn.Type, worn.IconId, worn.IconUnderlayId, worn.IconOverlayId, worn.Effects);
|
||||
list.Cell.SetItem(worn.ObjectId, tex);
|
||||
}
|
||||
}
|
||||
|
||||
private EquipMask MaskFor(UiItemList list)
|
||||
{
|
||||
foreach (var (mask, l) in _slots) if (ReferenceEquals(l, list)) return mask;
|
||||
return EquipMask.None;
|
||||
}
|
||||
|
||||
// ── IItemListDragHandler ──────────────────────────────────────────────────────────────────────
|
||||
/// <summary>No-op: a wielded item stays put until the server confirms (same as the inventory grid,
|
||||
/// unlike the toolbar's remove-on-lift). Unwield happens on DROP onto the pack grid — InventoryController.</summary>
|
||||
public void OnDragLift(UiItemList sourceList, UiItemSlot sourceCell, ItemDragPayload payload) { }
|
||||
|
||||
/// <summary>Accept iff the dragged item can be worn here (its ValidLocations intersects the slot mask).
|
||||
/// Retail: gmPaperDollUI::OnItemListDragOver (decomp 174302).</summary>
|
||||
public bool OnDragOver(UiItemList targetList, UiItemSlot targetCell, ItemDragPayload payload)
|
||||
{
|
||||
var item = _objects.Get(payload.ObjId);
|
||||
if (item is null) return false;
|
||||
return (item.ValidLocations & MaskFor(targetList)) != EquipMask.None;
|
||||
}
|
||||
|
||||
/// <summary>Wield the dropped item: optimistic local equip (instant) + GetAndWieldItem 0x001A.
|
||||
/// wieldMask = ValidLocations & slotMask resolves the specific bit (the composite weapon slot, the
|
||||
/// chosen finger). Retail: gmPaperDollUI HandleDropRelease → GetAndWieldItem.</summary>
|
||||
public void HandleDropRelease(UiItemList targetList, UiItemSlot targetCell, ItemDragPayload payload)
|
||||
{
|
||||
var item = _objects.Get(payload.ObjId);
|
||||
if (item is null) return;
|
||||
EquipMask wieldMask = item.ValidLocations & MaskFor(targetList);
|
||||
if (wieldMask == EquipMask.None) return; // not wieldable here (defensive; OnDragOver already rejected)
|
||||
_objects.WieldItemOptimistic(payload.ObjId, _playerGuid(), wieldMask);
|
||||
_sendWield?.Invoke(payload.ObjId, (uint)wieldMask);
|
||||
}
|
||||
|
||||
/// <summary>Detach event handlers (idempotent).</summary>
|
||||
public void Dispose()
|
||||
{
|
||||
_objects.ObjectAdded -= OnObjectChanged;
|
||||
_objects.ObjectMoved -= OnObjectMoved;
|
||||
_objects.ObjectRemoved -= OnObjectChanged;
|
||||
_objects.ObjectUpdated -= OnObjectChanged;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run the controller tests + the full App suite**
|
||||
|
||||
```
|
||||
dotnet test tests/AcDream.App.Tests/AcDream.App.Tests.csproj
|
||||
```
|
||||
|
||||
Expected: **PASS** (the 6 `PaperdollControllerTests` + the whole App suite green; the EquipMask correction is value-agnostic for existing App tests).
|
||||
|
||||
- [ ] **Step 5: Add the divergence-register rows**
|
||||
|
||||
Read `docs/architecture/retail-divergence-register.md`, find the highest existing `AP-NN`, and append three rows using the next sequential numbers (the B-Drag rows AP-60/AP-61 are the current tail, so expect AP-62..AP-64 — but use whatever the file's actual max+1 is). One-line rows in the file's existing format:
|
||||
|
||||
1. **MissileAmmo slot mask LIKELY** — `0x100001E0 → MissileAmmo 0x800000` is inferred (the decomp immediate at `GetLocationInfoFromElementID` 173676 is corrupted to a string pointer); risk if wrong: dropping ammo onto that slot wields to the wrong location. Source: `PaperdollController.SlotMap`. Gate-verify.
|
||||
2. **Dual-wield-into-shield-slot special not implemented** — retail `OnItemListDragOver` (decomp 174302) lets a melee-capable item also drop on the Shield slot; acdream's `wieldMask = ValidLocations & slotMask` rejects it. Risk: a dual-wielder can't off-hand a melee weapon via the doll. Source: `PaperdollController.HandleDropRelease`.
|
||||
3. **Wield-reject rollback assumes `InventoryServerSaveFailed 0x00A0`** — an optimistic wield rolls back only if ACE emits `0x00A0` for a `GetAndWieldItem` rejection; otherwise it's corrected by the next authoritative message. Source: `GameEventWiring` 0x00A0 handler. Gate-verify via WireMCP.
|
||||
|
||||
- [ ] **Step 6: Commit**
|
||||
|
||||
```bash
|
||||
git add src/AcDream.App/UI/Layout/PaperdollController.cs tests/AcDream.App.Tests/UI/Layout/PaperdollControllerTests.cs docs/architecture/retail-divergence-register.md
|
||||
git commit -m "feat(D.2b): PaperdollController — equip slots bind + wield drag handler (Slice 1)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 6: Wire `PaperdollController` into GameWindow (App)
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/AcDream.App/Rendering/GameWindow.cs` (the field declaration near `_inventoryController`; the bind site at ~line 2245, right after the `InventoryController.Bind(...)` call)
|
||||
|
||||
- [ ] **Step 1: Add the field**
|
||||
|
||||
Find the `_inventoryController` field declaration (grep `_inventoryController` in `GameWindow.cs` — it's a `private … InventoryController? _inventoryController;`). Add directly beneath it:
|
||||
|
||||
```csharp
|
||||
private AcDream.App.UI.Layout.PaperdollController? _paperdollController;
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Add the Bind call**
|
||||
|
||||
In the inventory-frame block, immediately after the `_inventoryController = AcDream.App.UI.Layout.InventoryController.Bind(...);` statement ends (the `sendPutItemInContainer:` line that closes with `));`, ~line 2245) and before the following `Console.WriteLine("[D.2b-B] retail inventory window …");`, insert:
|
||||
|
||||
```csharp
|
||||
// Slice 1: bind the paperdoll equip slots (same imported subtree) — show equipped gear +
|
||||
// wield-on-drop. Unwield is handled by InventoryController (drag an equipped item to the grid).
|
||||
_paperdollController = AcDream.App.UI.Layout.PaperdollController.Bind(
|
||||
invLayout, Objects,
|
||||
playerGuid: () => _playerServerGuid,
|
||||
iconIds: (type, icon, under, over, effects) => iconComposer.GetIcon(type, icon, under, over, effects),
|
||||
sendWield: (item, mask) => _liveSession?.SendGetAndWieldItem(item, mask));
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Build the whole solution**
|
||||
|
||||
```
|
||||
dotnet build
|
||||
```
|
||||
|
||||
Expected: **build succeeds** (0 errors).
|
||||
|
||||
- [ ] **Step 4: Run the full test suite (all projects)**
|
||||
|
||||
```
|
||||
dotnet test
|
||||
```
|
||||
|
||||
Expected: **all green** across Core / Core.Net / App / UI.Abstractions. This is the phase-boundary full-suite gate (the B-Wire process lesson — don't trust filtered subsets).
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add src/AcDream.App/Rendering/GameWindow.cs
|
||||
git commit -m "feat(D.2b): wire PaperdollController into the inventory frame (Slice 1)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Visual gate (user)
|
||||
|
||||
Launch the client (plain `dotnet run` — do NOT auto-kill a running client; if a rebuild is locked, ASK the user to close it), log in, press **F12**:
|
||||
|
||||
- Equipped gear shows as icons in the correct doll slots; empty slots are transparent.
|
||||
- Drag a wieldable item from the pack onto a matching slot → it wields instantly (icon appears) and survives the server echo; an invalid slot shows red and rejects.
|
||||
- Drag an equipped item from a slot to the pack → it unwields.
|
||||
|
||||
While testing, run WireMCP to confirm `GetAndWieldItem 0x001A` goes out on a wield and whether `0x00A0` comes back on a rejected wield (divergence row 3).
|
||||
|
||||
## Post-gate (after visual confirmation)
|
||||
|
||||
- Update `claude-memory/project_d2b_retail_ui.md` with the Slice 1 SHIPPED entry (key facts + DO-NOT-RETRY).
|
||||
- Update `docs/plans/2026-04-11-roadmap.md` (Sub-phase C Slice 1 shipped).
|
||||
- Note in `docs/ISSUES.md` any gate-verify follow-ups (the AP rows).
|
||||
Loading…
Add table
Add a link
Reference in a new issue