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).
|
||||
367
docs/superpowers/specs/2026-06-20-d2b-drag-drop-spine-design.md
Normal file
367
docs/superpowers/specs/2026-06-20-d2b-drag-drop-spine-design.md
Normal file
|
|
@ -0,0 +1,367 @@
|
|||
# D.2b drag-drop spine (Stream B.1) — design
|
||||
|
||||
**Date:** 2026-06-20
|
||||
**Phase:** D.2b retail-UI engine → D.5 core panels. Stream **B.1** of the
|
||||
2026-06-18 handoff (the shared drag-drop infra that BOTH the shortcut-drag stream
|
||||
(B) and the inventory window (C) sit on). Build it once, well — it is the
|
||||
critical-path lynchpin.
|
||||
**Branch:** `claude/hopeful-maxwell-214a12` (== main == `31d7ffd`).
|
||||
**Spec author:** lead-engineer brainstorm 2026-06-20 (4 design decisions confirmed
|
||||
by the user).
|
||||
|
||||
**Read alongside:**
|
||||
- `docs/research/2026-06-16-ui-item-slot-icon-dragdrop-spine-deep-dive.md` §5 / §5.7
|
||||
(the pseudocode IS the spec; sub-element + flag anchors).
|
||||
- `docs/research/2026-06-18-d53-bar-finish-and-inventory-handoff.md` §B.1
|
||||
(current-code readiness).
|
||||
- `claude-memory/project_d2b_retail_ui.md` (the toolkit), `feedback_ui_resolve_zero_magenta`
|
||||
(the 0-id → magenta footgun; guard on the id, not the GL handle).
|
||||
|
||||
---
|
||||
|
||||
## 1. Goal & non-goals
|
||||
|
||||
**Goal.** Complete the widget-level drag-drop machine so a player can pick up an
|
||||
item icon, see a translucent ghost track the cursor, watch hovered slots flip to
|
||||
**accept (green) / reject (red)**, and drop — at which point the drop is dispatched
|
||||
to the owning panel's handler with a fully-resolved payload. `UiRoot` already holds
|
||||
the *device-level* drag state machine (`BeginDrag`/`UpdateDragHover`/`FinishDrag`,
|
||||
promoted on >3 px move, live-wired to Silk.NET). This stream supplies the five
|
||||
missing pieces (handoff §B.1): payload injection, the cursor ghost, drop-target
|
||||
hooks on the cell, the accept/reject overlay, and the panel-handler interface.
|
||||
|
||||
**In scope (the spine — generic, shared):**
|
||||
1. Payload injection into `UiRoot.BeginDrag` (today passes `payload: null`).
|
||||
2. A cursor-following drag ghost, painted by `UiRoot`, item-agnostic.
|
||||
3. Drop-target hooks on `UiItemSlot` (DragEnter/Over/DropReleased → accept/reject
|
||||
overlay + dispatch).
|
||||
4. The `IItemListDragHandler` interface + registration on `UiItemList`.
|
||||
5. A typed `ItemDragPayload` describing what is dragged and where from.
|
||||
6. Moving the item-cell's use-trigger from MouseDown → Click (drag/click
|
||||
disambiguation; also more retail-faithful).
|
||||
7. A **visible toolbar stub handler** so the chain is verifiable this session.
|
||||
|
||||
**Out of scope (explicitly deferred — later streams):**
|
||||
- The `AddShortcut 0x019C` / `RemoveShortcut 0x019D` wire and the mutable
|
||||
`ShortcutStore` (Stream **B.2**).
|
||||
- The inventory window, `UiItemList` N-cell grid mode, window manager
|
||||
(Streams **C / window-mgr**).
|
||||
- Stack-split drag (the entry/slider `0x100001A3/A4`), give-to-NPC, drop-to-ground,
|
||||
wield wire — all per-panel **HandleDropRelease** opcode selection (the deep-dive
|
||||
§5.7 opcode table; lands with each consuming panel).
|
||||
- The faithful `m_pDragIcon` (base+overlay-no-underlay) drag composite — MVP reuses
|
||||
the existing full icon at reduced alpha (AP-47).
|
||||
- Esc-to-cancel-drag (retail has it; future polish).
|
||||
|
||||
---
|
||||
|
||||
## 2. Retail grounding (the four confirmed decisions)
|
||||
|
||||
The named decomp (`acclient_2013_pseudo_c.txt`) was greped to anchor each piece;
|
||||
the spine introduces **no new wire format** (opcodes are B.2/C), so the workflow's
|
||||
grep→cross-ref→pseudocode step applies to the *event-chain semantics* and
|
||||
`InqDropIconInfo`, both confirmed below.
|
||||
|
||||
### 2.1 The retail chain (deep-dive §5.1–5.5)
|
||||
- The cell `UIElement_UIItem` is BOTH drag source and drop target. On
|
||||
left-press-and-move it walks to its parent `UIElement_ItemList` and calls
|
||||
`ItemList_BeginDrag` (`ListenToElementMessage` decomp 229344, msg `0x21`).
|
||||
- Every cell is a drop target *by construction* — `PostInit` sets the
|
||||
`CatchDroppedItem` attribute `0x36` true (decomp 229744).
|
||||
- On drag-over the cell forwards to `ItemList_DragOver`; the LIST routes to its
|
||||
registered `m_dragHandler` (`RegisterItemListDragHandler`, decomp 230461;
|
||||
confirmed at acclient 0x004a539e + the gmToolbarUI block 0x004bdd89).
|
||||
- `InqDropIconInfo(dragElement, &objId, &containerId, &flags)` reads the dragged
|
||||
element's properties (decomp 230533); the **flags** word (confirmed live at
|
||||
`gmToolbarUI` 0x004bd162 / 0x004bd1af): **`flags & 0xE == 0`** ⇒ fresh-from-
|
||||
inventory; **`flags & 4`** ⇒ within-list reorder. Accept/reject overlay state ids:
|
||||
neutral `0x1000003f`, **reject `0x10000040`**, accept `0x10000041`
|
||||
(`SetDragAcceptState`, confirmed 0x004bd16d).
|
||||
- The drag ghost `m_dragIcon` (id `0x10000345`) is a root-level translucent copy of
|
||||
the icon, tracking the cursor (decomp 229738; §5.5).
|
||||
|
||||
### 2.2 How the four picks map (all confirmed with the user)
|
||||
| # | Decision | Pick | Retail basis |
|
||||
|---|---|---|---|
|
||||
| 1 | Payload shape | typed `ItemDragPayload` record, snapshotted at begin | mirrors `InqDropIconInfo`'s {objId, container, flags}; our `flags` derived at drop from SourceKind+target |
|
||||
| 2 | Ghost render | painted by `UiRoot` via a generic `GetDragGhost()` hook | retail's root-level `m_dragElement` floats above all windows |
|
||||
| 3 | Drop unit | **cell** hits + shows overlay; **list** owns the handler | retail cell→`ItemList_DragOver`→`m_dragHandler` |
|
||||
| 4 | PR scope | infra + a **visible toolbar stub** handler | so the ghost/overlay/dispatch are confirmable now; wire is B.2 |
|
||||
|
||||
---
|
||||
|
||||
## 3. Architecture — components & boundaries
|
||||
|
||||
All code lives in `src/AcDream.App/UI/` (the retail UI tree; NOT the ImGui devtools
|
||||
path). No new project references; `UiRoot` stays item-agnostic (it learns the
|
||||
payload/ghost only through two `UiElement` virtuals).
|
||||
|
||||
```
|
||||
Silk.NET mouse ─► UiRoot (device drag machine; UNCHANGED chain, + payload pull + ghost)
|
||||
│ BeginDrag(source): payload = source.GetDragPayload(); cancel if null
|
||||
│ Draw(): paint source.GetDragGhost() at the cursor (overlay layer)
|
||||
▼
|
||||
UiItemSlot (the CELL — drag source + drop target + overlay owner)
|
||||
│ GetDragPayload() → ItemDragPayload | null (null = empty cell, no drag)
|
||||
│ GetDragGhost() → (tex,w,h) | null
|
||||
│ OnEvent: Click→use; DragEnter→ask handler→overlay; DropReleased→dispatch
|
||||
▼ (walks Parent to)
|
||||
UiItemList (the GRID — owns the registered handler)
|
||||
│ DragHandler : IItemListDragHandler
|
||||
▼
|
||||
IItemListDragHandler (a panel controller implements this)
|
||||
bool OnDragOver(list, cell, payload) → accept/reject (overlay only)
|
||||
void HandleDropRelease(list, cell, payload) → the action (wire = per panel)
|
||||
▲
|
||||
ToolbarController (registers itself; STUB this stream — logs, no wire = TS-33)
|
||||
```
|
||||
|
||||
**Boundary rules honored:** Rule 1 (no fat feature body in `GameWindow` — all logic
|
||||
in the UI classes + the controller); Rule 3 (panels target the toolkit, never a
|
||||
backend); `UiRoot` depends on nothing item-specific (Rule-2-spirit: the generic
|
||||
toolkit core doesn't reach up into the item layer — it pulls through virtuals).
|
||||
|
||||
---
|
||||
|
||||
## 4. New & changed types (precise signatures)
|
||||
|
||||
### 4.1 NEW — `ItemDragPayload` + `ItemDragSource` (`UI/ItemDragPayload.cs`)
|
||||
```csharp
|
||||
namespace AcDream.App.UI;
|
||||
|
||||
/// <summary>Where a dragged item came from — the retail InqDropIconInfo flag
|
||||
/// distinction (flags&0xE==0 fresh-from-inventory vs flags&4 within-list reorder)
|
||||
/// expressed as a typed enum. The drop handler maps SourceKind+target back to the
|
||||
/// fresh-vs-reorder decision. Decomp: gmToolbarUI 0x004bd162 / 0x004bd1af.</summary>
|
||||
public enum ItemDragSource { Inventory, ShortcutBar, Equipment, Ground }
|
||||
|
||||
/// <summary>Snapshot of a drag-in-progress, taken at drag-begin (so a server move
|
||||
/// arriving mid-drag can't mutate it under us). Port of retail's m_dragElement +
|
||||
/// InqDropIconInfo out-params (objId/container/flags, decomp 230533).
|
||||
/// SourceContainer is NOT stored — the handler resolves the LIVE container from
|
||||
/// ClientObjectTable.Get(ObjId).ContainerId at drop (single source of truth; same
|
||||
/// value retail reads off the element, see §7).</summary>
|
||||
public sealed record ItemDragPayload(
|
||||
uint ObjId, // dragged weenie guid (retail itemID, +0x5FC)
|
||||
ItemDragSource SourceKind, // what kind of slot it left
|
||||
int SourceSlot, // the source cell's SlotIndex (retail m_lastShortcutNumDragged)
|
||||
UiItemSlot SourceCell); // back-ref: reorder-restore / clear source state / ghost texture
|
||||
```
|
||||
|
||||
### 4.2 CHANGED — `UiElement` (two new virtuals; default null)
|
||||
```csharp
|
||||
/// <summary>The data this element carries when a drag begins. UiRoot.BeginDrag pulls
|
||||
/// this; a NULL return CANCELS the drag (retail: ItemList_BeginDrag only arms an
|
||||
/// occupied cell). Default null = not draggable.</summary>
|
||||
public virtual object? GetDragPayload() => null;
|
||||
|
||||
/// <summary>The texture UiRoot paints at the cursor while this element is the drag
|
||||
/// source: (GL handle, width, height). Null = no ghost. Keeps UiRoot item-agnostic.</summary>
|
||||
public virtual (uint tex, int w, int h)? GetDragGhost() => null;
|
||||
```
|
||||
|
||||
### 4.3 CHANGED — `UiRoot`
|
||||
- `BeginDrag(UiElement source)` (drop the `payload` param): pull
|
||||
`var payload = source.GetDragPayload();` → if `null`, set `_dragCandidate = false`
|
||||
and **return without arming** (no `DragSource`, no event). Else set
|
||||
`DragSource`/`DragPayload`, fire `DragBegin` with the payload. Call site (line 188)
|
||||
becomes `BeginDrag(Captured);`.
|
||||
- `Draw(ctx)`: after `DrawOverlays`, still inside the overlay layer, draw the ghost:
|
||||
```csharp
|
||||
if (DragSource?.GetDragGhost() is { tex: var t, w: var gw, h: var gh } && t != 0)
|
||||
ctx.DrawSprite(t, MouseX - gw/2f, MouseY - gh/2f, gw, gh, 0,0,1,1,
|
||||
new Vector4(1,1,1, GhostAlpha)); // GhostAlpha = 0.6f
|
||||
```
|
||||
Root transform is at origin during `Draw`, so cursor coords are absolute. The
|
||||
ghost is NOT a tree element → it never intercepts hit-tests.
|
||||
|
||||
### 4.4 CHANGED — `UiItemSlot`
|
||||
- Add `public int SlotIndex { get; set; } = -1;` (the cell's own index within its
|
||||
panel — 0..17 toolbar, container slot for inventory; distinct from `ShortcutNum`,
|
||||
the 1–9 LABEL which is -1 on the bottom row).
|
||||
- Add `public ItemDragSource SourceKind { get; set; } = ItemDragSource.Inventory;`
|
||||
(controller overrides; toolbar = `ShortcutBar`).
|
||||
- Add drag-accept overlay sprites (confirmed ids; configurable; guard `id != 0`
|
||||
before resolving — the magenta footgun):
|
||||
```csharp
|
||||
public uint DragAcceptSprite { get; set; } = 0x060011F9u; // ItemSlot_DragOver_Accept
|
||||
public uint DragRejectSprite { get; set; } = 0x060011F8u; // ItemSlot_DragOver_Reject
|
||||
private enum DragAccept { None, Accept, Reject }
|
||||
private DragAccept _dragAccept = DragAccept.None;
|
||||
```
|
||||
- `GetDragPayload()` → `ItemId != 0 ? new ItemDragPayload(ItemId, SourceKind, SlotIndex, this) : null`.
|
||||
- `GetDragGhost()` → `ItemId != 0 && IconTexture != 0 ? (IconTexture, (int)Width, (int)Height) : null`.
|
||||
- **Move use from MouseDown → Click** (the disambiguation fix): `OnEvent` keeps
|
||||
`MouseDown` → `return true` (still consume the press, but DON'T fire use), and adds
|
||||
`Click` (0x01) → `Clicked?.Invoke(); return true`. (`UiRoot` already suppresses the
|
||||
post-drag Click at line 311, so a completed drag won't also use the item; a
|
||||
press-release-without-move still emits Click → use.) `WireClick` in
|
||||
`ToolbarController` is unchanged — it still assigns `Clicked`.
|
||||
- `OnEvent` new cases (acdream's UiEventType is the contract — these are acdream's
|
||||
DragEnter/DragOver/DropReleased, NOT retail's raw per-cell 0x21/0x3e/0x15 codes;
|
||||
see §6 note):
|
||||
```csharp
|
||||
case UiEventType.DragBegin: return true; // we're the source; payload already pulled
|
||||
case UiEventType.DragEnter: // pointer entered me mid-drag
|
||||
_dragAccept = (FindList() is {} l && l.DragHandler is {} h
|
||||
&& e.Payload is ItemDragPayload p && h.OnDragOver(l, this, p))
|
||||
? DragAccept.Accept : DragAccept.Reject;
|
||||
return true;
|
||||
case UiEventType.DragOver: _dragAccept = DragAccept.None; return true; // UiRoot fires on LEAVE
|
||||
case UiEventType.DropReleased:
|
||||
_dragAccept = DragAccept.None;
|
||||
if (e.Data0 == 1 // accepted = dropped on a DIFFERENT element (skips self/empty)
|
||||
&& FindList() is {} dl && dl.DragHandler is {} dh
|
||||
&& e.Payload is ItemDragPayload dp)
|
||||
dh.HandleDropRelease(dl, this, dp);
|
||||
return true;
|
||||
```
|
||||
`FindList()` walks `Parent` until a `UiItemList`. The accept/reject overlay draws
|
||||
in `OnDraw` on top of the icon+digit, `id != 0` guarded.
|
||||
|
||||
### 4.5 NEW — `IItemListDragHandler` (`UI/IItemListDragHandler.cs`)
|
||||
```csharp
|
||||
/// <summary>A panel controller implements this and registers itself on each of its
|
||||
/// UiItemLists. Port of retail's m_dragHandler vtable (RegisterItemListDragHandler,
|
||||
/// decomp 230461). OnDragOver decides the accept/reject OVERLAY only (advisory);
|
||||
/// HandleDropRelease is authoritative — it does the action (or no-ops to reject).</summary>
|
||||
public interface IItemListDragHandler
|
||||
{
|
||||
bool OnDragOver(UiItemList targetList, UiItemSlot targetCell, ItemDragPayload payload);
|
||||
void HandleDropRelease(UiItemList targetList, UiItemSlot targetCell, ItemDragPayload payload);
|
||||
}
|
||||
```
|
||||
|
||||
### 4.6 CHANGED — `UiItemList`
|
||||
- Add `public IItemListDragHandler? DragHandler { get; private set; }` +
|
||||
`public void RegisterDragHandler(IItemListDragHandler h) => DragHandler = h;`.
|
||||
|
||||
### 4.7 CHANGED — `ToolbarController` (the stub handler — TS-33)
|
||||
- `: IItemListDragHandler`. In the ctor, after caching slots, set each cell's
|
||||
`SlotIndex = i` and `SourceKind = ItemDragSource.ShortcutBar`, and
|
||||
`list.RegisterDragHandler(this)`.
|
||||
- `OnDragOver` → `payload.ObjId != 0` (accept any real item; retail's
|
||||
`IsShortcutEligible` gate is B.2).
|
||||
- `HandleDropRelease` → `Console.WriteLine($"[B.2 TODO] drop obj 0x{payload.ObjId:X8} " +
|
||||
$"from {payload.SourceKind} slot {payload.SourceSlot} → toolbar slot {targetCell.SlotIndex}");`
|
||||
No store mutation, no wire. (TS-33; replaced wholesale in B.2.)
|
||||
|
||||
---
|
||||
|
||||
## 5. Data flow — frame by frame
|
||||
|
||||
1. **Press** on an occupied toolbar slot → `UiRoot.OnMouseDown` sets `Captured = cell`,
|
||||
`_dragCandidate = true` (cell isn't `CapturesPointerDrag`, isn't a window-drag).
|
||||
2. **Move > 3 px** → `UiRoot.OnMouseMove` calls `BeginDrag(cell)` → pulls
|
||||
`cell.GetDragPayload()` = `ItemDragPayload{ObjId, ShortcutBar, srcSlot, cell}`
|
||||
(non-null since occupied) → arms `DragSource`/`DragPayload`, fires `DragBegin`.
|
||||
3. **Each move while dragging** → `UpdateDragHover(x,y)` hit-tests; on target CHANGE
|
||||
it fires `DragOver` (LEAVE) on the old cell → its overlay resets to neutral, and
|
||||
`DragEnter` on the new cell → it asks its list's handler `OnDragOver` → overlay
|
||||
flips accept/reject. Meanwhile `UiRoot.Draw` paints the ghost at the cursor.
|
||||
4. **Release** → `UiRoot.OnMouseUp`: `DragSource` set → `FinishDrag(x,y)` delivers
|
||||
`DropReleased` to the cell under the cursor (`Data0 = 1` if a different element).
|
||||
The cell → its list's `HandleDropRelease(list, cell, payload)` → (stub) logs.
|
||||
`DragSource`/`DragPayload` cleared → ghost stops. Capture released.
|
||||
5. **Empty-slot drag attempt** → step 2 `GetDragPayload()` returns null →
|
||||
`_dragCandidate=false`, nothing armed; the eventual mouse-up emits Click (no-op,
|
||||
`Clicked` guards `ItemId != 0`).
|
||||
|
||||
---
|
||||
|
||||
## 6. Edge cases & notes
|
||||
|
||||
- **Event-id namespace.** acdream's `UiEventType` (DragBegin 0x15, DragEnter 0x21,
|
||||
DragOver 0x1C, DropReleased 0x3E) is the contract the cell handles — these are
|
||||
`UiRoot`'s constants and differ from retail's raw per-cell `ListenToElementMessage`
|
||||
codes (0x21 begin / 0x3e over / 0x15 drop). Do NOT try to match the retail per-cell
|
||||
codes; the SEMANTICS match, the numbers are `UiRoot`'s. (IA-12 already covers "UI
|
||||
toolkit mirrors behavior, not byte-layout.")
|
||||
- **Hover re-eval cadence.** `UiRoot.UpdateDragHover` early-returns when the target
|
||||
is unchanged, so accept/reject is computed once per cell-enter, not every move.
|
||||
For item cells the decision is constant per (cell, payload), so this is
|
||||
behavior-equivalent to retail's per-move `MouseOverTop` — no register row.
|
||||
- **Self-drop / drop-on-nothing.** `FinishDrag` sets `Data0=0` when the target is the
|
||||
source or null; the cell gates `HandleDropRelease` on `Data0==1`, so both are clean
|
||||
no-ops (= "put it back").
|
||||
- **Magenta footgun.** `DragAcceptSprite`/`DragRejectSprite` default non-zero; the
|
||||
`OnDraw` overlay still guards `if (id != 0)` before `SpriteResolve`, never on the
|
||||
returned GL handle (`feedback_ui_resolve_zero_magenta`).
|
||||
- **No heavy diagnostics in the render loop.** The stub's `Console.WriteLine` fires
|
||||
only on a discrete drop event (not per frame), so it can't eat dt-based animations.
|
||||
|
||||
---
|
||||
|
||||
## 7. Divergence register rows (add in the IMPLEMENTATION commit)
|
||||
|
||||
- **AP-47** (Documented approximation): *Drag ghost reuses the full composited
|
||||
`m_pIcon` at reduced alpha (`GhostAlpha=0.6`) instead of retail's dedicated
|
||||
`m_pDragIcon` (base + custom-overlay, NO underlay).* Where: `UiRoot.Draw` +
|
||||
`UiItemSlot.GetDragGhost`. Why safe: cosmetic only — the dragged item is still
|
||||
unambiguously identifiable; building the second composite is deferred polish. Risk:
|
||||
the ghost shows the opaque type-default underlay backing rather than retail's
|
||||
underlay-less translucent copy. Oracle: `IconData::RenderIcons` 407594-407625
|
||||
(m_pDragIcon path); deep-dive §3.2/§5.5.
|
||||
- **TS-33** (Temporary stopgap): *Toolbar `IItemListDragHandler.HandleDropRelease`
|
||||
is a logging stub — no `AddShortcut 0x019C`/`RemoveShortcut 0x019D` wire, no
|
||||
`ShortcutStore` mutation.* Where: `ToolbarController.HandleDropRelease`. Awaiting:
|
||||
Stream B.2. Risk: a drop onto the bar visibly does nothing but log; reorder/add/
|
||||
remove are inert until B.2. Oracle: `gmToolbarUI::HandleDropRelease`
|
||||
acclient_2013_pseudo_c.txt:197971.
|
||||
|
||||
**Note (not a register row):** the payload omits `SourceContainer`; the handler
|
||||
resolves the live container via `ClientObjectTable.Get(ObjId).ContainerId` at drop.
|
||||
Same container id retail reads off the dragged element — single source of truth, no
|
||||
stale snapshot → no observable behavior deviation.
|
||||
|
||||
---
|
||||
|
||||
## 8. Testing (conformance)
|
||||
|
||||
App-layer tests in `tests/AcDream.App.Tests/UI/` (no GL — pure logic):
|
||||
1. `GetDragPayload` returns null for an empty cell, a correct `ItemDragPayload` for a
|
||||
bound cell (ObjId/SourceKind/SourceSlot/SourceCell).
|
||||
2. `BeginDrag` does NOT arm (`DragSource == null`) when the source's payload is null;
|
||||
arms + fires `DragBegin` when non-null.
|
||||
3. Drop dispatch: a fake `IItemListDragHandler` registered on a `UiItemList` receives
|
||||
`OnDragOver` on DragEnter and `HandleDropRelease` on DropReleased with the right
|
||||
`(list, cell, payload)`; NOT called on a `Data0==0` self/empty drop.
|
||||
4. Accept/reject overlay state flips to Accept when the handler returns true, Reject
|
||||
when false, None on leave.
|
||||
5. Use-vs-drag: a Click (no intervening drag) fires `Clicked`; a completed drag does
|
||||
not (drive `UiRoot` MouseDown→move>3px→MouseUp and assert `Clicked` didn't fire).
|
||||
6. `FindList()` resolves the parent `UiItemList` through the cell's `Parent` chain.
|
||||
|
||||
Drive these through `UiRoot`'s public `OnMouseDown/OnMouseMove/OnMouseUp` where
|
||||
possible (integration-level), so the device chain is exercised, not just the cell in
|
||||
isolation.
|
||||
|
||||
---
|
||||
|
||||
## 9. Acceptance criteria
|
||||
|
||||
- [ ] `dotnet build` green; `dotnet test` green (existing + the new §8 tests).
|
||||
- [ ] `UiRoot` has zero compile-time dependency on `UiItemSlot`/`ItemDragPayload`
|
||||
(item-agnostic; only the two `UiElement` virtuals bridge).
|
||||
- [ ] AP-47 + TS-33 rows added in the implementation commit; counts bumped.
|
||||
- [ ] **Visual (user):** with `ACDREAM_RETAIL_UI=1`, in-world, grab a hotbar item
|
||||
and drag it: a translucent ghost follows the cursor; the hovered slot shows the
|
||||
accept (green) frame, a slot that rejects shows red; on release over another
|
||||
slot the log prints `[B.2 TODO] drop obj … → toolbar slot N`; the source item
|
||||
stays put (no wire yet). A plain click still USES the item; a drag does not.
|
||||
- [ ] Memory updated if a durable lesson emerged; roadmap/ISSUES note if needed.
|
||||
|
||||
---
|
||||
|
||||
## 10. Subagent task slices (for the plan)
|
||||
|
||||
1. **Toolkit core** — `ItemDragPayload`/`ItemDragSource`, the two `UiElement`
|
||||
virtuals, `UiRoot.BeginDrag` payload pull + cancel, `UiRoot.Draw` ghost.
|
||||
2. **Cell hooks** — `UiItemSlot`: `SlotIndex`/`SourceKind`, `GetDragPayload`/
|
||||
`GetDragGhost`, MouseDown→Click move, the DragEnter/Over/DropReleased cases +
|
||||
accept/reject overlay; `IItemListDragHandler`; `UiItemList.DragHandler`.
|
||||
3. **Toolbar stub + wiring** — `ToolbarController : IItemListDragHandler`, register +
|
||||
set SlotIndex/SourceKind, the logging `HandleDropRelease`; register rows.
|
||||
4. **Tests** — the §8 suite.
|
||||
|
||||
(Slices 1→2 are sequential; 3 depends on 2; 4 can follow each.)
|
||||
|
|
@ -0,0 +1,235 @@
|
|||
# Design — D.2b Sub-phase B-Grid (inventory sub-window mount + UiItemList grid mode)
|
||||
|
||||
**Date:** 2026-06-20
|
||||
**Phase:** D.2b retail-UI arc, Sub-phase **B** (inventory window), step **B-Grid** — the first of
|
||||
four (B-Grid → B-Controller → B-Wire → B-Drag). Decomposition approved 2026-06-20.
|
||||
**Status:** approved design shape, dat-grounded, pre-implementation.
|
||||
**Predecessor:** Sub-phase A (window manager) shipped; F12 toggles a hidden placeholder window
|
||||
registered as `WindowNames.Inventory`. B-Grid makes that window show the real nested frame.
|
||||
|
||||
---
|
||||
|
||||
## 1. Context & goal
|
||||
|
||||
B-Grid makes `LayoutImporter.Import(0x21000023)` (gmInventoryUI) produce the **full nested
|
||||
inventory frame** and gives `UiItemList` an **N-cell grid** so item cells tile. After B-Grid,
|
||||
F12 shows the real bordered inventory frame — outer chrome + a backpack strip + a 3D-items area
|
||||
+ an (empty) paperdoll panel — replacing the placeholder. Cells are empty until B-Controller
|
||||
populates them; paperdoll *content* is Sub-phase C.
|
||||
|
||||
**Goal is structural, not interactive:** the frame renders with the correct nested geometry and
|
||||
a working grid widget. No wire, no controller, no drag (those are B-Wire / B-Controller / B-Drag).
|
||||
|
||||
---
|
||||
|
||||
## 2. The dat-grounded finding (why this design)
|
||||
|
||||
Dumping `LayoutDesc 0x21000023` (via `AcDream.Cli dump-vitals-layout`) overturned the research
|
||||
agent's "game-class Type + recursive Import" model. The actual structure:
|
||||
|
||||
- Root `0x100001CC`, Type `0x10000023` (gmInventoryUI), 300×362. Children:
|
||||
- `0x100001CD` paperdoll panel — **Type 0**, `BaseElement 0x100001D4`, **`BaseLayoutId 0x21000024`**, childless, **no own media**, 224×214 @ (0,23)
|
||||
- `0x100001CE` backpack panel — **Type 0**, `BaseElement 0x100001C8`, **`BaseLayoutId 0x21000022`**, childless, **no own media**, 61×339 @ (239,23)
|
||||
- `0x100001CF` 3D-items panel — **Type 0**, `BaseElement 0x100001C4`, **`BaseLayoutId 0x21000021`**, childless, **no own media**, 234×120 @ (0,237)
|
||||
- `0x100001D2` close button — Type 0, inherits, **has own media** (Normal/Normal_pressed)
|
||||
- `0x100001D3` title — Type 0, inherits, **has own media** (DirectState)
|
||||
- `0x100001D0` backdrop, `0x100001D1` bottom rule — Type 3, `BaseElement 0`, own media
|
||||
|
||||
So the three panels are **Type-0 pure-container leaves that nest via the existing
|
||||
`BaseElement`+`BaseLayoutId` inheritance path** — *not* game-class Types. Their content lives in
|
||||
the separate layouts `0x21000024/22/21`.
|
||||
|
||||
`LayoutImporter.Resolve` already loads `BaseLayoutId`, finds `BaseElement`, recurses it, and
|
||||
`Merge`s — **but** `ElementReader.Merge` (`ElementReader.cs:162`) sets
|
||||
`Children = new List(derived.Children)`, dropping the base's children. So today the panels import
|
||||
as **empty containers**. The fix is a surgical attach of the base's resolved children to the
|
||||
panel — not a new Type-dispatch mechanism.
|
||||
|
||||
---
|
||||
|
||||
## 3. Scope
|
||||
|
||||
**In scope (B-Grid):**
|
||||
- **Sub-window mount** — `LayoutImporter.Resolve` attaches a base element's resolved children to
|
||||
a childless, media-less inheriting element (the panels), so `Import(0x21000023)` yields the
|
||||
full nested tree.
|
||||
- **`UiItemList` grid mode** — column count + cell pitch so `AddItem`/`OnDraw` tile cells in a
|
||||
grid; single-cell (toolbar) behavior preserved.
|
||||
- Unit tests; a regression guard that vitals/chat/toolbar import unchanged.
|
||||
|
||||
**Out of scope (deferred):**
|
||||
- Populating cells from `ClientObjectTable`, the burden meter, find-by-id binding → **B-Controller**.
|
||||
- Inventory wire gaps (`DropItem`/`ViewContents`/…) → **B-Wire**.
|
||||
- Inventory cell as a drag source → **B-Drag**.
|
||||
- Paperdoll *content* rendering (the `UiViewport` doll) → **Sub-phase C**. (B-Grid's mount will
|
||||
pull in the paperdoll panel's equip-slot frames as ordinary elements, but the 3D doll viewport
|
||||
is C.)
|
||||
- `0x10000032` (UiItemSlot) factory registration — **not needed**: `UiItemList.ConsumesDatChildren`
|
||||
is true, so the importer skips cell templates; cells are built procedurally.
|
||||
- Cell pitch *values* + exact column counts per panel — read by **B-Controller** from the nested
|
||||
layouts at bind time. B-Grid supplies the mechanism, not the numbers.
|
||||
|
||||
---
|
||||
|
||||
## 4. Component 1 — sub-window mount (`LayoutImporter.Resolve`)
|
||||
|
||||
Restructure `Resolve` to capture the base's children and attach them when the derived element is a
|
||||
pure container that inherits from a base with content:
|
||||
|
||||
```csharp
|
||||
private static ElementInfo Resolve(
|
||||
DatCollection dats, ElementDesc d, HashSet<(uint, uint)> baseChain)
|
||||
{
|
||||
var self = ToInfo(d);
|
||||
var result = self;
|
||||
List<ElementInfo>? baseChildren = null;
|
||||
|
||||
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)
|
||||
{
|
||||
var baseInfo = Resolve(dats, baseDesc, baseChain);
|
||||
result = ElementReader.Merge(baseInfo, self);
|
||||
baseChildren = baseInfo.Children; // capture the base's resolved subtree
|
||||
}
|
||||
}
|
||||
|
||||
// Derived's own children (authoritative when present).
|
||||
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 AND no own media) that inherits
|
||||
// from a base WITH content attaches the base's subtree. This targets the gmInventoryUI
|
||||
// panels (0x100001CD/CE/CF — Type-0, media-less, childless, BaseLayoutId → a gm*UI window)
|
||||
// and is inert for: media-bearing inheritors (close button/title keep their own media,
|
||||
// so self.StateMedia is non-empty → skipped), normal elements with their own children
|
||||
// (result.Children already populated → skipped), and childless style-prototype inheritors
|
||||
// (vitals/chat/toolbar text — the base prototype has no children → baseChildren empty).
|
||||
if (LayoutImporter.ShouldMountBaseChildren(d.Children.Count, self.StateMedia.Count, baseChildren?.Count ?? 0))
|
||||
result.Children.AddRange(baseChildren!);
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/// <summary>True when a pure-container leaf should inherit its base's subtree (sub-window mount):
|
||||
/// the derived element has no own children, no own state media, and the base resolved to ≥1 child.</summary>
|
||||
internal static bool ShouldMountBaseChildren(int derivedChildCount, int derivedMediaCount, int baseChildCount)
|
||||
=> derivedChildCount == 0 && derivedMediaCount == 0 && baseChildCount > 0;
|
||||
```
|
||||
|
||||
The predicate is extracted as `ShouldMountBaseChildren` so it is **unit-testable without dats**.
|
||||
|
||||
**Coordinate correctness:** the panel inherits the base window-root's fields via `Merge` (so it
|
||||
gets that window's size) and the base's children via the mount. The children's X/Y are relative to
|
||||
the panel; `ScreenPosition` composes parent offsets, so a child at panel-local (x,y) with the panel
|
||||
at (0,23) lands at the right screen pixel.
|
||||
|
||||
**Cycle safety:** `baseChain` already guards the inheritance recursion; the nested layouts are
|
||||
distinct ids, so no cycle.
|
||||
|
||||
---
|
||||
|
||||
## 5. Component 2 — `UiItemList` grid mode
|
||||
|
||||
Add a column count + cell pitch; keep single-cell as the default.
|
||||
|
||||
```csharp
|
||||
public int Columns { get; set; } = 1; // grid columns; 1 = single column
|
||||
public float CellWidth { get; set; } // 0 = "fill the list" (single-cell legacy)
|
||||
public float CellHeight { get; set; }
|
||||
```
|
||||
|
||||
Layout rule (applied in `AddItem` and re-applied in `OnDraw` so a list resize reflows):
|
||||
|
||||
- **Fill mode** (`CellWidth <= 0`): the single cell fills the list — `cell[0]` at
|
||||
`(0, 0, Width, Height)`. Unchanged toolbar behavior.
|
||||
- **Grid mode** (`CellWidth > 0`): `cell[i]` at `(col·CellWidth, row·CellHeight, CellWidth,
|
||||
CellHeight)` where `col = i % Columns`, `row = i / Columns`.
|
||||
|
||||
The toolbar constructor keeps adding its one cell with `CellWidth = 0` (defaults), so it stays in
|
||||
fill mode. B-Controller sets `Columns` + `CellWidth/CellHeight` (read from the nested layout's
|
||||
ItemList element + cell template) and `Flush()` + `AddItem()`s the grid cells.
|
||||
|
||||
The grid math is a pure helper for testing:
|
||||
|
||||
```csharp
|
||||
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);
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. Testing
|
||||
|
||||
**Grid mode (pure, `tests/AcDream.App.Tests/UI/`):**
|
||||
- `CellOffset(4, 3, 36, 36)` ⇒ `(36, 36)` (index 4 → col 1, row 1).
|
||||
- Build a `UiItemList { Columns = 3, CellWidth = 36, CellHeight = 36 }`, `AddItem` 7 cells, assert
|
||||
`GetItem(4)` is at `Left == 36 && Top == 36 && Width == 36`.
|
||||
- Single-cell legacy: a default `UiItemList` (CellWidth 0) keeps its cell at the list size after
|
||||
`OnDraw` — assert the existing toolbar behavior is unchanged.
|
||||
|
||||
**Mount predicate (pure):**
|
||||
- `ShouldMountBaseChildren(0, 0, 5)` ⇒ true (panel: childless, media-less, base has 5 kids).
|
||||
- `ShouldMountBaseChildren(0, 1, 5)` ⇒ false (close button: has own media).
|
||||
- `ShouldMountBaseChildren(2, 0, 5)` ⇒ false (has own children).
|
||||
- `ShouldMountBaseChildren(0, 0, 0)` ⇒ false (style prototype: base childless — vitals text).
|
||||
|
||||
**Regression guard (hard acceptance):** vitals (`0x2100006C`), chat (`0x21000006`), and toolbar
|
||||
(`0x21000016`) must import + render unchanged. Their inherited bases are childless prototypes, so
|
||||
the mount is inert for them — but this is verified, not assumed (the existing importer tests +
|
||||
the visual check).
|
||||
|
||||
---
|
||||
|
||||
## 7. Divergence register
|
||||
|
||||
**No new row.** The mount makes `BaseElement`/`BaseLayoutId` inheritance carry the base's content
|
||||
subtree, which is what retail must do for the gmInventoryUI panels to render at all (they are
|
||||
childless in the dat — the content is only reachable through the base reference). This is
|
||||
*completing* faithful inheritance, not diverging from it. IA-12 (toolkit-defined UI) already
|
||||
covers the importer's reconstruction of keystone semantics. If a future retail-decomp pass shows
|
||||
retail's child-inheritance differs in the children-present merge case (which no inventory element
|
||||
exercises), that is a separate, later concern.
|
||||
|
||||
---
|
||||
|
||||
## 8. Acceptance criteria
|
||||
|
||||
- `dotnet build` green; `dotnet test` green (grid + mount-predicate tests added).
|
||||
- `Import(0x21000023)` yields a tree where the three panels (`0x100001CD/CE/CF`) each have
|
||||
children (the nested layout content), confirmed by a dat-backed assertion or the dump.
|
||||
- Vitals/chat/toolbar import + render unchanged (regression guard).
|
||||
- With `ACDREAM_RETAIL_UI=1`, F12 shows the real nested inventory frame (chrome + backpack strip +
|
||||
3D-items area + empty paperdoll panel) instead of the blank placeholder.
|
||||
- Visual verification by the user.
|
||||
|
||||
---
|
||||
|
||||
## 9. Open / confirmed-at-build items
|
||||
|
||||
- **Cell pitch + column counts** — B-Controller reads them from the nested layouts
|
||||
(`0x21000022` backpack, `0x21000021` 3D-items) at bind time. Geometry from the outer dump
|
||||
suggests the backpack strip is ~1 column and the 3D-items panel ~6 columns of 36×36, but the
|
||||
exact values are B-Controller's to read, not B-Grid's to hardcode.
|
||||
- The placeholder mount in `GameWindow` (Sub-phase A) is replaced here: instead of a bare
|
||||
`UiNineSlicePanel`, the inventory window becomes `LayoutImporter.Import(0x21000023)`'s root,
|
||||
registered under the same `WindowNames.Inventory`. (Mechanically a few lines in the RetailUi
|
||||
block; the F12 wiring is untouched.)
|
||||
|
||||
---
|
||||
|
||||
## 10. References
|
||||
|
||||
- Dat dump: `AcDream.Cli dump-vitals-layout "<datdir>" 0x21000023` (the structure in §2).
|
||||
- Format: `docs/research/2026-06-15-layoutdesc-format.md` (§8 Type table, §10/§12 inheritance).
|
||||
- Code: `src/AcDream.App/UI/Layout/LayoutImporter.cs` (`Resolve`/`Merge` path),
|
||||
`src/AcDream.App/UI/Layout/ElementReader.cs:162` (the children-drop), `UiItemList.cs`,
|
||||
`UiItemSlot.cs` (the shipped drag spine — reused in B-Drag, untouched here).
|
||||
- Handoff/decomposition: `docs/research/2026-06-20-window-manager-inventory-handoff.md` §4;
|
||||
the Sub-phase B decomposition (B-Grid → B-Controller → B-Wire → B-Drag).
|
||||
181
docs/superpowers/specs/2026-06-20-d2b-toolbar-collapse-design.md
Normal file
181
docs/superpowers/specs/2026-06-20-d2b-toolbar-collapse-design.md
Normal file
|
|
@ -0,0 +1,181 @@
|
|||
# D.2b toolbar collapse-to-one-row — design
|
||||
|
||||
**Date:** 2026-06-20
|
||||
**Phase:** D.2b retail-UI, toolbar polish (follows D.5.3 B.1/B.2, both visually confirmed this session).
|
||||
**Branch:** `claude/hopeful-maxwell-214a12`.
|
||||
**Driver:** user request — the toolbar frame should resize vertically between **one row** (row 2 hidden,
|
||||
the minimum) and **two rows** (row 2 shown), **snapping** between the two stops.
|
||||
|
||||
---
|
||||
|
||||
## 1. Goal & non-goals
|
||||
|
||||
**Goal.** The toolbar window can be collapsed to show only the top quickslot row (slots 1–9) or
|
||||
expanded to show both rows (slots 1–18), by dragging its **bottom edge**. The drag **snaps** to the
|
||||
nearer of two height stops — collapsed (row 2 hidden) or expanded (row 2 shown). Default = expanded
|
||||
(today's look). Horizontal size stays fixed; the window still moves by grabbing empty cells / chrome
|
||||
(IA-12). This is a toolkit UX defined from the user's retail observation — the real mechanism lives in
|
||||
`keystone.dll` (no decomp); our research notes the dat just stacks two always-present rows, so the dat
|
||||
encodes no collapse. Recorded as an amendment to **IA-17** (toolbar frame is toolkit-supplied).
|
||||
|
||||
**Non-goals:** a collapse/expand BUTTON (it's a bottom-edge resize); horizontal resize; persisting the
|
||||
collapsed state across sessions (it resets to expanded each launch — persistence is the deferred
|
||||
window-manager Plan-2); animating the snap.
|
||||
|
||||
---
|
||||
|
||||
## 2. Geometry (from the layout, not hardcoded)
|
||||
|
||||
The toolbar `LayoutDesc 0x21000016` root is **300×122**; the two rows are top `0x100001A7..AF` and
|
||||
bottom `0x100006B7..BF`, with the bottom row's slots at content-y ≈ 90 (deep-dive §2a table, slot 9 at
|
||||
`6,90`). Heights are computed at mount time from the actual layout, so there is no magic constant:
|
||||
|
||||
- `border` = `RetailChromeSprites.Border` (5 px).
|
||||
- `ExpandedHeight` = `contentHeight + 2·border` (today's frame height; `contentHeight` = the imported
|
||||
root's `Height`, 122).
|
||||
- `CollapsedHeight` = `minRow2Top + 2·border`, where `minRow2Top` = the smallest `Top` among the nine
|
||||
resolved row-2 slot elements (`0x100006B7..BF`). That cuts the frame just above row 2.
|
||||
- `snapMidpoint` = `(CollapsedHeight + ExpandedHeight) / 2`.
|
||||
|
||||
---
|
||||
|
||||
## 3. Components
|
||||
|
||||
### 3.1 `UiElement` — `MaxHeight` + a `ResizableEdges` mask — `src/AcDream.App/UI/UiElement.cs`
|
||||
Today resize clamps to a minimum only and (with `ResizeY`) treats BOTH vertical edges as grips. Add two
|
||||
small generic members:
|
||||
```csharp
|
||||
/// <summary>Maximum height enforced while resizing (default unbounded). Pairs with MinHeight.</summary>
|
||||
public float MaxHeight { get; set; } = float.MaxValue;
|
||||
|
||||
/// <summary>Which edges may start a resize, beyond the ResizeX/ResizeY axis gates. Default: all.
|
||||
/// Set to e.g. ResizeEdges.Bottom to allow only a bottom-edge drag (the collapse toolbar).</summary>
|
||||
public ResizeEdges ResizableEdges { get; set; } =
|
||||
ResizeEdges.Left | ResizeEdges.Right | ResizeEdges.Top | ResizeEdges.Bottom;
|
||||
```
|
||||
(`MaxWidth` is YAGNI — only height needs it here.)
|
||||
|
||||
- **`UiRoot.HitEdges`** applies the mask at the end: `e &= w.ResizableEdges;` (after the existing
|
||||
`ResizeX`/`ResizeY` masking). So a toolbar with `ResizableEdges = Bottom` only grips its bottom edge;
|
||||
a press near the top edge falls through to window-move, not resize.
|
||||
- **`UiRoot.ResizeRect`** gains a `maxH` parameter and clamps the Bottom/Top height branches:
|
||||
`h = Math.Clamp(startH + dy, minH, maxH)` (Bottom) and the Top branch likewise. `OnMouseMove`'s resize
|
||||
call passes `_resizeTarget.MaxHeight` (and `float.MaxValue` for the width's maxW). **This changes
|
||||
`ResizeRect`'s signature — update its existing callers + the `UiRootInputTests.ResizeRect_*` tests to
|
||||
pass the new `maxW`/`maxH` args** (`float.MaxValue` where unbounded, preserving their current
|
||||
assertions).
|
||||
|
||||
### 3.2 `UiCollapsibleFrame : UiNineSlicePanel` (new) — `src/AcDream.App/UI/UiCollapsibleFrame.cs`
|
||||
A toolbar-frame variant that snaps between two heights and toggles a set of "second-row" elements.
|
||||
One clear responsibility: reconcile its height to a stop and the rows to that stop, every tick.
|
||||
```csharp
|
||||
public sealed class UiCollapsibleFrame : UiNineSlicePanel
|
||||
{
|
||||
public UiCollapsibleFrame(Func<uint,(uint,int,int)> resolveChrome) : base(resolveChrome) { }
|
||||
|
||||
public float CollapsedHeight { get; set; }
|
||||
public float ExpandedHeight { get; set; }
|
||||
/// <summary>Elements shown only when expanded (the row-2 slot lists). Hidden when collapsed.</summary>
|
||||
public IReadOnlyList<UiElement> SecondRow { get; set; } = System.Array.Empty<UiElement>();
|
||||
|
||||
/// <summary>True when the frame is currently at (or nearer) the expanded stop.</summary>
|
||||
public bool IsExpanded => Height >= (CollapsedHeight + ExpandedHeight) * 0.5f;
|
||||
|
||||
protected override void OnTick(double dt)
|
||||
{
|
||||
base.OnTick(dt);
|
||||
if (ExpandedHeight <= CollapsedHeight) return; // not configured yet
|
||||
// Snap to the nearer stop (the resize drag sets Height live; we resolve it to a stop so the
|
||||
// frame always rests collapsed or expanded — never a half-row).
|
||||
bool expanded = IsExpanded;
|
||||
Height = expanded ? ExpandedHeight : CollapsedHeight;
|
||||
// Row 2 is shown only when expanded. (No clipping needed — the dat content is top-anchored,
|
||||
// so row-2 slots simply stop drawing when hidden; row 1 never moves.)
|
||||
for (int i = 0; i < SecondRow.Count; i++) SecondRow[i].Visible = expanded;
|
||||
}
|
||||
}
|
||||
```
|
||||
Notes:
|
||||
- The snap runs in `OnTick` (after the frame's `MinHeight`/`MaxHeight`-clamped resize drag set `Height`
|
||||
that frame), so the rendered height is always a stop. With only two stops one row apart, this reads
|
||||
as: drag the bottom edge past the midpoint → it jumps to the other stop + row 2 appears/hides.
|
||||
- `IsExpanded`/the snap use the midpoint; `MinHeight`/`MaxHeight` (set by the mount) keep the drag
|
||||
within `[Collapsed, Expanded]` so the midpoint test is well-defined.
|
||||
|
||||
### 3.3 GameWindow toolbar mount — `src/AcDream.App/Rendering/GameWindow.cs` (~line 2045)
|
||||
- Build the frame as `UiCollapsibleFrame` instead of `UiNineSlicePanel` (same `ResolveChrome` ctor arg).
|
||||
- After the content (`toolbarRoot`) is sized: compute `expandedH = toolbarContentH + 2·border`,
|
||||
`collapsedH = minRow2Top + 2·border` where `minRow2Top` = `min` of the nine row-2 lists' `Top`
|
||||
(resolve each via `toolbarLayout.FindElement(0x100006B7..BF)`; reuse `ToolbarController`'s row-2 id
|
||||
list or inline the nine ids).
|
||||
- Set on the frame: `Resizable = true; ResizableEdges = ResizeEdges.Bottom` (bottom-edge only — top
|
||||
edge stays a move grip); `MinHeight = collapsedH; MaxHeight = expandedH; Height = expandedH` (default
|
||||
expanded); `CollapsedHeight = collapsedH; ExpandedHeight = expandedH; SecondRow = <the nine row-2
|
||||
elements>`. (`ResizeX`/`ResizeY` keep defaults; the `ResizableEdges = Bottom` mask is the operative
|
||||
restriction.)
|
||||
- Change `toolbarRoot.Anchors` from all-four-edges to **`Left | Top | Right`** (drop `Bottom`) so the
|
||||
dat content keeps its full height and row 1 never reflows when the frame collapses; row 2 hides via
|
||||
`Visible`. (Width is fixed — `ResizeX=false` — so the horizontal anchors are inert but harmless.)
|
||||
|
||||
---
|
||||
|
||||
## 4. Behavior walk-through
|
||||
|
||||
- **Launch:** frame at `ExpandedHeight`, both rows visible (unchanged from today).
|
||||
- **Collapse:** grab the bottom edge, drag up past the midpoint → `OnTick` snaps `Height` to
|
||||
`CollapsedHeight` and hides the nine row-2 slots. The frame is now a single-row bar; row 1 unchanged.
|
||||
- **Expand:** drag the bottom edge down past the midpoint → snaps to `ExpandedHeight`, row 2 reappears.
|
||||
- **Move:** unchanged — drag an empty cell / chrome to reposition (IA-12); occupied cells drag items
|
||||
(B.1/B.2).
|
||||
- **Edge cases:** `MinHeight`/`MaxHeight` clamp the drag to `[Collapsed, Expanded]`; the snap is
|
||||
idempotent when not dragging (Height already at a stop). The collapsed state is per-session (resets
|
||||
to expanded on relaunch).
|
||||
|
||||
---
|
||||
|
||||
## 5. Divergence register
|
||||
|
||||
**Amend IA-17** (toolbar window FRAME is toolkit-supplied): add that the frame also supports a
|
||||
toolkit-defined **collapse-to-one-row** (bottom-edge resize snapping between a row-1-only and a
|
||||
two-row height, row-2 visibility tied to the stop). Retail's real collapse mechanism is keystone.dll
|
||||
(no decomp) and the dat encodes no collapse (both rows always present) — so this is our toolkit UX from
|
||||
the user's retail observation, same justification class as the rest of IA-17. No new row; extend IA-17's
|
||||
text + cite this spec.
|
||||
|
||||
---
|
||||
|
||||
## 6. Testing
|
||||
|
||||
`tests/AcDream.App.Tests/UI/`:
|
||||
1. `UiCollapsibleFrame.OnTick` snap: set `CollapsedHeight=96`, `ExpandedHeight=128`; set `Height` just
|
||||
below the midpoint (e.g. 100) + tick → `Height == 96` and every `SecondRow` element `Visible==false`;
|
||||
set `Height` just above (e.g. 120) + tick → `Height == 128` and `SecondRow` `Visible==true`.
|
||||
2. `UiCollapsibleFrame` not-configured guard: `ExpandedHeight==CollapsedHeight==0` → `OnTick` is a
|
||||
no-op (no divide/no forced height).
|
||||
3. `UiRoot.ResizeRect` MaxHeight clamp: a Bottom-edge resize with `dy` huge clamps `h` to `maxH`;
|
||||
a Top-edge resize likewise; min still honored. (Drive via the existing `ResizeRect` static test
|
||||
pattern in `UiRootInputTests`; also update the two pre-existing `ResizeRect_*` tests to the new
|
||||
`maxW`/`maxH` signature — pass `float.MaxValue`, assertions unchanged.)
|
||||
4. `UiRoot.HitEdges` honors `ResizableEdges`: a panel with `ResizableEdges = ResizeEdges.Bottom` returns
|
||||
only `Bottom` when pressed near its bottom edge, and `None` near its top edge (which would otherwise
|
||||
be a grip with `ResizeY` true).
|
||||
|
||||
A `UiCollapsibleFrame` needs a chrome resolver in tests — pass `_ => (1u,1,1)` (the existing
|
||||
`UiNineSlicePanel` test pattern); `OnTick` doesn't draw, so no GL.
|
||||
|
||||
---
|
||||
|
||||
## 7. Acceptance
|
||||
|
||||
- [ ] `dotnet build` + `dotnet test` green.
|
||||
- [ ] IA-17 amended.
|
||||
- [ ] **Visual (user):** default shows both rows; dragging the toolbar's **bottom edge up** snaps it to
|
||||
a single row (row 2 gone); dragging **down** snaps back to two rows; row 1 never moves/squishes;
|
||||
the window still moves by dragging empty cells/chrome; item drag (B.1/B.2) still works.
|
||||
|
||||
---
|
||||
|
||||
## 8. Plan size
|
||||
|
||||
One small task (TDD): `UiElement.MaxHeight` + `ResizeRect` clamp → `UiCollapsibleFrame` + its tests →
|
||||
the GameWindow mount swap + IA-17 amend. ~3 files + 1 test file. Suitable for a single implementer pass.
|
||||
|
|
@ -0,0 +1,386 @@
|
|||
# D.2b toolbar shortcut drag interactivity (Stream B.2) — design
|
||||
|
||||
**Date:** 2026-06-20
|
||||
**Phase:** D.2b retail-UI → D.5 core panels. Stream **B.2** of the 2026-06-18 handoff — make the
|
||||
toolbar shortcut drag *functional* (reorder / remove) and *retail-faithful*, on top of the B.1
|
||||
spine (`9d48346..acdefc2`, shipped + visually confirmed this session).
|
||||
**Branch:** `claude/hopeful-maxwell-214a12`.
|
||||
**Driver:** user visual-gate feedback 2026-06-20 (6 points) + the retail research below.
|
||||
|
||||
**Read alongside:**
|
||||
- `docs/research/2026-06-16-action-bar-toolbar-deep-dive.md` §5 (HandleDropRelease/RemoveShortcut/wire).
|
||||
- `docs/research/2026-06-16-ui-item-slot-icon-dragdrop-spine-deep-dive.md` §5 (the cell drag chain).
|
||||
- `docs/superpowers/specs/2026-06-20-d2b-drag-drop-spine-design.md` (B.1 — what this extends).
|
||||
- `claude-memory/project_d2b_retail_ui.md`.
|
||||
|
||||
---
|
||||
|
||||
## 1. Goal & non-goals
|
||||
|
||||
**Goal.** A player drags a hotbar shortcut, sees the item icon lift off the slot (slot empties) and
|
||||
follow the cursor at full opacity with a green-cross drop indicator; dropping on another slot moves
|
||||
it there (reordering, bumping any occupant back to the source slot); dropping anywhere else removes
|
||||
it from the bar. All changes are sent to the server (`AddShortcut 0x019C` / `RemoveShortcut 0x019D`)
|
||||
so they persist. This realizes the user's 6 visual-gate points as retail's single **remove-on-lift /
|
||||
place-on-drop / no-restore** mechanism.
|
||||
|
||||
**In scope:**
|
||||
1. **Spine extensions** (small, to the B.1 toolkit): a drag-LIFT hook to the source's handler;
|
||||
full-opacity ghost snapshotted at drag-begin (survives the source emptying); `FinishDrag`
|
||||
delivers a drop only when released on a real element (off-bar = no drop fires).
|
||||
2. **`ShortcutStore`** — a mutable 18-slot model (port of retail `ShortCutManager::shortCuts_[18]`).
|
||||
3. **`ToolbarController` as the live drag handler** — `OnDragLift` removes; `HandleDropRelease`
|
||||
places + bumps displaced → source; both drive the store, the wire, and a re-`Populate()`.
|
||||
4. **The wire** — fix `BuildAddShortcut` param names; add `WorldSession.SendAddShortcut` /
|
||||
`SendRemoveShortcut`.
|
||||
5. **Green-cross accept overlay** (`0x060011FA`) on toolbar cells (verified against the dat).
|
||||
|
||||
**Out of scope (later streams):**
|
||||
- **Drag FROM inventory onto the bar** (the `flags & 0xE == 0` "fresh-from-inventory" branch) — needs
|
||||
the inventory window as a drag source (Stream C). This spec is reorder-within-bar + remove only
|
||||
(the `flags & 4` branch).
|
||||
- Spell shortcuts (the `SpellId|Layer` payload) — items only (`spellId=layer=0`); the store carries a
|
||||
spell field for forward-compat but the toolbar binds item guids.
|
||||
- Server-pushed shortcut mutations after login (the store is client-authoritative post-`Load`).
|
||||
- The selected-object mana meter / stack-split UI (issue #141 deferred bits).
|
||||
|
||||
---
|
||||
|
||||
## 2. Retail grounding (the model — CONFIRMED by research 2026-06-20)
|
||||
|
||||
Retail's toolbar drag is **remove-on-lift, place-on-drop, no-restore** (decomp trace, this session):
|
||||
|
||||
- **On drag-begin** (`UIElement_ItemList::ItemList_BeginDrag` 0x004e32d0 → broadcasts msg `0x21` →
|
||||
`gmToolbarUI::RecvNotice_ItemListBeginDrag` 0x004bd930): `PrepareDragIcon` makes the **full icon**
|
||||
the cursor ghost (m_dragIcon), then `gmToolbarUI::RemoveShortcut` (0x004bd450) **removes the
|
||||
shortcut**: `ItemList_Flush` → cell → empty state `0x1000001c`, `Event_RemoveShortCut` sends
|
||||
`0x019D`, and `m_lastShortcutNumDragged` = the source slot. The slot is now empty; the item is "in
|
||||
hand."
|
||||
- **On mouse-up over a slot** (`UIElementManager::StopDragandDrop` 0x00459810 → success →
|
||||
`gmToolbarUI::HandleDropRelease` 0x004be7c0): within-bar reorder branch (`flags & 4`):
|
||||
`RemoveShortcutInSlotNum(target)` (evict the occupant, get its objId) → `AddShortcut(draggedObjId,
|
||||
target)` (`0x019C`); if an item was displaced and the source slot is free, `AddShortcut(displaced,
|
||||
m_lastShortcutNumDragged)` — the bumped item lands in the vacated source slot. (deep-dive §5.)
|
||||
- **On mouse-up over nothing** (`m_pElementLastDragCursorOver == null` → success=0): no
|
||||
`HandleDropRelease`, no re-add — the shortcut stays gone (already removed + `0x019D` sent at lift).
|
||||
- **No cancel/restore path.** `gmToolbarUI` has no end-drag/restore vtable entry. Lifted-then-not-
|
||||
landed = permanently removed (server already told).
|
||||
- **Drop indicator** is a per-slot OVERLAY sprite on `m_elem_Icon_DragAccept` (`0x1000045A`), NOT a
|
||||
cursor change (`UpdateCursorState` is combat/target-only). The toolbar accept sprite is the **green
|
||||
cross `0x060011FA`** (verified by exporting the `0x060011F7..FA` family from `client_portal.dat`:
|
||||
F7=green move-arrow, F8=red ∅ reject, F9=green **ring** [inventory], FA=green **cross** [toolbar]);
|
||||
reject = `0x060011F8`.
|
||||
|
||||
**Wire (`ShortCutData`, CONFIRMED 3 refs — deep-dive §131-145):**
|
||||
- `AddShortCut 0x019C` body after the 12-byte GameAction envelope: `Index(u32), ObjectId(u32),
|
||||
SpellId(u16), Layer(u16)` (item → `ObjectId=guid, SpellId=Layer=0`).
|
||||
- `RemoveShortCut 0x019D` body: `Index(u32)`.
|
||||
- ACE handles the remove-then-add reorder pattern (`Player_Character.cs:254`).
|
||||
|
||||
---
|
||||
|
||||
## 3. Architecture — components & boundaries
|
||||
|
||||
```
|
||||
press+move on OCCUPIED slot ─► UiRoot.BeginDrag
|
||||
│ snapshot ghost (full icon) BEFORE the lift clears the source cell
|
||||
│ fire DragBegin on the source cell
|
||||
▼
|
||||
UiItemSlot.OnEvent(DragBegin) ─► FindList().DragHandler.OnDragLift(list, cell, payload)
|
||||
▼
|
||||
ToolbarController.OnDragLift ─► ShortcutStore.Remove(srcSlot); SendRemoveShortcut(srcSlot); Populate()
|
||||
(source slot now empty; ghost still shows the snapshot)
|
||||
drag-over target slot ─► UiItemSlot DragEnter ─► handler.OnDragOver → green-cross accept overlay (FA)
|
||||
release ─► UiRoot.FinishDrag:
|
||||
├─ over a slot ─► that cell.OnEvent(DropReleased) ─► handler.HandleDropRelease(target, payload)
|
||||
│ ShortcutStore place + bump displaced→srcSlot; SendAddShortcut×N; Populate()
|
||||
└─ over nothing ─► no DropReleased delivered → lift's removal stands (item gone)
|
||||
```
|
||||
|
||||
**Boundaries:** `UiRoot`/`UiElement`/`UiItemSlot`/`UiItemList` stay item-agnostic — they gain a lift
|
||||
*hook* and a ghost snapshot, nothing item-specific. `ShortcutStore` is pure logic in `AcDream.Core`
|
||||
(testable, no GL). The wire lives in `AcDream.Core.Net`. `ToolbarController` (App) orchestrates.
|
||||
Honors structure Rules 1/2/3/6.
|
||||
|
||||
---
|
||||
|
||||
## 4. New & changed types (precise)
|
||||
|
||||
### 4.1 Spine — `IItemListDragHandler` gains the lift hook (`UI/IItemListDragHandler.cs`)
|
||||
```csharp
|
||||
public interface IItemListDragHandler
|
||||
{
|
||||
/// <summary>The drag STARTED from a cell in this list — retail's
|
||||
/// RecvNotice_ItemListBeginDrag → RemoveShortcut (decomp 0x004bd930/0x004bd450): the handler
|
||||
/// removes the lifted item from its model + wire, so the source slot empties immediately. The
|
||||
/// item is "in hand" until HandleDropRelease (place) or the drag ends off-target (stays removed).</summary>
|
||||
void OnDragLift(UiItemList sourceList, UiItemSlot sourceCell, ItemDragPayload payload);
|
||||
|
||||
bool OnDragOver(UiItemList targetList, UiItemSlot targetCell, ItemDragPayload payload);
|
||||
void HandleDropRelease(UiItemList targetList, UiItemSlot targetCell, ItemDragPayload payload);
|
||||
}
|
||||
```
|
||||
|
||||
### 4.2 Spine — `UiItemSlot` (`UI/UiItemSlot.cs`)
|
||||
- `OnEvent` `DragBegin` case changes from `return true` (no-op) to: notify the list's handler so it
|
||||
can lift:
|
||||
```csharp
|
||||
case UiEventType.DragBegin:
|
||||
if (FindList() is { DragHandler: { } h } list && e.Payload is ItemDragPayload p)
|
||||
h.OnDragLift(list, this, p);
|
||||
return true;
|
||||
```
|
||||
- `DropReleased` case: drop the `e.Data0 == 1` gate — reaching the cell means a real slot was hit
|
||||
(`FinishDrag` only delivers on a hit). The handler is authoritative (place; drop-on-self re-adds):
|
||||
```csharp
|
||||
case UiEventType.DropReleased:
|
||||
_dragAccept = DragAcceptState.None;
|
||||
if (FindList() is { DragHandler: { } dh } dl && e.Payload is ItemDragPayload dp)
|
||||
dh.HandleDropRelease(dl, this, dp);
|
||||
return true;
|
||||
```
|
||||
(`DragEnter`/`DragOver` overlay handling unchanged from B.1.)
|
||||
|
||||
### 4.3 Spine — `UiRoot` (`UI/UiRoot.cs`)
|
||||
- Add `private (uint tex, int w, int h)? _dragGhost;`. In `BeginDrag`, **snapshot the ghost before
|
||||
firing `DragBegin`** (the lift will empty the source cell, so a live re-read would vanish):
|
||||
```csharp
|
||||
private void BeginDrag(UiElement source)
|
||||
{
|
||||
var payload = source.GetDragPayload();
|
||||
if (payload is null) { _dragCandidate = false; return; }
|
||||
DragSource = source;
|
||||
DragPayload = payload;
|
||||
_dragGhost = source.GetDragGhost(); // snapshot NOW — survives the source emptying
|
||||
var e = new UiEvent(source.EventId, source, UiEventType.DragBegin, Payload: payload);
|
||||
source.OnEvent(in e); // → cell → handler.OnDragLift (clears the source slot)
|
||||
}
|
||||
```
|
||||
- `DrawDragGhost` uses the snapshot, not a live read; `GhostAlpha` → **1.0** (full opacity, retail
|
||||
m_dragIcon):
|
||||
```csharp
|
||||
private const float GhostAlpha = 1.0f;
|
||||
private void DrawDragGhost(UiRenderContext ctx)
|
||||
{
|
||||
if (_dragGhost is not { } g || g.tex == 0) return;
|
||||
ctx.DrawSprite(g.tex, MouseX - g.w / 2f, MouseY - g.h / 2f, g.w, g.h,
|
||||
0f, 0f, 1f, 1f, new Vector4(1f, 1f, 1f, GhostAlpha));
|
||||
}
|
||||
```
|
||||
- `FinishDrag` delivers a drop ONLY to a real hit element; off-bar (null hit) fires nothing; clear
|
||||
the ghost:
|
||||
```csharp
|
||||
private void FinishDrag(int x, int y)
|
||||
{
|
||||
var (t, lx, ly) = HitTestTopDown(x, y);
|
||||
if (t is not null)
|
||||
{
|
||||
var e = new UiEvent(DragSource!.EventId, t, UiEventType.DropReleased,
|
||||
Data1: (int)lx, Data2: (int)ly, Payload: DragPayload);
|
||||
t.OnEvent(in e); // the hit cell's handler places; a non-item target ignores it → item stays removed
|
||||
}
|
||||
// else: dropped off any element — no drop fires; the lift's removal stands (retail).
|
||||
DragSource = null; DragPayload = null; _dragGhost = null; _lastDragHoverTarget = null;
|
||||
}
|
||||
```
|
||||
(`GetDragGhost()` on `UiElement`/`UiItemSlot` stays as-is — `BeginDrag` calls it once.)
|
||||
|
||||
### 4.4 NEW — `ShortcutStore` (`src/AcDream.Core/Items/ShortcutStore.cs`)
|
||||
Port of retail `ShortCutManager::shortCuts_[18]` (`acclient.h:36492`). Pure logic.
|
||||
```csharp
|
||||
public sealed class ShortcutStore
|
||||
{
|
||||
public const int SlotCount = 18;
|
||||
private readonly uint[] _objIds = new uint[SlotCount]; // 0 = empty
|
||||
|
||||
/// <summary>Replace all slots from the login PlayerDescription shortcut list.</summary>
|
||||
public void Load(IReadOnlyList<PlayerDescriptionParser.ShortcutEntry> entries)
|
||||
{
|
||||
System.Array.Clear(_objIds);
|
||||
foreach (var e in entries)
|
||||
if (e.Index < SlotCount && e.ObjectGuid != 0) _objIds[e.Index] = e.ObjectGuid;
|
||||
}
|
||||
|
||||
public uint Get(int slot) => (uint)slot < SlotCount ? _objIds[slot] : 0u;
|
||||
public bool IsEmpty(int slot) => Get(slot) == 0u;
|
||||
public void Set(int slot, uint objId) { if ((uint)slot < SlotCount) _objIds[slot] = objId; }
|
||||
public void Remove(int slot) { if ((uint)slot < SlotCount) _objIds[slot] = 0u; }
|
||||
}
|
||||
```
|
||||
|
||||
### 4.5 Wire — `InventoryActions.BuildAddShortcut` rename (`Core.Net/Messages/InventoryActions.cs`)
|
||||
Byte layout is already correct; FIX the misleading param names (handoff §B.2; deep-dive §131-145):
|
||||
```csharp
|
||||
/// <summary>Pin an item/spell to a quickbar slot. ShortCutData = Index(u32),ObjectId(u32),
|
||||
/// SpellId(u16),Layer(u16). For an item: objectGuid + spellId=layer=0.</summary>
|
||||
public static byte[] BuildAddShortcut(uint seq, uint index, uint objectGuid, ushort spellId, ushort layer)
|
||||
{
|
||||
byte[] body = new byte[24];
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(body, GameActionEnvelope);
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(4), seq);
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(8), AddShortcutOpcode); // 0x019C
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(12), index);
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(16), objectGuid);
|
||||
BinaryPrimitives.WriteUInt16LittleEndian(body.AsSpan(20), spellId);
|
||||
BinaryPrimitives.WriteUInt16LittleEndian(body.AsSpan(22), layer);
|
||||
return body;
|
||||
}
|
||||
```
|
||||
(`BuildRemoveShortcut(seq, slotIndex)` is correct — unchanged.) Any existing caller of the old
|
||||
4-arg `BuildAddShortcut` must be updated; grep first (likely none wired today).
|
||||
|
||||
### 4.6 Wire — `WorldSession` sends (`Core.Net/WorldSession.cs`, mirror `SendChangeCombatMode` :1134)
|
||||
```csharp
|
||||
public void SendAddShortcut(uint index, uint objectGuid, ushort spellId = 0, ushort layer = 0)
|
||||
{
|
||||
uint seq = NextGameActionSequence();
|
||||
SendGameAction(InventoryActions.BuildAddShortcut(seq, index, objectGuid, spellId, layer));
|
||||
}
|
||||
public void SendRemoveShortcut(uint index)
|
||||
{
|
||||
uint seq = NextGameActionSequence();
|
||||
SendGameAction(InventoryActions.BuildRemoveShortcut(seq, index));
|
||||
}
|
||||
```
|
||||
|
||||
### 4.7 `ToolbarController` — the live handler (`UI/Layout/ToolbarController.cs`)
|
||||
- Holds a `ShortcutStore _store`. `Populate()` reads `_store` (18 slots → `repo.Get(objId)` → icon)
|
||||
INSTEAD of the read-only `_shortcuts()` provider. **Lazy-load-once:** the PlayerDescription shortcut
|
||||
list arrives AFTER Bind (at login), so load the store the first time `_shortcuts()` is non-empty,
|
||||
then treat it as authoritative:
|
||||
```csharp
|
||||
// top of Populate(), before rendering:
|
||||
if (!_storeLoaded && _shortcuts().Count > 0) { _store.Load(_shortcuts()); _storeLoaded = true; }
|
||||
```
|
||||
Bind's initial `Populate` (pre-login) sees an empty list and skips the load; the existing
|
||||
`repo.ObjectAdded → Populate` path (shortcut items arriving via `CreateObject`, by which time the PD
|
||||
shortcut list is set) triggers the one-time load. After load, drag ops mutate `_store` directly and
|
||||
it is never reloaded within the session (a relog = a fresh process → `_storeLoaded` resets). No
|
||||
GameWindow change is needed for loading.
|
||||
- `IsShortcutGuid(guid)` (the repo-event gate) checks `_store` (any slot holds `guid`) rather than
|
||||
`_shortcuts()`, so an item dragged ONTO the bar (a new guid) still gets its later updates rendered.
|
||||
- Each toolbar cell: `DragAcceptSprite = 0x060011FAu` (green cross). (`DragRejectSprite` stays
|
||||
`0x060011F8`.)
|
||||
- Inject two `Action`s for the wire (so the controller stays testable without a live session):
|
||||
`Action<uint,uint> sendAdd` = `(index, objId) => session.SendAddShortcut(index, objId)`,
|
||||
`Action<uint> sendRemove` = `index => session.SendRemoveShortcut(index)`. Null in tests.
|
||||
- `OnDragLift(sourceList, sourceCell, payload)` — retail `RemoveShortcut`:
|
||||
```csharp
|
||||
_store.Remove(payload.SourceSlot);
|
||||
_sendRemove?.Invoke((uint)payload.SourceSlot);
|
||||
Populate();
|
||||
```
|
||||
- `HandleDropRelease(targetList, targetCell, payload)` — retail reorder branch (deep-dive §5):
|
||||
```csharp
|
||||
int target = targetCell.SlotIndex;
|
||||
uint evicted = _store.Get(target); // RemoveShortcutInSlotNum
|
||||
if (evicted != 0) { _store.Remove(target); _sendRemove?.Invoke((uint)target); }
|
||||
_store.Set(target, payload.ObjId); _sendAdd?.Invoke((uint)target, payload.ObjId); // AddShortcut
|
||||
if (evicted != 0 && evicted != payload.ObjId && _store.IsEmpty(payload.SourceSlot))
|
||||
{ // bump displaced → vacated source slot
|
||||
_store.Set(payload.SourceSlot, evicted);
|
||||
_sendAdd?.Invoke((uint)payload.SourceSlot, evicted);
|
||||
}
|
||||
Populate();
|
||||
```
|
||||
- `OnDragOver` unchanged from B.1 (accept any real item → green-cross overlay).
|
||||
|
||||
---
|
||||
|
||||
## 5. Data flow — frame by frame (reorder slot 3 → occupied slot 5)
|
||||
|
||||
1. Press+move on slot 3 (objId A). `BeginDrag`: snapshot ghost = A's icon; fire `DragBegin`.
|
||||
2. Cell 3 → `OnDragLift`: `_store.Remove(3)` + `SendRemoveShortcut(3)` (0x019D) + `Populate()` →
|
||||
slot 3 shows empty. Ghost (A's full icon) follows the cursor.
|
||||
3. Drag over slot 5 (objId B) → green-cross accept overlay on slot 5.
|
||||
4. Release on slot 5 → `FinishDrag` delivers `DropReleased` to cell 5 → `HandleDropRelease`:
|
||||
`evicted=B`; `Remove(5)`+`SendRemoveShortcut(5)`; `Set(5,A)`+`SendAddShortcut(5,A)`; source slot 3
|
||||
is empty → `Set(3,B)`+`SendAddShortcut(3,B)`; `Populate()` → slot 5 = A, slot 3 = B (swapped).
|
||||
5. (Off-bar variant) release over the 3D world → no `DropReleased` → A stays removed → slot 3 empty,
|
||||
A gone from the bar (server already told at step 2).
|
||||
|
||||
---
|
||||
|
||||
## 6. Edge cases
|
||||
|
||||
- **Drop on self (slot 3 → slot 3):** at lift slot 3 emptied; at drop `evicted=0`, `Set(3,A)` +
|
||||
`SendAddShortcut(3,A)` → A back at 3. Net no-op (2 wire msgs — retail does the same).
|
||||
- **Drop on a non-item element** (chrome / another window): `FinishDrag` delivers `DropReleased` to
|
||||
it; its `OnEvent` doesn't handle it → no place → A stays removed (off-bar). Correct.
|
||||
- **Ghost lifetime:** snapshotted at `BeginDrag`, cleared in `FinishDrag` — independent of the source
|
||||
cell emptying. A cancelled drag (mouse-up off-bar) still clears `_dragGhost`.
|
||||
- **Empty-slot lift:** an empty slot is not a drag source (`IsDragSource => ItemId != 0` from B.1) →
|
||||
no drag arms → no lift. (And empty slots still move the window — IA-12, from B.1.)
|
||||
- **Spell shortcuts in the loaded list** (`ObjectGuid == 0`, a spell): `ShortcutStore.Load` skips
|
||||
them (item-only this stream); they neither render nor drag. Forward-compat: the store could hold
|
||||
spell entries later.
|
||||
|
||||
---
|
||||
|
||||
## 7. Divergence register (update in the implementation commits)
|
||||
|
||||
- **AP-47 (amend):** the ghost is now **full opacity** (retail m_dragIcon) — retire the
|
||||
`GhostAlpha=0.6` reduced-alpha approximation. The row's REMAINING approximation: the ghost reuses
|
||||
the full composited `m_pIcon` (which includes the type-default underlay) rather than retail's
|
||||
dedicated underlay-less `m_pDragIcon`. Reword AP-47 to drop the alpha claim, keep the underlay note.
|
||||
- **TS-33 (retire):** the toolbar `HandleDropRelease` logging stub is replaced by the real
|
||||
store-mutation + `AddShortcut`/`RemoveShortcut` wire. Delete the row.
|
||||
- **No new row** for remove-on-lift / off-bar-remove — it is the faithful retail mechanism (it
|
||||
*reduces* divergence). The store being client-authoritative post-login matches retail's
|
||||
`ShortCutManager` (client owns the array; server is notified via the wire).
|
||||
|
||||
---
|
||||
|
||||
## 8. Testing (conformance)
|
||||
|
||||
Core tests (`tests/AcDream.Core.Tests/`):
|
||||
1. `ShortcutStore`: `Load` maps Index→ObjId (skips ObjectGuid==0 + out-of-range); `Set`/`Remove`/
|
||||
`Get`/`IsEmpty`; bounds-safe.
|
||||
2. `BuildAddShortcut`: 24-byte body, envelope+seq+`0x019C`+index+objectGuid+spellId(u16)+layer(u16) at
|
||||
the right offsets (golden bytes). `BuildRemoveShortcut`: 16 bytes, index at +12.
|
||||
|
||||
App tests (`tests/AcDream.App.Tests/`):
|
||||
3. Spine: `BeginDrag` snapshots the ghost so it survives the source cell being cleared mid-drag (clear
|
||||
the source cell after begin; assert the drawn ghost source is still the snapshot — assert via a
|
||||
testable `UiRoot` accessor for `_dragGhost`).
|
||||
4. Spine: `FinishDrag` over null delivers NO `DropReleased` (a spy cell/handler isn't called); over a
|
||||
real cell, delivers it (drop the Data0 gate — drop-on-self now dispatches).
|
||||
5. `ToolbarController.OnDragLift`: removes the source slot from the store, invokes `sendRemove(src)`,
|
||||
and the source cell empties after `Populate`.
|
||||
6. `ToolbarController.HandleDropRelease` reorder onto an OCCUPIED target: store ends with dragged@target
|
||||
+ displaced@source; `sendRemove`+`sendAdd` called with the retail sequence/args. Onto an EMPTY
|
||||
target: dragged@target, source stays empty, displaced-branch not taken. Drop-on-self: re-adds to
|
||||
source.
|
||||
7. Update the B.1 tests changed by §4.2/§4.3: `DropReleased_notAccepted_skipsDispatch` →
|
||||
`DropReleased_alwaysDispatchesToHandler` (the cell dispatches whenever it receives a DropReleased);
|
||||
the full-chain drag tests still pass (a completed reorder fires `HandleDropRelease`, not `Clicked`).
|
||||
|
||||
---
|
||||
|
||||
## 9. Acceptance criteria
|
||||
|
||||
- [ ] `dotnet build` + `dotnet test` green (Core + App).
|
||||
- [ ] AP-47 reworded (no alpha); TS-33 deleted; wire builder renamed; no stray old-signature callers.
|
||||
- [ ] `UiRoot`/`UiElement`/`UiItemSlot` remain item-agnostic (the lift hook is generic; only
|
||||
`ToolbarController` knows shortcuts).
|
||||
- [ ] **Visual (user), `ACDREAM_RETAIL_UI=1`, in-world:** lift a hotbar item → **slot empties
|
||||
immediately**, **full-opacity** icon follows the cursor with a **green cross** over hovered
|
||||
slots; drop on another slot → it **moves there** (occupant bumps to the source slot); drop
|
||||
off-bar → it's **removed**; a single click still **uses** the item. Changes **persist after
|
||||
relog** (the wire reached ACE).
|
||||
- [ ] Memory crib + roadmap/ISSUES updated; #141's note refreshed if relevant.
|
||||
|
||||
---
|
||||
|
||||
## 10. Subagent task slices
|
||||
|
||||
1. **Core: `ShortcutStore` + wire** — `ShortcutStore.cs` + `BuildAddShortcut` rename +
|
||||
`WorldSession.SendAddShortcut`/`SendRemoveShortcut` + Core tests (§8.1-2).
|
||||
2. **Spine extensions** — `IItemListDragHandler.OnDragLift`; `UiItemSlot` DragBegin→lift + DropReleased
|
||||
ungate; `UiRoot` ghost snapshot + full opacity + `FinishDrag` deliver-on-hit-only; update the B.1
|
||||
tests (§8.3-4, §8.7).
|
||||
3. **ToolbarController handler** — `_store` + `Load` + `Populate`-from-store; green-cross sprite;
|
||||
`OnDragLift`/`HandleDropRelease`; wire `Action`s; GameWindow injects the session sends; controller
|
||||
tests (§8.5-6). Update AP-47, delete TS-33.
|
||||
|
||||
(1 and 2 are independent; 3 depends on both.)
|
||||
245
docs/superpowers/specs/2026-06-20-d2b-window-manager-design.md
Normal file
245
docs/superpowers/specs/2026-06-20-d2b-window-manager-design.md
Normal file
|
|
@ -0,0 +1,245 @@
|
|||
# Design — D.2b window manager (open/close + F12 inventory toggle)
|
||||
|
||||
**Date:** 2026-06-20
|
||||
**Phase:** D.2b retail-UI arc, Sub-phase **A** (window manager) — the first of three
|
||||
(A window manager → B inventory window → C paperdoll). Handoff:
|
||||
`docs/research/2026-06-20-window-manager-inventory-handoff.md` §3.
|
||||
**Status:** approved design, pre-implementation.
|
||||
|
||||
---
|
||||
|
||||
## 1. Context & goal
|
||||
|
||||
Every retail-UI window today is **always-on at a hardcoded position**: vitals, chat, and
|
||||
the toolbar are added to `_uiHost.Root` unconditionally at startup in the `_options.RetailUi`
|
||||
block of `GameWindow.OnLoad` (grep `_uiHost.Root.AddChild`). `UiHost`/`UiRoot` have **no
|
||||
open/close API** — `UiRoot` exposes the widget tree, input routing, focus, capture, modal,
|
||||
and drag-drop, but nothing to show/hide/raise a named top-level window.
|
||||
|
||||
**Goal:** the minimal shared infrastructure to **open/close a top-level window** and a
|
||||
keybind to **toggle the inventory window**. This is the prerequisite for Sub-phase B
|
||||
(the inventory window) and C (paperdoll) — both need a window that starts hidden and is
|
||||
summoned on demand.
|
||||
|
||||
**Non-goal:** the inventory window's *contents*. Sub-phase A toggles a throwaway
|
||||
**placeholder** window; Sub-phase B replaces its body with the real `gmInventoryUI`
|
||||
layout. See §9.
|
||||
|
||||
---
|
||||
|
||||
## 2. Scope
|
||||
|
||||
**In scope (A):**
|
||||
- A named-window registry on `UiRoot` with `Show` / `Hide` / `Toggle` / `BringToFront`.
|
||||
- Raise-on-click for top-level windows (retail-faithful window stacking).
|
||||
- F12 → toggle the inventory window, via the **existing** `InputAction.ToggleInventoryPanel`.
|
||||
- A minimal placeholder inventory window (default-hidden) so A is visually verifiable and
|
||||
gives B a concrete mount point.
|
||||
- Unit tests for the registry logic; divergence-register bookkeeping.
|
||||
|
||||
**Out of scope (deferred):**
|
||||
- Inventory window contents, grid, sub-window mount, wire gaps, `InventoryController` → **B**.
|
||||
- Paperdoll / `UiViewport` → **C**.
|
||||
- Disk persistence of open-state + window positions → handoff "Plan-2".
|
||||
- A toolbar inventory *button* (radar/menu buttons) — F12 only for now; the button can be a
|
||||
later cheap addition once the inventory window exists.
|
||||
- Spell bar — explicitly deferred by the handoff.
|
||||
|
||||
---
|
||||
|
||||
## 3. Architecture decision — registry on `UiRoot`
|
||||
|
||||
`UiRoot` already owns exactly the state a window manager manipulates: the top-level
|
||||
`Children`, `ZOrder` (drives both paint order in `DrawSelfAndChildren` and hit-test order in
|
||||
`HitTestTopDown`), keyboard focus, mouse capture, and the modal overlay. "Show / hide / raise
|
||||
a top-level window" is the job it already does — so the registry lives there.
|
||||
|
||||
This mirrors retail, where the Keystone root (`DAT_00870c2c`) owns the widget tree and window
|
||||
stacking. (Keystone is an external DLL with no decomp, so the manager is **toolkit-defined**,
|
||||
not a byte-faithful port — see §7.)
|
||||
|
||||
**Alternatives considered and rejected:**
|
||||
- **Dedicated `WindowManager` class wrapping `UiRoot`** — cleaner single-responsibility on
|
||||
paper, but it must reach into `UiRoot.Children` / `ZOrder` to mount + raise, so it just
|
||||
borrows `UiRoot`'s internals through a new seam. Ceremony for ~30 lines of logic.
|
||||
- **Thin API on `UiHost`** — `UiHost` is the facade `GameWindow` talks to, but the tree and
|
||||
`ZOrder` live in `UiRoot`; this would be a pure forwarding layer with no added value.
|
||||
|
||||
`UiHost` gets **two convenience forwarders** (`ToggleWindow`, `RegisterWindow`) that delegate
|
||||
to `Root`, because `GameWindow` holds a `UiHost`, not a `UiRoot`, at the call sites.
|
||||
|
||||
---
|
||||
|
||||
## 4. Components & interfaces
|
||||
|
||||
### 4.1 `UiRoot` window registry
|
||||
|
||||
A private `Dictionary<string, UiElement> _windows` plus:
|
||||
|
||||
```csharp
|
||||
/// <summary>Register a top-level window under a name for Show/Hide/Toggle.
|
||||
/// Idempotent (re-register replaces). The dict is the source of truth —
|
||||
/// independent of UiElement.Name, which is init-only and not reassigned here.</summary>
|
||||
public void RegisterWindow(string name, UiElement window);
|
||||
|
||||
/// <summary>Make the named window visible and raise it to the front.
|
||||
/// No-op if unknown. Returns true if a window was shown.</summary>
|
||||
public bool ShowWindow(string name);
|
||||
|
||||
/// <summary>Hide the named window (Visible = false). No-op if unknown.</summary>
|
||||
public bool HideWindow(string name);
|
||||
|
||||
/// <summary>Flip the named window's visibility; Show (raise) if it was hidden,
|
||||
/// Hide if it was visible. Returns the new IsVisible state.</summary>
|
||||
public bool ToggleWindow(string name);
|
||||
|
||||
/// <summary>Set element.ZOrder to one above the current max among top-level
|
||||
/// children, so it paints + hit-tests above its peers.</summary>
|
||||
public void BringToFront(UiElement window);
|
||||
```
|
||||
|
||||
- `Show`/`Hide` only flip `UiElement.Visible`; `Visible` already gates Draw, Tick, and
|
||||
HitTest (all early-return when false), so a hidden window is fully inert — no draw, no
|
||||
input, no tooltip timer.
|
||||
- `RegisterWindow` does **not** add the element to the tree — the caller mounts it with
|
||||
`AddChild` (so registration and tree-membership stay independent and the caller controls
|
||||
initial `Visible`). Registration only records the name→element mapping.
|
||||
- `BringToFront`: `window.ZOrder = 1 + max(child.ZOrder for child in Children)`. The existing
|
||||
default windows mount at `ZOrder = 0`, so an opened/clicked window rises above them.
|
||||
|
||||
### 4.2 Raise-on-click
|
||||
|
||||
In `UiRoot.OnMouseDown`, after the existing `FindWindow(target)` resolves the top-level
|
||||
window under the press, call `BringToFront(window)` so the clicked window comes to the top of
|
||||
the stack (retail raises clicked windows). This is the existing `FindWindow` result reused —
|
||||
~3 lines, no new traversal. Applies to **any** top-level window (vitals, chat, toolbar,
|
||||
inventory), not just registered ones; the registry is only for named Show/Hide/Toggle.
|
||||
|
||||
### 4.3 `UiHost` forwarders
|
||||
|
||||
```csharp
|
||||
public void RegisterWindow(string name, UiElement window) => Root.RegisterWindow(name, window);
|
||||
public bool ToggleWindow(string name) => Root.ToggleWindow(name);
|
||||
```
|
||||
|
||||
### 4.4 F12 wiring — `OnInputAction`
|
||||
|
||||
`InputAction.ToggleInventoryPanel` already exists, is already bound to **F12** in
|
||||
`KeyBindings.RetailDefaults()` (matching `docs/research/named-retail/retail-default.keymap.txt`
|
||||
line 149: `ToggleInventoryPanel [ "" [ 0 DIK_F12 ] ]`), and is already listed in the Settings
|
||||
rebind UI — so it's user-rebindable with no new wiring. Today **nothing consumes it.**
|
||||
|
||||
Add one arm to the `switch (action)` in `GameWindow.OnInputAction` (the `InputDispatcher.Fired`
|
||||
multicast subscriber; the switch begins at GameWindow.cs:11421):
|
||||
|
||||
```csharp
|
||||
case AcDream.UI.Abstractions.Input.InputAction.ToggleInventoryPanel:
|
||||
_uiHost?.ToggleWindow(WindowNames.Inventory);
|
||||
break;
|
||||
```
|
||||
|
||||
- Gating is already correct: the dispatcher is suppressed by `WantsKeyboard` when the chat
|
||||
input holds focus, so F12 cannot toggle inventory while you're typing.
|
||||
- `WindowNames.Inventory` is a `const string "inventory"` (a small `WindowNames` holder in
|
||||
`AcDream.App.UI`), so the mount, the registry, and the toggle agree on one literal.
|
||||
|
||||
### 4.5 Placeholder inventory window mount
|
||||
|
||||
In the `_options.RetailUi` block of `GameWindow.OnLoad`, alongside the vitals/chat/toolbar
|
||||
mounts, build a minimal framed window:
|
||||
|
||||
```csharp
|
||||
var inventoryWindow = new AcDream.App.UI.UiNineSlicePanel(ResolveChrome)
|
||||
{
|
||||
Left = 200, Top = 120, Width = 320, Height = 400,
|
||||
Visible = false, // starts hidden; F12 reveals it
|
||||
Draggable = true,
|
||||
Anchors = AcDream.App.UI.AnchorEdges.None,
|
||||
};
|
||||
_uiHost.Root.AddChild(inventoryWindow);
|
||||
_uiHost.RegisterWindow(AcDream.App.UI.WindowNames.Inventory, inventoryWindow);
|
||||
```
|
||||
|
||||
This uses the same `UiNineSlicePanel(ResolveChrome)` + `RetailChromeSprites.Border` chrome
|
||||
the vitals/chat windows use, so the placeholder already has the retail beveled frame. It is
|
||||
**throwaway scaffolding**: Sub-phase B replaces the body (and likely the whole construction)
|
||||
with the real `gmInventoryUI 0x21000023` nested layout, keeping the same registry name so the
|
||||
F12 wiring is untouched.
|
||||
|
||||
---
|
||||
|
||||
## 5. Persistence
|
||||
|
||||
In-memory for the session only. Open/closed state lives in `UiElement.Visible`; window
|
||||
positions are the hardcoded mount positions (already reset each session). No disk persistence
|
||||
— deferred to the handoff's Plan-2.
|
||||
|
||||
---
|
||||
|
||||
## 6. Testing
|
||||
|
||||
Unit tests in `tests/AcDream.App.Tests/UI/UiRootInputTests.cs` (or a sibling
|
||||
`UiRootWindowManagerTests.cs`) — the registry logic is pure (no GL), so it tests directly:
|
||||
|
||||
- `RegisterWindow` + `ToggleWindow` flips `Visible` false→true→false and returns the new state.
|
||||
- `ShowWindow` raises `ZOrder` above all peers; `HideWindow` sets `Visible=false` without
|
||||
touching `ZOrder`.
|
||||
- `BringToFront` on the lowest window makes its `ZOrder` strictly greatest among siblings.
|
||||
- `ToggleWindow`/`ShowWindow`/`HideWindow` on an unknown name are no-ops returning false.
|
||||
- Raise-on-click: an `OnMouseDown` on a registered window with a peer in front leaves the
|
||||
clicked window with the greatest `ZOrder` (reuses the existing `UiRootInputTests` harness
|
||||
pattern for synthesizing mouse events).
|
||||
|
||||
`dotnet build` + `dotnet test` green (full suite, currently 2752+).
|
||||
|
||||
---
|
||||
|
||||
## 7. Divergence register
|
||||
|
||||
The window manager is **toolkit-defined** — retail's window stacking lives in `keystone.dll`
|
||||
(no decomp), so this is the existing IA-12 / IA-15 toolkit-adaptation umbrella, not a
|
||||
byte-faithful port. Confirm whether an existing register row covers
|
||||
"retail-UI windows are toolkit-managed, not Keystone-ported"; if not, add a one-line row in
|
||||
`docs/architecture/retail-divergence-register.md` in the implementation commit. **F12 itself
|
||||
is retail-faithful** (matches the retail default keymap) — no divergence for the keybind.
|
||||
|
||||
---
|
||||
|
||||
## 8. Acceptance criteria
|
||||
|
||||
- `dotnet build` green; `dotnet test` green.
|
||||
- With `ACDREAM_RETAIL_UI=1`, pressing **F12** shows the placeholder inventory window above
|
||||
vitals/chat/toolbar; pressing **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).
|
||||
- Window manager logic is unit-tested; divergence-register row confirmed/added.
|
||||
- Visual verification by the user (the toggle + raise behavior).
|
||||
|
||||
---
|
||||
|
||||
## 9. Seam for Sub-phase B
|
||||
|
||||
A leaves B a clean handoff:
|
||||
- The registry name `WindowNames.Inventory` and the F12 → `ToggleWindow` wiring are stable;
|
||||
B keeps both.
|
||||
- B replaces the placeholder `UiNineSlicePanel` with the real `gmInventoryUI 0x21000023`
|
||||
nested layout (paperdoll + backpack + 3D-items), re-registering it under the same name.
|
||||
- B adds the dat **close button** → `HideWindow(WindowNames.Inventory)` wiring (the manager
|
||||
already exposes `HideWindow`; A leaves the placeholder closable only via F12).
|
||||
- B wires the inventory cell as a drag **source**, completing B.2's drag-from-inventory via
|
||||
the `SourceKind == Inventory` branch in `ToolbarController.HandleDropRelease`.
|
||||
|
||||
---
|
||||
|
||||
## 10. References
|
||||
|
||||
- Handoff: `docs/research/2026-06-20-window-manager-inventory-handoff.md` §3 (Sub-phase A).
|
||||
- Retail keymap: `docs/research/named-retail/retail-default.keymap.txt` lines 139–153
|
||||
(panel-toggle commands; inventory = F12) + line 246 (`Laugh` = `DIK_I`).
|
||||
- Toolkit: `src/AcDream.App/UI/UiRoot.cs`, `UiElement.cs`, `UiHost.cs`,
|
||||
`UiNineSlicePanel.cs`; `claude-memory/project_d2b_retail_ui.md`.
|
||||
- Input pipeline: `OnInputAction` switch at `src/AcDream.App/Rendering/GameWindow.cs:11421`;
|
||||
`InputAction.ToggleInventoryPanel`; `KeyBindings.cs:205`;
|
||||
`claude-memory/project_input_pipeline.md`.
|
||||
- Object model (for B): `claude-memory/project_object_item_model.md`,
|
||||
`src/AcDream.Core/Items/ClientObjectTable.cs`.
|
||||
|
|
@ -0,0 +1,265 @@
|
|||
# D.2b Sub-phase B-Controller — inventory population design
|
||||
|
||||
**Date:** 2026-06-21
|
||||
**Phase:** D.2b retail-UI engine → core panels → Sub-phase B (inventory) → **B-Controller**
|
||||
**Branch:** `claude/hopeful-maxwell-214a12`
|
||||
**Predecessor:** B-Grid (sub-window mount + `UiItemList` grid mode) + #145 (ZLevel z-order) — shipped.
|
||||
Handoff: `docs/research/2026-06-21-d2b-bgrid-shipped-bcontroller-next-handoff.md`.
|
||||
|
||||
## 0. Goal
|
||||
|
||||
Make the F12 inventory window show the player's live contents. Bind the imported
|
||||
`gmInventoryUI` (`LayoutDesc 0x21000023`) tree by element id and populate it from
|
||||
`ClientObjectTable`:
|
||||
|
||||
1. the **"Contents of Backpack" grid** with the player's pack items,
|
||||
2. the **right-strip pack-selector** (main-pack cell + side-bag container icons),
|
||||
3. the **vertical burden meter** + the `%` text,
|
||||
4. the **Type-0 captions** ("Burden", "Contents of Backpack").
|
||||
|
||||
This is the `gmInventoryUI::PostInit` / `gmBackpackUI::PostInit` / `gm3DItemsUI::PostInit`
|
||||
analogue. **Read-only**: no container switching, no drag-as-source, no wield/drop wire
|
||||
(those are B-Wire / B-Drag / Sub-phase C).
|
||||
|
||||
Both scope calls were taken by the user during brainstorming: **faithful pack-selector**
|
||||
(populate the right strip too) and **full faithful burden meter** (vertical fill + ported
|
||||
`InqLoad` formula).
|
||||
|
||||
## 1. The inventory tree (confirmed by dat dump + decomp)
|
||||
|
||||
`gmInventoryUI` `0x21000023` (300×362) is a frame nesting three sub-windows (mounted by
|
||||
B-Grid's sub-window mount; all reachable via `ImportedLayout.FindElement`):
|
||||
|
||||
| Sub-window | LayoutDesc | Mounted at | Size | This phase |
|
||||
|---|---|---|---|---|
|
||||
| paperdoll | `0x21000024` | `0x100001CD` | 224×214 | **Sub-phase C** (not here) |
|
||||
| backpack strip | `0x21000022` | `0x100001CE` | 61×339 | **here** |
|
||||
| 3D-items ("Contents of Backpack") | `0x21000021` | `0x100001CF` | 234×120 | **here** |
|
||||
|
||||
### Elements this controller binds
|
||||
|
||||
| Id | Role | Resolved widget | Source |
|
||||
|---|---|---|---|
|
||||
| `0x100001C6` | 3D-items **contents grid** (192×96) | `UiItemList` (grid) | `gm3DItemsUI::PostInit` m_itemList `DynamicCast(0x10000031)` (decomp 176734-176742) |
|
||||
| `0x100001C5` | "Contents of Backpack" caption (192×15) | Type-0 → caption pass | `gm3DItemsUI::PostInit` m_contentsText, `SetText` 176745 |
|
||||
| `0x100001CA` | backpack **m_containerList** (36×252, 1 col) | `UiItemList` (grid) | `gmBackpackUI::PostInit` `DynamicCast(0x10000031)` (176621-176629) |
|
||||
| `0x100001C9` | backpack **m_topContainer** (36×36, 1 cell) | `UiItemList` (single) | `gmBackpackUI::PostInit` (176612-176620) |
|
||||
| `0x100001D9` | **burden meter** (vertical 11×58, Type 7) | `UiMeter` | `gmBackpackUI::PostInit` `DynamicCast(7)` (176601-176610) |
|
||||
| `0x100001D8` | burden **`%` text** (36×15) | Type-0 → caption pass | `gmBackpackUI` m_burdenText, `SetText "%d%%"` (176583) |
|
||||
| `0x100001D7` | "Burden" caption (36×15) | Type-0 → caption pass | dat |
|
||||
|
||||
The three list elements (`0x100001C6/C9/CA`) are Type-0 in the dat, inheriting
|
||||
`BaseElement 0x10000339 / BaseLayoutId 0x2100003D` (the `UIElement_ItemList` prototype) →
|
||||
`ElementReader.Merge` resolves them to Type `0x10000031` → `DatWidgetFactory` builds them as
|
||||
`UiItemList`. Already verified reachable by id through the mount.
|
||||
|
||||
## 2. Architecture
|
||||
|
||||
New `src/AcDream.App/UI/Layout/InventoryController.cs`, mirroring `ToolbarController` /
|
||||
`VitalsController` (Code-Structure Rule 1 — no new feature body in `GameWindow`):
|
||||
|
||||
```csharp
|
||||
public sealed class InventoryController
|
||||
{
|
||||
public static InventoryController Bind(
|
||||
ImportedLayout invLayout,
|
||||
ClientObjectTable objects,
|
||||
Func<uint> playerGuid, // _playerServerGuid
|
||||
Func<ItemType,uint,uint,uint,uint,uint> iconIds, // IconComposer.GetIcon
|
||||
Func<int?> strength, // LocalPlayerState Strength.Current
|
||||
UiDatFont? datFont);
|
||||
public void Dispose(); // unsubscribe (controllers currently don't, but
|
||||
// the inventory rebuild is event-driven — be tidy)
|
||||
}
|
||||
```
|
||||
|
||||
Bound **inline in GameWindow's existing inventory-init block** (the `invLayout` local from
|
||||
B-Grid, `GameWindow.cs` ~2141), right after `RegisterWindow(WindowNames.Inventory, …)` —
|
||||
exactly where `ToolbarController.Bind(toolbarLayout, …)` sits for the toolbar.
|
||||
|
||||
Lifecycle: `Bind` find-by-id binds all elements, subscribes to
|
||||
`objects.ObjectAdded/ObjectMoved/ObjectRemoved` and `LocalPlayerState` attribute/`Changed`
|
||||
events, then calls `Populate()` once. Each subscription calls `Populate()` (filtered: only
|
||||
when the changed object is in the player's possession subtree, mirroring
|
||||
`ToolbarController.Populate`'s `IsShortcutGuid` gate). Missing elements are skipped silently
|
||||
(partial-layout tolerance, like `SelectedObjectController`).
|
||||
|
||||
## 3. Population (`Populate()`)
|
||||
|
||||
Reads `var contents = objects.GetContents(playerGuid())` (→ `IReadOnlyList<uint>`, slot-ordered).
|
||||
Partition each guid's `ClientObject`:
|
||||
- **side bag** = `Type.HasFlag(ItemType.Container)` OR `ItemsCapacity > 0`.
|
||||
- **loose item** = everything else.
|
||||
|
||||
Then:
|
||||
|
||||
- **3D-items grid `0x100001C6`** — `Columns=6`, `CellWidth=CellHeight=32` (192÷32=6 cols ×
|
||||
96÷32=3 rows; cell template `UIItem 0x21000037` = 32×32). `Flush()`, then `AddItem(new
|
||||
UiItemSlot{…})` + `cell.SetItem(guid, iconIds(item.Type, item.IconId, item.IconUnderlayId,
|
||||
item.IconOverlayId, item.Effects))` per **loose item**. (Pitch 32 vs 36 → **visual-confirm**;
|
||||
192/32=6 is the clean fit and matches the handoff "6 cols" note.) Overflow past 3 rows is
|
||||
clipped by the panel for now; scroll = follow-up (the `0x100001C7` gutter is the scrollbar).
|
||||
- **m_containerList `0x100001CA`** — `Columns=1`, 32px cells. One cell per **side bag**, same
|
||||
`SetItem`. Empty on a char with no side bags (correct).
|
||||
- **m_topContainer `0x100001C9`** — single cell = the **main pack**. Icon: a fixed backpack
|
||||
RenderSurface DID (pin from the decomp / a known UI icon; if none found, leave the
|
||||
empty-slot art and file a follow-up — do NOT invent a guid). Bound object id = `playerGuid()`
|
||||
(the main pack ≡ the player container).
|
||||
|
||||
Reuse the shipped `UiItemList` grid mode (`Columns`/`CellWidth`/`CellHeight`/`Flush`/`AddItem`)
|
||||
and `UiItemSlot.SetItem(guid, tex)` + `IconComposer.GetIcon`. **Do not rebuild the spine.**
|
||||
|
||||
## 4. Burden meter (faithful vertical port)
|
||||
|
||||
### 4.1 Retail formula (ported, with anchors)
|
||||
|
||||
`gmBackpackUI::SetLoadLevel(double load)` (decomp 176536, `0x004a6ea0`):
|
||||
- `fill = clamp(load × 0.3333…, 0, 1)` → pushed to the meter as float attribute `0x69`
|
||||
(`UIElement::SetAttribute_Float`, 176565-176573).
|
||||
- `%` text = `floor((load×⅓) × 300) = floor(load × 100)` formatted `"%d%%"` →
|
||||
`UIElement_Text::SetText(m_burdenText)` (176576-176583).
|
||||
|
||||
`load = CACQualities::InqLoad()` (decomp 409756, `0x0058f130`):
|
||||
```
|
||||
strength = InqAttribute(1) // primary attr 1 = Strength, default 10
|
||||
aug = InqInt(0xE6) // capacity augmentation, default 0
|
||||
capacity = EncumbranceSystem::EncumbranceCapacity(strength, aug)
|
||||
burden = InqInt(5) // PropertyInt 5 = EncumbranceVal (total carried)
|
||||
load = EncumbranceSystem::Load(capacity, burden)
|
||||
```
|
||||
|
||||
`EncumbranceSystem::EncumbranceCapacity(str, aug)` (decomp 256393, `0x004fcc00`):
|
||||
```
|
||||
if str <= 0: return 0
|
||||
bonus = clamp(aug × 30, 0, 150) // 0x1e=30, cap 0x96=150
|
||||
return str × 150 + bonus × str // = str × (150 + bonus)
|
||||
```
|
||||
`EncumbranceSystem::Load(capacity, burden)` (decomp 256413, `0x004fcc40`): the decompiler
|
||||
mangled the FP body to `if cap<=0 return cap; return burden`; the structure (a divide-by-zero
|
||||
guard + a single divide) is unambiguously `load = burden / capacity` — the encumbrance ratio
|
||||
(1.0 = at capacity, up to ~3.0). **Cross-ref:** matches acdream's existing
|
||||
`BurdenMath.ComputeMax = 150×str + str×bonus` (`ClientObject.cs:215`) and the r06 research doc
|
||||
("maxBurden = 150 × Strength + Strength × bonusBurden; carry limit 3 × maxBurden"). ACE was
|
||||
not checked out in this worktree; the decomp + existing port + r06 doc are three corroborating
|
||||
sources.
|
||||
|
||||
**acdream port:** `maxBurden = BurdenMath.ComputeMax(strength, bonus)` (reuse; `bonus = clamp(aug×30,0,150)`,
|
||||
`aug` from player PropertyInt `0xE6`, default 0 → `str×150`). `load = burden / maxBurden`.
|
||||
`fill = clamp(load/3, 0, 1)`. `%text = floor(load×100)`.
|
||||
|
||||
### 4.2 Burden data source
|
||||
|
||||
acdream does **not** track the player's wire `EncumbranceVal` today (WorldSession parses only
|
||||
per-item Burden). Priority:
|
||||
1. player `ClientObject`'s PropertyInt 5: `objects.Get(playerGuid())?.Properties.GetInt(5)` —
|
||||
populated iff PD/`0x02CE` carried it (B-Wire guarantees this later).
|
||||
2. fallback: client-side sum of carried `Burden` over the player's possession subtree
|
||||
(`GetContents(player)` + recurse side bags + items with `WielderId == player`).
|
||||
3. neither → `currentBurden = 0` → bar empty / "0%" (correct "no data" state).
|
||||
|
||||
`strength` from `LocalPlayerState.GetAttribute(Strength)?.Current` (default 10 if absent,
|
||||
matching `InqAttribute`'s `0xa` default). **Divergence row** if (2) is used (client recompute
|
||||
vs server EncumbranceVal — same quantity).
|
||||
|
||||
Drive the meter via `UiMeter.Fill = () => fill` and a `Label`/caption provider for the `%`
|
||||
text, recomputed on `ObjectAdded/Moved/Removed` + `LocalPlayerState.Changed/AttributeChanged`.
|
||||
|
||||
### 4.3 Vertical fill (the one `UiMeter` change)
|
||||
|
||||
Per `UIElement_Meter::DrawChildren` (decomp 123574, `0x0046fbd0`): the meter draws its **own
|
||||
DirectState media as the track (full)** and the **`m_pcChildImage` child clipped to attribute
|
||||
`0x69` (the fill fraction)**. So `BuildMeter`'s existing single-image assignment is **already
|
||||
correct** (meter-own `0x0600121D` → `BackTile`/track; child `0x0600121C` → `FrontTile`/fill).
|
||||
**No sprite-role change.**
|
||||
|
||||
The clip axis/direction is `m_eDirection` (read from LayoutDesc property `0x6f` at
|
||||
`UIElement_Meter::Initialize`, decomp 123334-123336): `1`=L→R, `2`=T→B, `3`=R→L, `4`=B→T.
|
||||
`UiMeter` is hardcoded `1`. Add:
|
||||
- a `UiMeter.Vertical` (or `Direction`) flag,
|
||||
- a `DrawVBar` that draws `BackTile` over the full rect then `FrontTile` clipped to
|
||||
`Height × fraction` along the fill direction. The burden child sprite (11×61) ≈ the bar
|
||||
(11×58) — effectively single-sprite, no 3-slice tiling needed.
|
||||
|
||||
Direction source: read property `0x6f` if `ElementReader` surfaces it; **else** infer vertical
|
||||
from `Width < Height` and default **B→T (dir 4)** — the AC convention for a vertical resource
|
||||
bar — with a **visual-confirm** and an AP register row for the geometry-inference. (Reading
|
||||
`0x6f` is preferred; decide in the plan based on how cheaply `ElementReader` can expose it.)
|
||||
|
||||
## 5. Captions (Type-0 text)
|
||||
|
||||
Controller **caption pass** (not a factory Type-0→`UiText` promotion — that would risk other
|
||||
panels' inert Type-0 elements). For each caption id, attach a centered `UiText` child carrying
|
||||
the known string, mirroring `SelectedObjectController`'s name-overlay attach and
|
||||
`VitalsController`'s number overlay:
|
||||
- `0x100001D7` → "Burden"
|
||||
- `0x100001C5` → "Contents of Backpack" (procedural in retail via `SetText`, 176743-176745)
|
||||
- `0x100001D8` → the live `%` text (`floor(load×100)` + "%"), updated with the meter.
|
||||
|
||||
Caption strings are hardcoded with their decomp citation (retail sets `0x100001C5`/`0x100001D8`
|
||||
procedurally; `0x100001D7` "Burden" is the dat element's label). Font = the retail dat font
|
||||
(`datFont`), color/justification per the Type-0 base (left/normal). If the dat string for
|
||||
`0x100001D7` is trivially readable from the layout, prefer it over the hardcode.
|
||||
|
||||
## 6. Divergence register rows (add in the impl commit)
|
||||
|
||||
- **AP** — burden `currentBurden` computed client-side (Σ carried Burden) when the wire
|
||||
`EncumbranceVal` (PropertyInt 5) is absent; retail reads the server-maintained value.
|
||||
Retire when B-Wire parses it.
|
||||
- **AP** — capacity augmentation (`aug`, PropertyInt `0xE6`) not tracked → `bonus = 0` →
|
||||
un-augmented `str×150`. Buffed/augmented chars read slightly high.
|
||||
- **AP** (only if dir not read from `0x6f`) — burden meter orientation inferred from `W<H`
|
||||
geometry + default B→T, vs retail's `m_eDirection` from property `0x6f`.
|
||||
- **AP** — main-pack `m_topContainer` icon is a fixed/placeholder backpack art, not a
|
||||
weenie-driven icon (the main pack ≡ the player, which has no item IconId).
|
||||
|
||||
## 7. Testing (dat-free conformance)
|
||||
|
||||
`tests/AcDream.App.Tests/UI/Layout/InventoryControllerTests.cs` (+ a burden-math test):
|
||||
|
||||
- **Bind** — given a synthetic `ImportedLayout` (built via `BuildFromInfos` from ElementInfo
|
||||
fixtures, or a hand-built tree) with the 7 ids, `Bind` finds the lists/meter and does not
|
||||
throw on missing ids.
|
||||
- **Grid population** — N loose items in `GetContents(player)` → the 3D grid has N cells with
|
||||
the right guids; M side bags → `m_containerList` has M cells; partition correctness
|
||||
(container vs loose).
|
||||
- **Burden golden values** (Str=100 → max=15000): burden 0 → load 0 → fill 0 / "0%"; 7500 →
|
||||
0.5 → 0.1667 / "50%"; 15000 → 1.0 → 0.3333 / "100%"; 45000 → 3.0 → 1.0 (clamped) / "300%";
|
||||
60000 → 4.0 → 1.0 (clamped) / "400%". Assert `fill` and `%text` separately.
|
||||
- **Capacity** — `EncumbranceCapacity(100, 0)=15000`; `(100, 3)=15000+min(90,150)·100=24000`;
|
||||
`(100, 10)=15000+min(300→150)·100=30000`; `(0, x)=0`.
|
||||
- **Vertical fill rect** — `UiMeter` vertical mode: fraction 0.5 of an 11×58 bar fills the
|
||||
correct half along the fill direction.
|
||||
- **Caption** — the caption `UiText` children carry the expected strings.
|
||||
- **Rebuild** — firing `ObjectAdded` for a player-pack item re-runs `Populate` and the new cell
|
||||
appears; an unrelated guid does not.
|
||||
|
||||
Reuse the existing Layout test fixtures (`vitals_2100006C.json` pattern). A real-dat smoke
|
||||
check of `Import(0x21000023)` + `Bind` is a nice-to-have before wiring (not gating).
|
||||
|
||||
## 8. Visual verification (final launch — folded with the z-order eyeball)
|
||||
|
||||
Launch with `ACDREAM_RETAIL_UI=1`, F12: confirm (a) pack items show in the "Contents of
|
||||
Backpack" grid, (b) side bags (if any) + main-pack cell in the right strip, (c) the burden bar
|
||||
fills vertically the right direction with the right sprites and the `%` reads sanely, (d) the
|
||||
"Burden"/"Contents of Backpack" captions render, AND (e) chat + toolbar show no #145 z-order
|
||||
regression. Items (c) direction and (a) cell pitch are the two visual-confirm unknowns.
|
||||
|
||||
## 9. Acceptance criteria
|
||||
|
||||
- [ ] `InventoryController` binds `0x21000023`'s tree by id; build + tests green.
|
||||
- [ ] 3D-items grid populates from `GetContents(player)` loose items; right strip from side
|
||||
bags + main pack.
|
||||
- [ ] Burden meter renders vertically with the ported formula; `%` text correct; golden tests
|
||||
pass.
|
||||
- [ ] Captions render.
|
||||
- [ ] Every ported AC algorithm cites its decomp anchor in comments; divergence rows added.
|
||||
- [ ] Visual verification (with the z-order eyeball) by the user.
|
||||
|
||||
## 10. Out of scope (explicit non-goals → later sub-phases)
|
||||
|
||||
- Container **switching** (click a pack-selector cell → re-point the 3D grid). → interactivity.
|
||||
- Inventory cell as a **drag source** (`SourceKind==Inventory`). → B-Drag.
|
||||
- Wire gaps (`DropItem`/`GetAndWieldItem`/`ViewContents`/`SetStackSize`/player `EncumbranceVal`
|
||||
parse). → B-Wire.
|
||||
- Paperdoll doll + per-slot equip art (`0x10000032` UiItemSlot registration). → Sub-phase C.
|
||||
- Scrolling the contents grid past 3 rows.
|
||||
|
|
@ -0,0 +1,172 @@
|
|||
# D.2b inventory window finish (Stage 1) — design / spec
|
||||
|
||||
**Date:** 2026-06-21
|
||||
**Phase:** D.2b inventory, **Stage 1 of the "full retail inventory" arc** (Stage 2 = paperdoll, separate spec).
|
||||
**Branch:** `claude/hopeful-maxwell-214a12` (follows B-Wire, tip `7c006d1`).
|
||||
**Predecessor:** B-Wire (inventory wire layer) — shipped + burden gate passed.
|
||||
**Status:** approved design → write plan next.
|
||||
|
||||
This was triggered by a side-by-side: acdream's inventory vs. a retail client (`+Je`). The gaps the
|
||||
user flagged — "scroll bar, background all the way, slots for backpacks" — are the 2D window finish.
|
||||
The 3D paperdoll doll is the heavier, separable Stage 2.
|
||||
|
||||
---
|
||||
|
||||
## 1. Goal / non-goals
|
||||
|
||||
**Goal.** Make the inventory window match retail's 2D presentation:
|
||||
1. The "Contents of Backpack" grid **clips to its panel and scrolls** (today it renders every row
|
||||
unclipped, so a full pack overflows off-window — which is *also* why picked-up items "don't appear"
|
||||
and why the backdrop looks torn at the bottom).
|
||||
2. The dark **backdrop covers the whole window** (no grass showing through).
|
||||
3. The **side-bag selector** renders as a proper slot column (bags + empty frames), like retail.
|
||||
|
||||
**Non-goals (deferred):**
|
||||
- **Paperdoll** (3D character doll + per-slot equip silhouettes) → **Stage 2** (needs a Core→App
|
||||
`UiViewport` render seam + `0x10000032` `UiItemSlot` registration). Research:
|
||||
`docs/research/2026-06-16-equipment-paperdoll-deep-dive.md`.
|
||||
- **Drag-to-drop / drag-from-inventory** → B-Drag.
|
||||
- The **side-bag column's own scrollbar** (`0x100001CB`): 7 slots fit the 252px column exactly, so
|
||||
no scroll is needed there; leave it inert.
|
||||
|
||||
---
|
||||
|
||||
## 2. Grounding (from the dat layout dumps — authoritative geometry)
|
||||
|
||||
Read from `.layout-dumps/inventory-0x21000023.txt`, `items3d-0x21000021.txt`, `backpack-0x21000022.txt`:
|
||||
|
||||
- **Outer frame `0x21000023`** (300×362): the **backdrop `0x100001D0`** is **X=0,Y=0, 300×362**
|
||||
(full window), Type 3, Alphablend sprite `0x06004D0A`, ZLevel 100. Three sub-windows mount inside:
|
||||
paperdoll `0x100001CD` (0,23 224×214), backpack `0x100001CE` (239,23 61×339), 3D-items
|
||||
`0x100001CF` (0,237 234×120).
|
||||
- **3D-items `0x21000021`** (234×120): **contents grid `0x100001C6` = X=15,Y=20, 192×96** (exactly
|
||||
6 cols × 3 rows of 32px); **scrollbar `0x100001C7` = X=207,Y=20, 16×96**, Type 0 inheriting base
|
||||
layout `0x2100003E` (the scrollbar base → resolves to a Type-11 `UiScrollbar` via the inheritance
|
||||
chain), ZLevel 9 (just behind the grid's 10).
|
||||
- **Backpack `0x21000022`** (61×339): main-pack cell `0x100001C9` (6,32 36×36); **side-bag column
|
||||
`0x100001CA` = X=6,Y=73, 36×252** (= 7 slots of 36px), inherits the ItemList base `0x2100003D`;
|
||||
side-bag scrollbar gutter `0x100001CB` (41,73 16×252) — unused (7 slots fit). Burden meter
|
||||
`0x100001D9` + captions `0x100001D7/D8` (already wired by B-Controller).
|
||||
|
||||
**Key deduction:** the backdrop is geometrically full-window, so the "torn" look is the **unclipped
|
||||
grid overflowing below Y=362** (41 items / 6 cols = 7 rows × 32 = 224px, drawn from abs Y≈257 down to
|
||||
≈481, far past the frame's 362 bottom). Clipping the grid (component A) removes that overflow and the
|
||||
torn-bottom backdrop with it.
|
||||
|
||||
---
|
||||
|
||||
## 3. Components
|
||||
|
||||
### A — Contents-grid clip + scroll (the core)
|
||||
|
||||
**A1. `UiItemList` gains a clip+scroll capability**, backed by the existing **`UiScrollable`** model
|
||||
(the same one `UiText`/the chat transcript + `UiScrollbar` use — do not invent a second scroll model).
|
||||
- The list clips cell drawing to its own rect (`0x100001C6` = 192×96 = 3 visible rows). Content
|
||||
height = `ceil(count / Columns) × CellHeight`; view height = the list's `Height`.
|
||||
- A scroll offset (whole rows, or pixels) shifts the cells up; cells fully outside the view are not
|
||||
drawn (scissor-clip via `UiRenderContext`, mirroring the transcript's clip).
|
||||
- Mouse-wheel over the list scrolls it (reuse the wheel→`UiScrollable` path the transcript uses).
|
||||
- `UiScrollable` exposes the thumb-size + position ratios the bound `UiScrollbar` reads (already its
|
||||
contract).
|
||||
|
||||
**A2. `InventoryController` binds the gutter scrollbar.** Resolve `0x100001C7` from the layout; it
|
||||
is built by `DatWidgetFactory` as a Type-11 `UiScrollbar` (via its `0x2100003E` base). Bind it to
|
||||
`_contentsGrid`'s `UiScrollable` — exactly as `ChatWindowController` binds its transcript scrollbar
|
||||
(`ChatWindowController.cs:237-254`). If `0x100001C7` does not resolve to a `UiScrollbar` (inheritance
|
||||
surprise), the plan's first step pins why before wiring.
|
||||
|
||||
**Acceptance:** with > 18 loose items the grid shows 3 rows + a working scrollbar; picked-up items are
|
||||
reachable by scrolling; the grid no longer draws past the frame; the bottom backdrop is intact.
|
||||
|
||||
### B — Backdrop full coverage
|
||||
|
||||
The backdrop `0x100001D0` is already full-window (300×362). **Primary fix is A** (clipping the grid
|
||||
removes the overflow that tears the bottom). After A lands, the implementer verifies at the visual
|
||||
gate that the dark backing covers the whole window. **If a residual gap remains** (e.g. the Alphablend
|
||||
backdrop sprite drawn at native size instead of filled to the element's 300×362 bounds, or a sub-window
|
||||
region uncovered), root-cause it then — the candidate is `UiDatElement`'s Alphablend draw not
|
||||
stretching/tiling to bounds. This is a **root-cause-then-fix** task gated on the post-A screenshot, not
|
||||
a pre-committed code change (a render-coverage bug must be confirmed visually before fixing).
|
||||
|
||||
**Acceptance:** no world/grass shows through the inventory window anywhere (matches retail's solid
|
||||
backing).
|
||||
|
||||
### C — Side-bag slot column
|
||||
|
||||
The side-bag column `0x100001CA` (36×252 = 7 slots) currently renders **only the actual side bags**
|
||||
(via `InventoryController.Populate`'s `isBag → _containerList` branch), so a character with one bag
|
||||
shows one lone cell. Retail shows the bag(s) **plus empty slot frames** filling the column.
|
||||
|
||||
- `InventoryController.Populate`: after adding the player's side bags to `_containerList`, **pad with
|
||||
empty `UiItemSlot`s** up to the slot count. Slot count = the column capacity (252 / cellPitch = 7),
|
||||
optionally clamped to the player's `ContainersCapacity` when known (> 0); default to the 7-slot
|
||||
column height otherwise. Empty cells draw the empty-slot art already (the toolbar empty-slot path).
|
||||
- **Cell pitch = 36** for `_containerList` + `_topContainer` (match `0x100001C9`/`0x100001CA`'s 36px
|
||||
geometry), not the 32px used for the contents grid. (Today the controller sets `CellPx = 32` for all
|
||||
three — split the contents-grid pitch (32) from the backpack-column pitch (36).)
|
||||
|
||||
**Acceptance:** the side-bag column shows the character's bag(s) in the top slot(s) + empty frames
|
||||
below, a clean column like retail's.
|
||||
|
||||
---
|
||||
|
||||
## 4. Design decisions
|
||||
|
||||
- **Reuse `UiScrollable`, don't fork it.** It already models content/view/offset + thumb ratios and is
|
||||
battle-tested by the chat transcript. `UiItemList` becomes a second consumer. (DRY; matches the
|
||||
widget-generalization ethos.)
|
||||
- **Scroll by whole rows vs. pixels:** scroll by **pixels** (smooth), clamped so the last row aligns to
|
||||
the view bottom — matches the transcript's pixel scroll and avoids a janky row-snap. (Plan may choose
|
||||
row-snap if the dat/retail behavior says so; default pixels.)
|
||||
- **Backdrop fix is gated on the post-A screenshot** (decision §B) — don't pre-change the Alphablend
|
||||
draw before confirming A didn't already fix it; avoids touching shared `UiDatElement` render for
|
||||
every window without cause.
|
||||
- **Side-bag slot count from `ContainersCapacity` when known, else 7** — the column is physically 7
|
||||
slots; clamping to capacity matches retail (you see only as many slots as you can use) without a wire
|
||||
dependency (capacity ships in the weenie header, already parsed).
|
||||
|
||||
---
|
||||
|
||||
## 5. Testing
|
||||
|
||||
- **`UiItemList` clip/scroll (unit, App.Tests):** content-height for N cells; the visible-cell index
|
||||
range at scroll offset 0 and at max offset; wheel delta updates the offset (clamped); `UiScrollable`
|
||||
thumb/position ratios for a known content/view. (No GL — assert the model + which cells are in-view.)
|
||||
- **`InventoryController` (unit, App.Tests, `BuildLayout` harness):** the side-bag column renders
|
||||
`bags + empties = slotCount` cells (filled first, then empty); the contents grid is bound to the
|
||||
scrollbar (the `0x100001C7` widget's scroll model is the grid's). A >18-item grid reports content
|
||||
height > view height (scrollable).
|
||||
- **Backdrop:** visual gate only (no unit test).
|
||||
- Full `dotnet build` + `dotnet test` green; existing inventory tests (incl. the B-Wire burden tests)
|
||||
unbroken.
|
||||
|
||||
---
|
||||
|
||||
## 6. Divergence register
|
||||
|
||||
- If the side-bag slot count uses a fallback (7) when `ContainersCapacity` is absent, add a row noting
|
||||
the approximation. If B's residual fix stretches the Alphablend backdrop in a way that differs from
|
||||
retail's exact draw, add a row. Otherwise no new deviations (faithful 2D layout). Any row lands in the
|
||||
same commit as its deviation (register rule).
|
||||
|
||||
---
|
||||
|
||||
## 7. Out of scope (explicit)
|
||||
|
||||
Paperdoll doll + equip-slot silhouettes (Stage 2); drag-to-drop / drag-from-inventory (B-Drag); the
|
||||
side-bag column scrollbar `0x100001CB` (inert — 7 slots fit); contents-grid drag-reorder; stack-quantity
|
||||
overlays.
|
||||
|
||||
---
|
||||
|
||||
## 8. Acceptance + visual gate
|
||||
|
||||
- [ ] Contents grid clips to 3 rows + scrolls (wheel + scrollbar); picked-up items reachable; no
|
||||
overflow past the frame.
|
||||
- [ ] Backdrop covers the whole window (no grass through), confirmed at the gate.
|
||||
- [ ] Side-bag column shows bag(s) + empty slot frames (36px pitch).
|
||||
- [ ] `dotnet build` + `dotnet test` green.
|
||||
- [ ] **Visual gate:** open F12 with a full pack → matches retail screenshot 2 (minus the doll):
|
||||
scrollable grid, solid backdrop, bag-slot column.
|
||||
|
||||
Then Stage 2 (paperdoll) gets its own brainstorm → spec → plan.
|
||||
272
docs/superpowers/specs/2026-06-21-d2b-inventory-wire-design.md
Normal file
272
docs/superpowers/specs/2026-06-21-d2b-inventory-wire-design.md
Normal file
|
|
@ -0,0 +1,272 @@
|
|||
# D.2b-B B-Wire — inventory wire layer (design / spec)
|
||||
|
||||
**Date:** 2026-06-21
|
||||
**Phase:** D.2b inventory, sub-phase **B-Wire** (follows B-Controller, shipped `c38f098`).
|
||||
**Branch:** `claude/hopeful-maxwell-214a12`.
|
||||
**Predecessor handoff:** `docs/research/2026-06-21-d2b-bcontroller-shipped-next-handoff.md` §4(a).
|
||||
**Research oracle:** `docs/research/2026-06-16-inventory-deep-dive.md` §4 (the wire catalog,
|
||||
with CONFIRMED ACE `file:line` + holtburger fixtures per format).
|
||||
**Status:** approved design → write plan next.
|
||||
|
||||
---
|
||||
|
||||
## 1. Goal / non-goals
|
||||
|
||||
**Goal.** Make the inventory **wire layer** retail-faithful and complete:
|
||||
|
||||
1. Deliver the player's **server-authoritative properties** to the player's `ClientObject`,
|
||||
so the burden bar reads the wire `EncumbranceVal` instead of a client-side estimate.
|
||||
(Retires divergence **AP-48** and **AP-49**.)
|
||||
2. Fix two **latent parse bugs** in existing inbound handlers (dropped fields).
|
||||
3. Add the missing inventory **builders** (C→S) and **parsers** (S→C) so the next
|
||||
sub-phases (B-Drag, container-open) have their wire ready and tested.
|
||||
|
||||
**Non-goals (deferred to the consuming sub-phase).** The *behavior* that calls these
|
||||
builders/parsers: drag-release → DropItem/GetAndWieldItem (B-Drag), opening a side-pack /
|
||||
ground container → ViewContents into a second `UiItemList` (container-open), speculative-move
|
||||
rollback UI (B-Drag). B-Wire lands wire + parsers + tests + minimal table-apply; it does not
|
||||
build the UI actions. A parser with no UI consumer yet registers as **parsed-and-applied to the
|
||||
object table** (or parsed-and-logged where there is genuinely nothing to apply) so the wire is
|
||||
correct and the later phase only adds behavior.
|
||||
|
||||
**Mandatory workflow (binding on the implementer + any subagent).** Every wire format goes
|
||||
through **grep-named → cross-ref (ACE + holtburger) → pseudocode → port → conformance test**.
|
||||
The deep-dive §4 already did the cross-ref and cites the ACE writer `file:line` + holtburger
|
||||
fixture for each format; re-confirm each against `docs/research/named-retail/` before porting
|
||||
(the named symbols to anchor on are listed per component below). Do not guess a field order.
|
||||
|
||||
---
|
||||
|
||||
## 2. Background — the root cause (why the burden bar is wrong today)
|
||||
|
||||
The burden binding is **already correct**: `InventoryController.RefreshBurden`
|
||||
([InventoryController.cs:215](../../../src/AcDream.App/UI/Layout/InventoryController.cs)) reads
|
||||
`EncumbranceVal` (PropertyInt 5) from `_objects.Get(playerGuid).Properties.Ints` and only falls
|
||||
back to `ClientObjectTable.SumCarriedBurden` when it is absent. The value **never arrives**.
|
||||
Three independent gaps, all confirmed in source:
|
||||
|
||||
1. **Login path.** `GameEventWiring`'s PlayerDescription (`0x0013`) handler
|
||||
([GameEventWiring.cs:285](../../../src/AcDream.Core.Net/GameEventWiring.cs)) parses the player's
|
||||
full PropertyInt table into a `PropertyBundle` (`PlayerDescriptionParser` → `bundle.Ints[5]`)
|
||||
but **never applies that bundle to the player's `ClientObject`** — it extracts vitals /
|
||||
attributes / skills / spells / enchantments and records item *membership*, then drops the
|
||||
player's own property bundle. (The "PD is a membership manifest, not the data source" comment
|
||||
at line 403 is about *items* — whose weenie data comes from CreateObject — and does **not**
|
||||
apply to the player's own stats, which legitimately come from PD.)
|
||||
2. **Live path.** Burden changes (pick up / drop) ride `PrivateUpdatePropertyInt (0x02CD)` —
|
||||
the player's own object, **no guid on the wire**. acdream does not parse `0x02CD` at all (the
|
||||
`PublicUpdatePropertyInt` 0x02CE doc-comment says exactly this).
|
||||
3. **Apply gate.** Even parsed `0x02CE` updates only reach the table when
|
||||
`Property == UiEffects` — [ObjectTableWiring.cs:28](../../../src/AcDream.Core.Net/ObjectTableWiring.cs)
|
||||
hard-gates every other int out.
|
||||
|
||||
A **consumer-side** bug compounds this: `InventoryController.Concerns(o)` returns *false* for the
|
||||
player's own object (its `ContainerId`/`WielderId` are not the player guid), so even once
|
||||
`EncumbranceVal` updates live, the burden bar would not refresh. Fixed in C1 below.
|
||||
|
||||
---
|
||||
|
||||
## 3. Components
|
||||
|
||||
All wire code lives in `AcDream.Core.Net` (pure data, no GL). Tests in
|
||||
`tests/AcDream.Core.Net.Tests` (+ a burden end-to-end test in `tests/AcDream.App.Tests`).
|
||||
|
||||
### C1 — Player-property delivery (the core; retires AP-48/AP-49)
|
||||
|
||||
**C1a — Login delivery.** In the PD handler ([GameEventWiring.cs:285](../../../src/AcDream.Core.Net/GameEventWiring.cs)),
|
||||
after the existing attribute/skill/membership extraction, apply the parsed player `PropertyBundle`
|
||||
to the player's `ClientObject`. Requires:
|
||||
- A new `Func<uint> playerGuid` threaded into the GameEvent wiring (the local player's server
|
||||
guid; `GameWindow._playerServerGuid`, already passed elsewhere).
|
||||
- A new `ClientObjectTable.UpsertProperties(uint guid, PropertyBundle bundle)` — **create-if-absent
|
||||
then merge** (PD may arrive before the player's own `CreateObject`; the existing `UpdateProperties`
|
||||
no-ops on an unknown object, [ClientObjectTable.cs:152](../../../src/AcDream.Core/Items/ClientObjectTable.cs)).
|
||||
The later `CreateObject` `Ingest` is already a merge-upsert, so either arrival order converges.
|
||||
- Apply the **whole** bundle (Ints/Floats/Bools/Int64s/Strings/DataIds/InstanceIds), not a
|
||||
whitelist — the bundle is dictionaries; storing the player's full property set is faithful
|
||||
(retail keeps them on the ACCWeenieObject) and future-proofs Str/aug/etc. (Decision §4.1.)
|
||||
|
||||
**C1b — Live delivery.** New `PrivateUpdatePropertyInt (0x02CD)` GameMessage parser
|
||||
(`src/AcDream.Core.Net/Messages/PrivateUpdatePropertyInt.cs`, mirroring `PublicUpdatePropertyInt.cs`):
|
||||
- Wire body: `u32 opcode(0x02CD), u8 seq, u32 property, i32 value` — **no guid** (size 13).
|
||||
Seq is a single byte (same `ByteSequence` as 0x02CE; not honored, latest-wins, DR-4).
|
||||
- `WorldSession` dispatches it (new `else if (op == PrivateUpdatePropertyInt.Opcode)` in the
|
||||
GameMessage switch, [WorldSession.cs:954](../../../src/AcDream.Core.Net/WorldSession.cs)) →
|
||||
new event `PlayerIntPropertyUpdated(uint Property, int Value)` (no guid — implicitly the player).
|
||||
- `ObjectTableWiring.Wire` gains the `Func<uint> playerGuid` and subscribes
|
||||
`PlayerIntPropertyUpdated` → `table.UpdateIntProperty(playerGuid(), property, value)`.
|
||||
- Anchor: named-retail `ACCWeenieObject` int-property apply (the client-side `0x02CD` consumer);
|
||||
ACE writer `GameMessagePrivateUpdatePropertyInt`.
|
||||
|
||||
**C1c — Apply gate.** Loosen [ObjectTableWiring.cs:26-30](../../../src/AcDream.Core.Net/ObjectTableWiring.cs)
|
||||
so the `0x02CE` (`ObjectIntPropertyUpdated`) path applies **all** ints via
|
||||
`table.UpdateIntProperty(u.Guid, u.Property, u.Value)` (which stores in the bundle and still
|
||||
mirrors UiEffects→`Effects`). (Decision §4.2.)
|
||||
|
||||
**C1d — Consumer refresh.** In `InventoryController.Concerns(o)`
|
||||
([InventoryController.cs:115](../../../src/AcDream.App/UI/Layout/InventoryController.cs)) also return
|
||||
true when `o.ObjectId == playerGuid` so a live player-property update refreshes the burden bar.
|
||||
(The burden source IS the player object; today it is excluded.)
|
||||
|
||||
### C2 — Latent-bug fixes (existing inbound handlers)
|
||||
|
||||
**C2a — `InventoryPutObjInContainer (0x0022)`** GameEvent: read the dropped **4th** field
|
||||
`containerType` (`u32 itemGuid, u32 containerGuid, u32 placement, u32 containerType`).
|
||||
`GameEvents.ParsePutObjInContainer` stops after 3 u32s today. Oracle: ACE
|
||||
`GameEventItemServerSaysContainId.cs` + holtburger `events.rs` (fixture slot=3 type=1).
|
||||
|
||||
**C2b — `InventoryServerSaveFailed (0x00A0)`** GameEvent: read the dropped **`weenieError`** u32
|
||||
(`u32 itemGuid, u32 weenieError`). `GameEvents.ParseInventoryServerSaveFailed` reads only the guid.
|
||||
Oracle: ACE `GameEventInventoryServerSaveFailed.cs` + holtburger `events.rs:147`.
|
||||
|
||||
### C3 — New C→S builders (`InventoryActions.cs`, matching the existing builder style)
|
||||
|
||||
All ride the `0xF7B1` GameAction envelope (`u32 0xF7B1; u32 seq; u32 subOpcode; …`). Anchor each
|
||||
against ACE `GameAction*/…Handle` + holtburger `inventory/actions.rs` (both cited in deep-dive §4.1).
|
||||
|
||||
| Builder | Opcode | Body |
|
||||
|---|---|---|
|
||||
| `BuildDropItem` | `0x001B` | `u32 itemGuid` |
|
||||
| `BuildGetAndWieldItem` | `0x001A` | `u32 itemGuid, u32 equipMask` |
|
||||
| `BuildNoLongerViewingContents` | `0x0195` | `u32 containerGuid` |
|
||||
|
||||
Opcode source CONFIRMED: ACE `GameActionType.cs` (`GetAndWieldItem=0x001A, DropItem=0x001B,
|
||||
NoLongerViewingContents=0x0195`). Add `WorldSession.Send*` wrappers (mirroring
|
||||
`SendAddShortcut`/`SendRemoveShortcut`) so GameWindow can inject them via `_liveSession?.Send*`.
|
||||
|
||||
### C4 — New S→C parsers
|
||||
|
||||
**C4a — `ViewContents (0x0196)` — GameEvent** (rides `0xF7B0`). New `GameEvents.ParseViewContents`
|
||||
+ a `GameEventType.ViewContents = 0x0196` entry. Body: `u32 containerGuid, u32 count,
|
||||
[u32 guid, u32 containerType]×count` (a leading guid then a PackableList<ContentProfile>).
|
||||
Oracle: ACE `GameEventViewContents.cs` + named-retail `ClientUISystem::OnViewContents`
|
||||
(`?OnViewContents@ClientUISystem@@…PackableList<ContentProfile>`) + holtburger `events.rs`.
|
||||
No UI consumer yet → parse + apply to the table where meaningful (the listed guids are the
|
||||
container's contents) or parse-and-log; container-open phase wires it to a second `UiItemList`.
|
||||
|
||||
**C4b — `SetStackSize (0x0197)` — top-level GameMessage** (UIQueue), **not** a GameEvent. New
|
||||
`src/AcDream.Core.Net/Messages/SetStackSize.cs` + dispatch in `WorldSession`'s GameMessage switch.
|
||||
Body: `u32 opcode(0x0197), u8 seq, u32 guid, u32 stackSize, u32 value` (size 17 ⇒ byte seq).
|
||||
Apply via the table (update the object's `StackSize` + `Value`; add a typed
|
||||
`ClientObjectTable.UpdateStackSize(guid, stackSize, value)` or route through the property path).
|
||||
Oracle: ACE `GameMessageSetStackSize.cs` (writer) + named-retail
|
||||
`ACCWeenieObject::ServerSaysSetStackSize` (the client consumer).
|
||||
|
||||
**C4c — `InventoryRemoveObject (0x0024)` — top-level GameMessage** (UIQueue), **not** a GameEvent.
|
||||
New `src/AcDream.Core.Net/Messages/InventoryRemoveObject.cs` + dispatch in `WorldSession`'s switch.
|
||||
Body: `u32 opcode(0x0024), u32 guid`. Apply: remove the object from the player's inventory **view**.
|
||||
Confirm evict-vs-unparent against named-retail `ClientUISystem` + ACE send sites before choosing;
|
||||
default to the faithful "no longer in my inventory" semantics (unparent from its container + fire
|
||||
the table's refresh event, so the grid drops the cell) rather than a hard global evict unless the
|
||||
oracle shows full destruction. Oracle: ACE `GameMessageInventoryRemoveObject.cs`.
|
||||
|
||||
> **Namespace caution.** `SetStackSize 0x0197` / `InventoryRemoveObject 0x0024` are **GameMessage
|
||||
> opcodes** (top-level `WorldSession` switch, like `0x02CE`/`0xF745`). `ViewContents 0x0196` /
|
||||
> `InventoryPutObjInContainer 0x0022` are **GameEvent eventTypes** (inside the `0xF7B0` envelope,
|
||||
> dispatched by `GameEvents.Dispatch`). Adjacent numbers, different dispatchers — do not cross them.
|
||||
|
||||
### C5 — Registration
|
||||
|
||||
- **GameEventWiring.WireAll** ([GameEventWiring.cs](../../../src/AcDream.Core.Net/GameEventWiring.cs)):
|
||||
register the GameEvent parsers that exist (or are added) but are not dispatched today:
|
||||
`ViewContents (0x0196)`, `InventoryPutObjectIn3D (0x019A)`, `CloseGroundContainer (0x0052)`,
|
||||
`InventoryServerSaveFailed (0x00A0)`. Where there is no UI consumer yet, the handler applies to
|
||||
the table if meaningful, else logs at debug — the registration makes the wire correct so B-Drag
|
||||
only adds behavior.
|
||||
- **WorldSession** GameMessage switch: dispatch `PrivateUpdatePropertyInt (0x02CD)`,
|
||||
`SetStackSize (0x0197)`, `InventoryRemoveObject (0x0024)`.
|
||||
- **GameWindow** wiring: pass `_playerServerGuid` into `ObjectTableWiring.Wire` and the GameEvent
|
||||
wiring; inject the new `WorldSession.Send*` builders where the existing shortcut sends are wired.
|
||||
|
||||
---
|
||||
|
||||
## 4. Design decisions
|
||||
|
||||
### 4.1 Apply the whole player `PropertyBundle` at login (vs. whitelist)
|
||||
**Chosen: whole bundle via upsert.** The bundle is plain dictionaries; storing the player's full
|
||||
property set mirrors retail (all live on the ACCWeenieObject) and means future readers (Str, aug,
|
||||
coin value, etc.) get their value for free without re-touching the PD handler. Whitelisting
|
||||
EncumbranceVal+aug would be smaller but would re-introduce the same "value never arrives" class of
|
||||
bug for the next property we need. No downside: the player object is the client's own authoritative
|
||||
store.
|
||||
|
||||
### 4.2 Loosen the int-apply gate to all ints (vs. extend the whitelist)
|
||||
**Chosen: apply all ints.** Same reasoning. `UpdateIntProperty` already stores any int in the
|
||||
bundle and only special-cases UiEffects for the typed mirror; the gate in `ObjectTableWiring` is the
|
||||
sole thing dropping non-UiEffects ints. Removing it is the faithful behavior (the server is the
|
||||
authority on object properties). Typed-field mirrors stay opt-in per property.
|
||||
|
||||
### 4.3 Consumer-less parsers register now (vs. defer)
|
||||
**Chosen: register now, apply-or-log.** The user chose the full wire pass to unblock B-Drag in one
|
||||
trip. A registered parser that applies to the object table (or logs where there's nothing to apply)
|
||||
keeps the wire correct and conformance-tested; the consuming phase adds only UI behavior. Any parser
|
||||
that ends up a pure no-op gets a one-line divergence/TODO note so it isn't mistaken for "wired".
|
||||
|
||||
---
|
||||
|
||||
## 5. Testing / conformance
|
||||
|
||||
One conformance test per format, golden bytes from the ACE writer / holtburger fixture cited above:
|
||||
|
||||
- `PrivateUpdatePropertyInt.TryParse` — valid 0x02CD (no guid) → (property, value); wrong opcode →
|
||||
null; truncation → null.
|
||||
- **Login delivery** — feed a PlayerDescription payload carrying PropertyInt 5 (and aug 0xE6) →
|
||||
assert the player `ClientObject.Properties.Ints[5]` is set after the handler runs (covers
|
||||
`UpsertProperties` create-if-absent both orders: PD-before-CreateObject and after).
|
||||
- **Gate** — a 0x02CE for a non-UiEffects int lands in the target object's bundle.
|
||||
- `ParsePutObjInContainer` — 4-field read (the 4th = containerType).
|
||||
- `ParseInventoryServerSaveFailed` — reads guid + weenieError.
|
||||
- `BuildDropItem` / `BuildGetAndWieldItem` / `BuildNoLongerViewingContents` — exact byte layout
|
||||
(envelope + body), round-tripped against the ACE handler's expected read.
|
||||
- `ParseViewContents` — guid + count + N×{guid,containerType}; zero-count edge.
|
||||
- `SetStackSize` parse — op + byte-seq + guid + stackSize + value (size 17).
|
||||
- `InventoryRemoveObject` parse — op + guid.
|
||||
- **Burden end-to-end** (App.Tests) — with the wire `EncumbranceVal` present, `RefreshBurden` uses
|
||||
it (not `SumCarriedBurden`); a live player-int update fires a burden refresh (C1d).
|
||||
|
||||
All existing tests stay green (App 532 / Core 1526 baseline). `dotnet build` + `dotnet test` green
|
||||
before the phase is claimed.
|
||||
|
||||
---
|
||||
|
||||
## 6. Divergence register changes (`docs/architecture/retail-divergence-register.md`)
|
||||
|
||||
- **Retire AP-48** (client-side `SumCarriedBurden` fallback) — burden now reads the wire value.
|
||||
Keep `SumCarriedBurden` in code as a defensive fallback for "wire value genuinely absent" and
|
||||
note that in the row's deletion commit; if a residual divergence remains (fallback ever used),
|
||||
reword the row instead of deleting.
|
||||
- **Retire AP-49** (carry-aug unwired) — the aug (`0xE6`) now arrives via the PD bundle.
|
||||
- No new deviations expected (faithful ports). If C4c's evict-vs-unparent choice, or any
|
||||
consumer-less no-op, ends up an approximation, add its row **in the same commit** per the
|
||||
register rules.
|
||||
|
||||
---
|
||||
|
||||
## 7. Out of scope (explicit)
|
||||
|
||||
- Drag-to-drop / drag-to-wield **behavior** (B-Drag wires the C3 builders to drag-release).
|
||||
- Opening side-packs / ground containers into a second `UiItemList` (container-open wires C4a).
|
||||
- Speculative-move rollback **UI** (B-Drag; C2b lands the parser only).
|
||||
- Paperdoll `UiViewport` + per-slot art (Sub-phase C).
|
||||
- `CreateObject` field extraction — **already done** (`ObjectTableWiring.ToWeenieData` captures
|
||||
IconId/StackSize/Value/capacities/Burden); the deep-dive's "discards" note is stale. Verify no
|
||||
field is still `_`-discarded in `CreateObject.TryParse`; extend only if a real gap remains.
|
||||
|
||||
---
|
||||
|
||||
## 8. Acceptance criteria
|
||||
|
||||
- [ ] Login PD delivers the player's PropertyInt table to the player `ClientObject` (EncumbranceVal
|
||||
+ aug present after login), via `UpsertProperties` (both arrival orders covered by tests).
|
||||
- [ ] `PrivateUpdatePropertyInt (0x02CD)` parsed + dispatched + applied to the player object; the
|
||||
apply gate passes all ints.
|
||||
- [ ] Burden bar reads the wire `EncumbranceVal` and refreshes live on a player-int update (C1d).
|
||||
- [ ] `0x0022` reads containerType; `0x00A0` reads weenieError.
|
||||
- [ ] Builders `0x001B` / `0x001A` / `0x0195` added with `WorldSession.Send*` wrappers + byte tests.
|
||||
- [ ] Parsers `ViewContents 0x0196` / `SetStackSize 0x0197` / `InventoryRemoveObject 0x0024` added,
|
||||
dispatched in the correct dispatcher, applied-or-logged.
|
||||
- [ ] `WireAll` registers ViewContents / InventoryPutObjectIn3D / CloseGroundContainer /
|
||||
InventoryServerSaveFailed.
|
||||
- [ ] Every AC-specific format cites its named-retail symbol/address + ACE `file:line` in a comment.
|
||||
- [ ] AP-48 + AP-49 retired in the register; any new approximation has a row.
|
||||
- [ ] `dotnet build` + `dotnet test` green; existing tests unbroken.
|
||||
|
|
@ -0,0 +1,151 @@
|
|||
# D.2b inventory container-switching — design
|
||||
|
||||
**Date:** 2026-06-22
|
||||
**Branch:** `claude/hopeful-maxwell-214a12`
|
||||
**Phase:** D.2b retail-UI inventory arc — container-switching (the first inventory feature with a live two-way wire round-trip).
|
||||
**Brainstorm:** this doc. Handoff that scoped it: `docs/research/2026-06-22-container-switching-handoff.md`. SSOT: `claude-memory/project_d2b_retail_ui.md`.
|
||||
|
||||
---
|
||||
|
||||
## Goal
|
||||
|
||||
Click a side bag in the inventory's pack-selector column → the contents grid shows **that bag's** contents (opened server-side via a `Use → ViewContents` round-trip), with the open bag marked by the retail **open-container triangle** and the selected cell marked by the retail **selected-item square**. Click the main-pack cell → back to the main pack.
|
||||
|
||||
This is the first inventory feature that **talks to the server and consumes the reply** (a `Use 0x0036` → `ViewContents 0x0196` round-trip), a step up from the read-only display work so far.
|
||||
|
||||
## Scope
|
||||
|
||||
**In:**
|
||||
1. `_openContainer` state in `InventoryController`; the contents grid shows `GetContents(_openContainer)`.
|
||||
2. Click a side-bag cell → open it: `SendUse(bagGuid)`; on switching away from a previously-open side bag, `SendNoLongerViewingContents(prevGuid)`.
|
||||
3. `ViewContents 0x0196` consumed as a **full REPLACE** of the container's membership (consumes the `GameEventWiring.cs:256` TODO), not the current additive merge.
|
||||
4. Click the main-pack cell (`0x100001C9`) → `_openContainer = player`; the grid shows the main pack again.
|
||||
5. **Open-container triangle** `0x06005D9C` on the cell whose `ItemId == _openContainer`.
|
||||
6. **Selected-item square** `0x06004D21` on the cell whose `ItemId == _selectedItem` — panel-wide (one selection across grid + column), applied **uniformly** to grid items AND bag cells. **Visual-only** this phase.
|
||||
7. Caption `0x100001C5` follows the open container ("Contents of Backpack" → "Contents of <BagName>").
|
||||
|
||||
**Out (deferred, with reasons):**
|
||||
- **Wiring the green square to the selected-object bar** (toolbar name/info strip) or to global 3D-world selection — the square is *visual-only* this phase. The selection→selected-object-bar integration is its own follow-up (it's a selection-system concern, not container-switching).
|
||||
- **Preferred-pack auto-fit** (opening a pack makes it the auto-add target). Noted in `reference_retail_inventory_paperdoll`; out of the display-only first cut.
|
||||
- **Drag INTO a side bag** (that's B-Drag) and **double-click-to-use** an inventory item (interaction work).
|
||||
- The side-bag column scrollbar `0x100001CB` (inert — 7 fit).
|
||||
|
||||
## Retail anchors (decomp + wire, all verified this session)
|
||||
|
||||
| Concern | Retail anchor | Fact |
|
||||
|---|---|---|
|
||||
| Open a container | `Use 0x0036` (`InteractRequests.BuildUse`) | Use-on-container opens it server-side; ACE replies `ViewContents`. |
|
||||
| Contents reply | `GameEventViewContents` (ACE) | `containerGuid, count, [guid, containerType]×count`. Entries written `OrderBy(PlacementPosition)` — **no explicit slot field; list order encodes the slot.** Our `ParseViewContents` (8 bytes/entry) is correct. |
|
||||
| Close a view | `NoLongerViewingContents 0x0195` (`WorldSession.SendNoLongerViewingContents`) | Sent when switching away from an open side bag. |
|
||||
| Open-container indicator | `UIElement_ItemList::UpdateOpenContainerIndicator` `0x004e3070` → `SetOpenContainerState` `0x004e1200` | shows element `0x10000450` (sprite `0x06005D9C`) on the cell where `item.itemID == openContainerId`. |
|
||||
| Selected-item indicator | `UIElement_ItemList::ItemList_SetSelectedItem` `0x004e2fe0` → `SetSelectedState` `0x004e1240` | shows element `0x10000342` (sprite `0x06004D21`, pixel-confirmed green/yellow frame) on the cell where `item.itemID == selectedItemId`. Uniform across item + container cells. |
|
||||
|
||||
**Indicator reconciliation (user-confirmed retail model — axiom):** the two indicators are orthogonal and can co-occur on one cell. Triangle = "this is the *open* backpack (its contents fill the grid)". Square = "this item is *selected*" — a backpack is just an item, so a clicked bag gets both. Side-bag cells use the 36×36 container prototype `0x1000033F` (triangle child only, no square child) — so the square is drawn as a procedural overlay (see Components / divergence).
|
||||
|
||||
## Components
|
||||
|
||||
### 1. `ClientObjectTable.ReplaceContents` (Core — new)
|
||||
|
||||
```
|
||||
public void ReplaceContents(uint containerId, IReadOnlyList<uint> guids)
|
||||
```
|
||||
|
||||
Full-replace the container's membership so `GetContents(containerId)` returns exactly `guids` in order:
|
||||
- **Detach** each current member NOT in the new set: `ContainerId = 0`, `Reindex`, fire `ObjectMoved(o, containerId, 0)`. (It left the container server-side.)
|
||||
- **Record** each new guid in order: create-if-absent, set `ContainerId = containerId` and `ContainerSlot = index` (reconstructs ACE's `PlacementPosition`), `Reindex`, fire `ObjectAdded` (new) or `ObjectMoved` (existing). containerType is not needed by the grid (dropped).
|
||||
|
||||
Mirrors the existing `RecordMembership`/`Reindex` machinery; the only new behavior is the authoritative-snapshot semantics (flush-then-record).
|
||||
|
||||
### 2. `GameEventWiring` ViewContents handler (Core.Net)
|
||||
|
||||
Replace the additive `foreach … RecordMembership` (`:260`–`:266`) with one `items.ReplaceContents(p.Value.ContainerGuid, [entry.Guid …])`. Delete the `:256` TODO. This is the authoritative full snapshot per ACE.
|
||||
|
||||
### 3. `WorldSession.SendUse` (Core.Net — new)
|
||||
|
||||
```
|
||||
public void SendUse(uint targetGuid) // NextGameActionSequence + BuildUse + SendGameAction
|
||||
```
|
||||
|
||||
A thin wire wrapper (mirrors `SendAddShortcut` etc.). Distinct from `GameWindow.SendUse` (the world-interaction path with autowalk/close-range hold) — opening a pack in your own inventory needs no movement, just the wire.
|
||||
|
||||
### 4. `UiItemSlot` overlays (App)
|
||||
|
||||
Two new procedural overlays in `OnDraw`, modeled exactly on the existing `DragAccept` overlay (resolve via `SpriteResolve`, **guard `id != 0` before resolving** per `feedback_ui_resolve_zero_magenta`):
|
||||
- `bool IsOpenContainer` + `uint OpenContainerSprite = 0x06005D9C` → triangle.
|
||||
- `bool Selected` + `uint SelectedSprite = 0x06004D21` → square.
|
||||
|
||||
Draw order in `OnDraw`: icon/empty → digits → open-container triangle → selected square → drag-accept (transient, stays on top). `UiItemSlot` is already a behavioral leaf that paints DragAccept/digits procedurally, so drawing these procedurally (rather than via prototype state elements) is consistent — and it lets the square render on a 36×36 bag cell whose container prototype lacks the `m_elem_Icon_Selected` child.
|
||||
|
||||
### 5. `InventoryController` (App)
|
||||
|
||||
- **State:** `uint _openContainer` (default `= _playerGuid()`), `uint _selectedItem` (default 0).
|
||||
- **`Populate` split:**
|
||||
- Side-bag column (`_containerList`) + main-pack cell (`_topContainer`): always the **player's** bags (constant across switches; only indicators move). Unchanged logic + padding (AP-52).
|
||||
- Contents grid (`_contentsGrid`): the **open container's** loose items — `GetContents(_openContainer)`, `if (isBag) continue` (bags live in the column; a side bag has no sub-bags so this is a no-op when a bag is open), equipped excluded. Pad to the open container's `ItemsCapacity` (player 102 / side bag 24).
|
||||
- Caption `0x100001C5`: `"Contents of " + (_openContainer == player ? "Backpack" : Get(_openContainer)?.Name ?? "Backpack")`.
|
||||
- Ends with `ApplyIndicators()`.
|
||||
- **Click wiring** (set per-cell in `Populate`, the loop knows the cell's role):
|
||||
- Grid item cell → `SelectItem(guid)`: `_selectedItem = guid`; `ApplyIndicators()` (square moves; no wire, no repopulate).
|
||||
- Container cell (side bag or main-pack) → `OpenContainer(guid)`:
|
||||
- `_selectedItem = guid` (the bag is also selected).
|
||||
- If `guid != _openContainer`: if the *previous* `_openContainer` was a side bag (`!= player && != 0`), `sendNoLongerViewing(prev)`; set `_openContainer = guid`; if `guid != player`, `sendUse(guid)`; `Populate()` (repaint grid for the new container + indicators). Else (same container) `ApplyIndicators()`.
|
||||
- Empty cell (`ItemId == 0`) → no-op.
|
||||
- **`ApplyIndicators()`:** for every cell in the three lists, `cell.Selected = ItemId != 0 && ItemId == _selectedItem`; `cell.IsOpenContainer = ItemId != 0 && ItemId == _openContainer`. (Light: toggles bools on existing cells; no Flush.)
|
||||
- **`Concerns`** += `|| o.ContainerId == _openContainer` — so the `ViewContents`-driven membership changes (which fire `ObjectAdded`/`Moved` for the open container's items) trigger a `Populate()` that fills the grid when the reply lands.
|
||||
|
||||
### 6. `GameWindow` wiring (App)
|
||||
|
||||
`InventoryController.Bind` gains two optional callbacks (default null, so existing tests/callers compile):
|
||||
- `sendUse: g => _liveSession?.SendUse(g)`
|
||||
- `sendNoLongerViewing: g => _liveSession?.SendNoLongerViewingContents(g)`
|
||||
|
||||
Wired at the existing bind site (`GameWindow.cs:2231`), mirroring the toolbar's `sendAddShortcut`/`sendRemoveShortcut`.
|
||||
|
||||
## Data flow
|
||||
|
||||
```
|
||||
click side-bag cell
|
||||
→ _selectedItem = bag (square), _openContainer = bag (triangle)
|
||||
→ switching from another side bag? SendNoLongerViewingContents(prev) [0x0195]
|
||||
→ SendUse(bag) [0x0036]
|
||||
→ Populate(): grid = GetContents(bag) (EMPTY initially) + indicators painted
|
||||
───────────── server ─────────────
|
||||
→ ViewContents(bag, [items]) [0x0196]
|
||||
→ ReplaceContents(bag, guids) → ObjectAdded/Moved
|
||||
→ Concerns(o.ContainerId == _openContainer) → Populate() → grid fills
|
||||
```
|
||||
|
||||
Robust to lazy-load either way: if the bag's contents were already known, the grid fills on the first `Populate()`; if they arrive only on open (the expected retail behavior), the `Concerns` extension fills it when `ViewContents` lands.
|
||||
|
||||
## Divergence register
|
||||
|
||||
- **Retire AP-56** (both indicators now drawn) and the container-switch clause of **AP-53** (side-pack contents now shown; keep/reword the capacity-default part).
|
||||
- **Add** (same commit):
|
||||
- The open-container triangle + selected-item square are drawn as **procedural `UiItemSlot` overlays** keyed by controller state (`_openContainer`/`_selectedItem`), reproducing the retail *keying* (`item.itemID == openContainerId / selectedItemId`) but not via the dat prototype's `m_elem_Icon_OpenContainer`/`m_elem_Icon_Selected` state elements + `SetOpenContainerState`/`SetSelectedState` calls. Lets the square render on the 36×36 container cell (whose prototype lacks the square child). Outcome matches retail.
|
||||
- Inventory item **selection is panel-local and visual-only** — not wired to the selected-object bar (`SelectedObjectController`) or to global 3D-world selection. (Deferred to the selection follow-up.)
|
||||
|
||||
## Test plan
|
||||
|
||||
- **Core (`ClientObjectTable.Tests`):** `ReplaceContents` — records a fresh list in order; a second replace with a *smaller* list detaches the dropped guids (`GetContents` shrinks, dropped items `ContainerId == 0`); ordering follows list index.
|
||||
- **Core.Net (`GameEventWiring`/parse tests):** `ViewContents` → full replace (a second smaller `ViewContents` shrinks membership — proves it's not the old additive merge).
|
||||
- **App (`InventoryControllerTests`):** extend the existing fake-layout harness (`BuildLayout`/`Bind`) — add optional `sendUse`/`sendNoLongerViewing` capture lambdas. Cases:
|
||||
- default `_openContainer` = player; grid shows player contents (existing tests still pass).
|
||||
- click a side-bag cell → `sendUse(bag)` fired; grid shows `GetContents(bag)`; caption follows the bag name.
|
||||
- switch bag A → bag B → `sendNoLongerViewing(A)` then `sendUse(B)`.
|
||||
- click main-pack cell after a bag → `sendNoLongerViewing(bag)`, **no** `sendUse`; grid back to player.
|
||||
- indicators: `IsOpenContainer` on the open cell, `Selected` on the selected cell; select-only grid click moves the square, leaves `_openContainer` + grid unchanged, sends no wire.
|
||||
- **App (`UiItemSlot`):** overlay state — `Selected`/`IsOpenContainer` gate the draw; `id != 0` guard.
|
||||
|
||||
Run the **full** suite at the phase boundary (not just filtered batches — the B-Wire process lesson).
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
- [ ] Decomp anchors cited in code comments (the `0x004exxxx` functions + sprite ids + the ACE wire ref).
|
||||
- [ ] Divergence register updated in the same commit (AP-56 retired, AP-53 reworded, new rows added).
|
||||
- [ ] `dotnet build` + full `dotnet test` green.
|
||||
- [ ] **Visual gate (the oracle):** F12 → click a side bag → grid swaps to its contents; the open bag shows the triangle; the clicked cell shows the green/yellow square; click another bag → grid + indicators follow; click the main-pack cell → back to the main pack. WireMCP capture of `127.0.0.1:9000` confirms the `Use 0x0036 → ViewContents 0x0196` round-trip (and settles lazy-load-on-open).
|
||||
- [ ] SSOT (`project_d2b_retail_ui.md`) + roadmap updated.
|
||||
|
||||
## Open verification (at the live gate, not a blocker)
|
||||
|
||||
Whether a side bag's contents are pre-known or arrive only on open — confirmed by the WireMCP/log capture at the gate. The design works either way; this just documents the observed behavior + retires the handoff's open question.
|
||||
145
docs/superpowers/specs/2026-06-22-d2b-empty-slot-art-design.md
Normal file
145
docs/superpowers/specs/2026-06-22-d2b-empty-slot-art-design.md
Normal file
|
|
@ -0,0 +1,145 @@
|
|||
# D.2b — Inventory empty-slot art: faithful cell-template resolution (design)
|
||||
|
||||
**Date:** 2026-06-22
|
||||
**Status:** DESIGN — approach + scope approved in the 2026-06-22 brainstorm. No code yet.
|
||||
**Branch:** `claude/hopeful-maxwell-214a12` (tip after the inventory window-finish Stage 1).
|
||||
**Closes (inventory portion):** the OPEN issue *"Inventory + equipment slots show the wrong empty-slot background art"* (`docs/ISSUES.md`). The paperdoll equip silhouettes stay Sub-phase C.
|
||||
**Line numbers drift — grep the symbol.**
|
||||
|
||||
---
|
||||
|
||||
## 1. Problem
|
||||
|
||||
At the inventory window's visual gate the empty cells in the **contents grid** (`0x100001C6`) and the **side-bag column** (`0x100001CA`) render the wrong background — the generic toolbar empty square — instead of the retail pack-slot art.
|
||||
|
||||
The handoff framed the fix as "swap the toolbar's `0x060074CF` for template `0x21000037`'s empty state." Investigation showed that framing is malformed:
|
||||
|
||||
- **`0x060074CF` is not "the toolbar's sprite"** — it is the *generic* `ItemSlot_Empty` shared by many `0x21000037` catalog elements (the toolbar shortcut prototypes derive from base `0x1000045C`/`0x10000445` and use it).
|
||||
- **`0x21000037` is a catalog of ~50 per-slot-kind empty backgrounds**, not "a template with an empty state": the generic square, ~25 equip-slot silhouettes (`0x06006Dxx`/`0x06000Fxx`/…), a numbered group, and standalone `UIElement_UIItem` (class `0x10000032`) cell **prototypes**.
|
||||
- **The dat doesn't say which prototype a list uses.** The itemlist base `0x2100003D` (element `0x10000339`) and the contents grid `0x100001C6` are bare 32×32 containers; the cell art is bound at *runtime* by `UIElement_ItemList`.
|
||||
- **acdream synthesizes bare cells** (`new UiItemSlot` with the hardcoded `EmptySprite = 0x060074CF`), bypassing retail's per-list cell-template inheritance entirely. *That* is the mechanism gap.
|
||||
|
||||
## 2. The retail mechanism (verified against the named decomp)
|
||||
|
||||
Every cell — empty or filled — is created by `UIElement_ItemList::InternalCreateItem` (decomp `004e3570`, named-retail line 231486; verified, not agent-reported). The cache-miss path:
|
||||
|
||||
```c
|
||||
eax_1 = UIElement::GetAttribute_Enum(this, 0x1000000e, &this_1); // 231517: cell-template ELEMENT ID, read off THIS list's ElementDesc
|
||||
... var_18=0x10000038; var_14=5; var_10_3=0x23 ... // 231518-231520
|
||||
eax_2 = DBObj::GetByEnum(0x10000038, 5, 0x23); // the shared UIItem-catalog LayoutDesc (= 0x21000037, strong inference)
|
||||
eax_3 = UIElementManager::CreateChildElement(s_pInstance, this, eax_2 /*catalog*/, this_1 /*element id*/); // 231526: clone that prototype
|
||||
return eax_3->DynamicCast(0x10000032); // 231534: a UIElement_UIItem cell
|
||||
```
|
||||
|
||||
`ItemList_AddEmptySlot` (`004e36b0`) → `InternalCreateItem` → `UIItem_SetState(0x1000001c)` (= the `ItemSlot_Empty` state). The cloned prototype's baked `ItemSlot_Empty` media **is** the empty-slot background. `gm3DItemsUI::PostInit` (`004a7190`) and `gmBackpackUI::PostInit` (`004a6f70`) only bind the lists (`GetChildRecursive` + `DynamicCast(0x10000031)`); they set **no** cell art.
|
||||
|
||||
**Conclusion:** the empty-slot sprite is *data-driven per list* via attribute **`0x1000000e`** on the list's own ElementDesc → an element in the catalog `0x21000037` → that prototype's `ItemSlot_Empty` (`0x1000001c`) media. acdream's hardcoded `0x060074CF` is wrong for the pack/backpack lists.
|
||||
|
||||
## 3. Goal / non-goals
|
||||
|
||||
**Goal:** the contents grid + side-bag + main-pack empty cells render the correct per-list empty-slot art, **resolved from the dat exactly as retail does** (attribute `0x1000000e` → catalog `0x21000037` prototype's `ItemSlot_Empty`), retiring the hardcoded default for those lists.
|
||||
|
||||
**Non-goals (explicitly out):**
|
||||
- **Paperdoll equip silhouettes** — Sub-phase C (needs the `0x10000032` `UiItemSlot` registration + the `UiViewport`). This design *lays the groundwork* (the same resolver serves them) but does not wire them.
|
||||
- **Main-pack backpack *icon*** — AP-51 (the *filled* state of `0x100001C9`), separate.
|
||||
- **Container switching** — deferred (AP-53).
|
||||
- **The toolbar** — its hardcoded `0x060074CF` is the *correct* outcome (its `0x1000000e` resolves to a generic prototype); left untouched to avoid regression. The new resolver is reusable for it later.
|
||||
|
||||
## 4. Design
|
||||
|
||||
### 4.1 Components (smallest faithful change)
|
||||
|
||||
| Unit | Change | Why |
|
||||
|---|---|---|
|
||||
| `src/AcDream.App/UI/Layout/ItemListCellTemplate.cs` (**new**, static helper) | `ResolveEmptySprite(DatCollection dats, uint listLayoutId, uint listElementId) → uint` | The pure dat→sprite resolver. Mirrors the **existing** `0x21000037` read in `GameWindow` (the digit-array block, `GameWindow.cs:1928-1994`) but extracted into a testable helper instead of another inline block (CLAUDE.md rule #1: no new feature bodies in `GameWindow`). |
|
||||
| `src/AcDream.App/UI/UiItemList.cs` | add `uint CellEmptySprite` property | The list's per-cell empty sprite; applied to every cell. |
|
||||
| `src/AcDream.App/UI/Layout/InventoryController.cs` | `Bind`/ctor gain the three resolved sprites; set `CellEmptySprite` on `_contentsGrid` / `_containerList` / `_topContainer` | Where the lists are owned. |
|
||||
| `src/AcDream.App/Rendering/GameWindow.cs` | call the helper for the 3 lists, pass results into `InventoryController.Bind` | Thin wiring only (≤ a dozen lines), mirroring the `ToolbarController.Bind(..., peaceDigits, …)` seam at `GameWindow.cs:2015`. |
|
||||
|
||||
`UiItemSlot.EmptySprite` and its `0x060074CF` default are **unchanged** (still correct for the toolbar). The inventory cells get their value from `UiItemList.CellEmptySprite`.
|
||||
|
||||
### 4.2 `UiItemList.CellEmptySprite`
|
||||
|
||||
```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). 0 = leave the
|
||||
/// UiItemSlot default (0x060074CF). Applied to every existing + future cell.</summary>
|
||||
public uint CellEmptySprite
|
||||
{
|
||||
get => _cellEmptySprite;
|
||||
set { _cellEmptySprite = value; if (value != 0) foreach (var c in _cells) c.EmptySprite = value; }
|
||||
}
|
||||
```
|
||||
`AddItem` applies it to each new cell: `if (CellEmptySprite != 0) cell.EmptySprite = CellEmptySprite;` (placed with the existing `cell.SpriteResolve ??=` line). Order-independent: the ctor's default cell is re-stamped by the setter when the controller assigns `CellEmptySprite`.
|
||||
|
||||
### 4.3 The resolver (explicit, single-sprite contract)
|
||||
|
||||
`ResolveEmptySprite(dats, listLayoutId, listElementId)`:
|
||||
|
||||
1. `listLd = dats.Get<LayoutDesc>(listLayoutId)`; find `listElem` (id `listElementId`) in `listLd.Elements` (recursively into `Children`). Not found → return `0`.
|
||||
2. **Read the cell-template id:** `cellProtoId = listElem.StateDesc.Properties[0x1000000e]` as a single id value (`DataIdBaseProperty`/`IntBaseProperty` — exact type confirmed in §6 step 1). Absent on the element → walk the `BaseElement`/`BaseLayoutId` chain once (the itemlist base `0x10000339` is bare, so this just confirms absence). Still absent → return `0` (caller keeps the `UiItemSlot` default; logged).
|
||||
3. `catalogLd = dats.Get<LayoutDesc>(0x21000037)`; `proto = catalogLd.Elements[cellProtoId]`. Not found → return `0`.
|
||||
4. **Pick the prototype's single empty-background sprite, in this priority** (explicit — handles both observed prototype shapes):
|
||||
a. **Frame child** — if `proto` has a child whose `StateDesc` (DirectState) carries a background image (the recessed pack-slot frame; e.g. the 36×36 prototype `0x1000033F`'s child `0x10000450` → `0x06005D9C`), return that `File`.
|
||||
b. **m_elem_Icon empty** — else find the icon sub-element (id `0x1000033B`, recursively through any inner wrapper such as `0x10000340`) and return its `ItemSlot_Empty` (state `0x1000001c`) `MediaDescImage.File` (e.g. the 32×32 prototype `0x1000033A` → `0x06004D20`).
|
||||
c. else → return `0`.
|
||||
|
||||
The catalog id `0x21000037` is hardcoded — a **strong inference** (it's the only LayoutDesc that is a catalog of standalone `0x10000032` prototypes; `GetByEnum(0x10000038,5,0x23)` resolves through a master enum-map *dat* object with no code literal). The `0x10000038` enum domain is not wired in acdream. §6 step 1 validates the hardcode (the resolved id must name a real `0x21000037` prototype with a sane sprite).
|
||||
|
||||
### 4.4 Data flow
|
||||
|
||||
```
|
||||
GameWindow (composition root, ≤12 lines, under _datLock):
|
||||
contentsEmpty = ItemListCellTemplate.ResolveEmptySprite(dats, 0x21000021, 0x100001C6) // gm3DItemsUI contents grid
|
||||
sideBagEmpty = ItemListCellTemplate.ResolveEmptySprite(dats, 0x21000022, 0x100001CA) // gmBackpackUI side-bag column
|
||||
mainPackEmpty = ItemListCellTemplate.ResolveEmptySprite(dats, 0x21000022, 0x100001C9) // gmBackpackUI main-pack cell
|
||||
↓ (named params, mirroring ToolbarController.Bind(..., peaceDigits, …))
|
||||
InventoryController.Bind(..., contentsEmpty, sideBagEmpty, mainPackEmpty):
|
||||
_contentsGrid.CellEmptySprite = contentsEmpty
|
||||
_containerList.CellEmptySprite = sideBagEmpty
|
||||
_topContainer.CellEmptySprite = mainPackEmpty
|
||||
↓ (UiItemList.AddItem / setter)
|
||||
each cell.EmptySprite = (per-list resolved sprite) → UiItemSlot.OnDraw draws it when ItemId == 0
|
||||
```
|
||||
|
||||
Each list reads its **own** `0x1000000e` (the contents grid and the backpack lists live in different layouts), so the resolution is per-list-faithful, not a size guess.
|
||||
|
||||
### 4.5 Why GameWindow-resolves rather than the importer
|
||||
|
||||
The fully-automatic alternative — resolve inside `LayoutImporter`/`DatWidgetFactory` so *every* `UiItemList` gets `CellEmptySprite` for free — would touch the `ElementInfo` POCO (which carries an explicit "don't add members without updating consumers" warning), `ElementReader`, and the factory, and would route the **toolbar** through the new path (regression risk on frozen, working art). The chosen design keeps the blast radius to the inventory, puts the logic in one testable helper, and reuses the proven `GameWindow`-reads-`0x21000037` + passes-to-`Bind` pattern. The importer route remains available for Sub-phase C if we later want all lists automatic.
|
||||
|
||||
## 5. Divergence register impact
|
||||
|
||||
- **No new deviation is introduced** — this *ports* the retail resolver for the inventory lists (a deviation retired, not added).
|
||||
- The **hardcoded empty-slot art was never registered** (a pre-existing gap — a deviation without a row). This change makes it honest by adding **one narrow row**: *"The toolbar's item slots use the hardcoded `UiItemSlot.EmptySprite = 0x060074CF` rather than resolving their cell template via `0x1000000e`; correct in outcome (the toolbar's `0x1000000e` resolves to a generic prototype) but not dat-resolved. Retire when the toolbar is routed through `ItemListCellTemplate`."* (AP-55 or next free id, added in the implementation commit.)
|
||||
- A second row for the **flat-cell approximation**: *"acdream's `UiItemSlot` is a flat single-sprite cell; for a prototype with both a frame child and an inner icon (the 36×36 backpack `0x1000033F`: frame `0x06005D9C` + inner `0x06000F6E`), only the dominant background (the frame) is drawn — retail clones the full prototype subtree. Revisit at the visual gate / a layered cell if the backpack slots read wrong."* (next free id.)
|
||||
|
||||
## 6. Implementation outline + step-1 validation (this is how we "pin the exact asset")
|
||||
|
||||
1. **Pin the values (first task).** Extend the layout dumper (`AcDream.Cli dump-vitals-layout`, the reflective ElementDesc walker) to surface each element's scalar **attribute/property table** (it currently prints geometry + states + media, omitting `Properties` keys like `0x1000000e`). Dump `0x1000000e` for `0x100001C6` (layout `0x21000021`), `0x100001C9` and `0x100001CA` (layout `0x21000022`). Record: the property type, the resolved prototype id, and that prototype's chosen sprite per §4.3. This **confirms the catalog is `0x21000037`** and **locks the golden test values**. (If `0x1000000e` is absent on a list element, escalate to the cdb fallback: break at `InternalCreateItem` `004e3616`, read arg4 + the catalog DID — per the research report's §"How to verify next".)
|
||||
2. `ItemListCellTemplate.ResolveEmptySprite` + unit/real-dat tests.
|
||||
3. `UiItemList.CellEmptySprite` + unit test.
|
||||
4. `InventoryController.Bind` params + apply; update affected InventoryController tests.
|
||||
5. `GameWindow` wiring.
|
||||
6. Divergence rows; ISSUES update; visual gate.
|
||||
|
||||
## 7. Testing
|
||||
|
||||
- **Resolver, real-dat smoke** (`tests/AcDream.App.Tests`, dat-gated like the existing Layout real-dat smokes): `ResolveEmptySprite(dats, 0x21000021, 0x100001C6)` returns **non-zero**, **≠ `0x060074CF`**, and **== the `ItemSlot_Empty` of the prototype named by that element's `0x1000000e`** (structural assertion — no magic literal until step 1 locks it). Same for `0x100001C9`/`0x100001CA` (layout `0x21000022`).
|
||||
- **`UiItemList.CellEmptySprite`** unit test: assigning it stamps existing **and** subsequently-added cells; `0` leaves the `UiItemSlot` default.
|
||||
- **`InventoryController`**: after `Bind` with the three sprites, the contents-grid / side-bag / main-pack cells carry the expected `EmptySprite`; the padded empty cells too.
|
||||
- **Unchanged:** `UiItemSlotTests.DefaultEmptySprite_isToolbarBorder` stays green (the default is still `0x060074CF`).
|
||||
- `dotnet build` + full suite green (run the **full** suite at the phase boundary — a filtered batch hid a cross-file regression in B-Wire).
|
||||
|
||||
## 8. Acceptance criteria
|
||||
|
||||
- Build + full test suite green.
|
||||
- The resolver's golden values are pinned from the dat (step 1) and locked by a real-dat test.
|
||||
- Divergence rows added in the same commit; ISSUES entry moved to *Recently closed* (inventory portion) with the commit SHA.
|
||||
- **Visual gate (user, F12, `ACDREAM_RETAIL_UI=1`):** the contents-grid and side-bag empty cells show the retail pack-slot background, not the generic toolbar square. The user is the arbiter on whether the 36×36 backpack frame approximation reads correctly; if not, escalate to a layered cell (noted, not in scope now).
|
||||
|
||||
## 9. Open questions (resolved during step 1, not blocking the design)
|
||||
|
||||
- Exact `0x1000000e` property **type** + **value** per list (→ the exact prototypes/sprites). *Resolved by the step-1 dump.*
|
||||
- Whether the 36×36 backpack cells need both the frame **and** the inner layer. *Resolved at the visual gate; approximation + escalation path recorded as a divergence row.*
|
||||
|
|
@ -0,0 +1,121 @@
|
|||
# D.2b inventory drag-drop (item moving) — design
|
||||
|
||||
**Date:** 2026-06-22
|
||||
**Branch:** `claude/hopeful-maxwell-214a12`
|
||||
**Phase:** D.2b retail-UI inventory arc — B-Drag (inventory drag SOURCE + drop placement).
|
||||
**Brainstorm:** this doc. Builds on the shipped drag-drop spine (B.1) + container-switching.
|
||||
|
||||
---
|
||||
|
||||
## Goal
|
||||
|
||||
Drag an inventory item and drop it to move it, **instantly**:
|
||||
1. **on an empty grid slot** → the item goes to the **first empty slot** of the open pack (pack-to-front),
|
||||
2. **on an occupied item** → **insert before** it (the rest shift down),
|
||||
3. **on a side-bag cell** → the item goes **into that container**,
|
||||
with a **green insert-arrow** overlay while a valid drop is hovered and a **red circle** when the target container is **full**.
|
||||
|
||||
The move **feels instant**: the grid updates locally the moment you drop, with the server reconciling in the background (and snapping the item back only if it bounces the move).
|
||||
|
||||
## Scope
|
||||
|
||||
**In:**
|
||||
- Inventory cells as drag **sources** (already: `UiItemSlot.IsDragSource` when occupied, `SourceKind = Inventory`).
|
||||
- `InventoryController` as the drop **handler** (`IItemListDragHandler`) on the contents grid + the side-bag list.
|
||||
- The three drop placements (goal 1–3) → `PutItemInContainer 0x0019`.
|
||||
- **Optimistic local move** on drop (instant repaint) + **rollback** on `InventoryServerSaveFailed`.
|
||||
- **Accept/reject overlays**: green insert-arrow (valid) / red circle (target bag full).
|
||||
|
||||
**Out (separate work, with reasons):**
|
||||
- **Equipping via the paperdoll** (`GetAndWieldItem 0x001A`) — Sub-phase C / its own drop path.
|
||||
- **Drop-to-ground** (`DropItem 0x001B`) — separate interaction.
|
||||
- **Dragging the side-bag cells themselves** (reordering bags) — bags are drop *targets* here, not sources.
|
||||
- **Stack split/merge** on drop — deferred (the Selected-Target-Bar split is its own feature).
|
||||
|
||||
## Retail anchors (the spec is a faithful port)
|
||||
|
||||
| Concern | Retail | Note |
|
||||
|---|---|---|
|
||||
| Drop placement + accept/reject flags | `UIElement_ItemList::InqDropIconInfo 0x004e26f0` | reads the cell's drop-info properties → placement + `DropItemFlags` |
|
||||
| Insert-before | `UIElement_ItemList::ItemList_InsertItem` | the dragged item takes the target slot; rest shift |
|
||||
| Commit the drop | `UIElement_ItemList::HandleDropRelease` | issues the move |
|
||||
| Local move + server reconcile | `ItemList_InsertItem` (local) + `RecvNotice_ServerSaysMoveItem`/`ServerSaysMoveItem` | retail moves locally then reconciles — this is the "instant + rollback" model |
|
||||
| Wire | `PutItemInContainer 0x0019` (`item, container, placement`) → ACE `Player.HandleActionPutItemInContainer` | placement = the target slot index (server packs/shifts) |
|
||||
|
||||
## Components
|
||||
|
||||
### 1. `WorldSession.SendPutItemInContainer` (Core.Net — new wrapper)
|
||||
Thin wrapper over the existing `InteractRequests.BuildPickUp(seq, itemGuid, containerGuid, placement)` (opcode `0x0019`), mirroring `SendUse`/`SendNoLongerViewingContents`:
|
||||
```
|
||||
public void SendPutItemInContainer(uint itemGuid, uint containerGuid, int placement)
|
||||
```
|
||||
|
||||
### 2. `InventoryController : IItemListDragHandler` (App)
|
||||
Registered on the contents grid + the side-bag list in `Populate` (`list.RegisterDragHandler(this)`), like `ToolbarController`. Cells already default `SourceKind = Inventory`; set each cell's `SlotIndex` so the drop target knows its position.
|
||||
- **`OnDragLift` → no-op.** The item **stays in its slot** during the drag (unlike the toolbar's remove-on-lift); it moves only on drop. (Retail dims the source; we leave it in place + the floating ghost — a minor approximation.)
|
||||
- **`OnDragOver(targetList, targetCell, payload)` → accept/reject overlay (advisory):**
|
||||
- target is a **grid slot** (the open pack) → **accept** (reorder/insert is always valid in the open container).
|
||||
- target is a **side-bag cell** → **accept** unless that bag is **known-full** (`GetContents(bag).Count >= ItemsCapacity` when we know the count) → **reject**. A *closed* bag whose count we don't know → **accept** (advisory; the server is authoritative).
|
||||
- Sets `targetCell.DragAcceptSprite` = the green insert-arrow / `DragRejectSprite` = the red circle (ids §Overlays).
|
||||
- **`HandleDropRelease(targetList, targetCell, payload)` → optimistic move + wire:**
|
||||
1. Resolve `(targetContainer, placement)`:
|
||||
- grid empty slot → `targetContainer = EffectiveOpen()`, `placement = append` (first empty = end of the packed list).
|
||||
- grid occupied slot N → `targetContainer = EffectiveOpen()`, `placement = targetCell.SlotIndex` (insert-before).
|
||||
- side-bag cell → `targetContainer = targetCell.ItemId` (the bag guid), `placement = append`.
|
||||
2. If the target is **known-full** (the reject case) → no-op (the red overlay already showed; nothing moves).
|
||||
3. Else: record the item's **pre-move** `(ContainerId, ContainerSlot)` in a pending-move map; **`_objects.MoveItem(item, targetContainer, placement)` locally** (instant repaint via `ObjectMoved` → `Concerns` → `Populate`); **`SendPutItemInContainer(item, targetContainer, placement)`**.
|
||||
|
||||
### 3. Optimistic reconcile + rollback — `ClientObjectTable` (Core)
|
||||
The pending-move tracking lives in **`ClientObjectTable`** (Core), so BOTH the App drop handler and the Core.Net wire handlers reach it (Core.Net can't depend on App):
|
||||
- `MoveItemOptimistic(itemId, newContainer, newSlot)` — snapshot the item's current `(ContainerId, ContainerSlot)` into a small pending map, then `MoveItem` (fires `ObjectMoved` → instant repaint). Called by `HandleDropRelease`.
|
||||
- `ConfirmMove(itemId)` — clear the pending entry (the move stuck).
|
||||
- `RollbackMove(itemId)` — `MoveItem` back to the snapshotted `(container, slot)`, then clear. Returns false if no pending entry.
|
||||
|
||||
Wiring (`GameEventWiring`):
|
||||
- **Reconcile (success):** the `InventoryPutObjInContainer 0x0022` echo already routes to `MoveItem` (no-op/correction for a move we initiated) — add `items.ConfirmMove(itemGuid)` after it.
|
||||
- **Rollback (failure):** `InventoryServerSaveFailed 0x00A0` (today it only logs) → `items.RollbackMove(itemGuid)` (snap back). B-Drag is what the B-Wire note reserved this handler for.
|
||||
|
||||
### 4. Overlays (sprites)
|
||||
Green insert-arrow + red circle, **dat-exported + visual-gate-confirmed** before pinning (per the empty-slot-art / backpack-icon lesson). Candidates from the shipped spine: green move-arrow `0x060011F7`, red ∅ `0x060011F8`, green ring `0x060011F9` (inventory accept). `UiItemSlot` already draws `DragAcceptSprite`/`DragRejectSprite` overlays on DragEnter/Reject — the controller sets the inventory ids.
|
||||
|
||||
## Data flow
|
||||
|
||||
```
|
||||
drop item on a target cell
|
||||
→ OnDragOver already showed green (valid) / red (full)
|
||||
→ HandleDropRelease: resolve (container, placement)
|
||||
known-full? → no-op (red, nothing moves)
|
||||
else → record pre-move pos
|
||||
→ MoveItem(item, container, placement) LOCALLY → instant repaint
|
||||
→ SendPutItemInContainer(item, container, placement)
|
||||
─────────── server ───────────
|
||||
→ InventoryPutObjInContainer 0x0022 (confirm) → MoveItem (no-op/correct) → clear pending
|
||||
OR
|
||||
→ InventoryServerSaveFailed 0x00A0 (reject) → MoveItem back to pre-move → clear pending
|
||||
```
|
||||
|
||||
## Divergence register
|
||||
- **`OnDragLift` no-op / source not dimmed** — retail dims the lifted item's source cell; we leave it in place + the floating ghost (minor visual).
|
||||
- **Closed-bag drop is advisory-accept** — the client can't know a closed bag's fullness (contents aren't indexed until opened), so it shows green + relies on the server's reject + rollback. Retail knows the count if loaded.
|
||||
- Accept/reject overlay ids set by the controller (procedural), pinned by dat-export (cf. AP-55/57).
|
||||
- Retire/relax the B-Wire note on `InventoryServerSaveFailed` ("B-Drag wires the rollback") — now wired.
|
||||
|
||||
## Test plan
|
||||
- **Core.Net:** `SendPutItemInContainer` wire (opcode `0x0019`, item/container/placement) — extend `InteractRequestsTests` (the builder is already covered; assert the wrapper path if a seam exists, else rely on the builder test).
|
||||
- **App (`InventoryControllerTests`):** the handler — extend the fake-layout harness with a `SendPutItemInContainer` capture.
|
||||
- drop on empty grid slot → `MoveItem(item, openContainer, append)` locally + wire fired with the open container.
|
||||
- drop on occupied slot N → wire `placement == N` (insert-before); local grid shows the item at N.
|
||||
- drop on a side-bag cell → wire `container == bagGuid`.
|
||||
- `OnDragOver`: grid → accept; full side bag → reject; closed bag → accept.
|
||||
- rollback: simulate `InventoryServerSaveFailed` → item returns to its pre-move container/slot.
|
||||
- **Core:** a pending-move rollback unit (record pre-move, restore on failure) if the mover lives in Core.
|
||||
|
||||
## Acceptance
|
||||
- [ ] Decomp anchors cited; divergence rows added same-commit.
|
||||
- [ ] `dotnet build` + full `dotnet test` green.
|
||||
- [ ] **Visual gate (instant):** drag an item onto an empty slot → it lands (first empty) the instant you release; onto an item → inserts before it; onto a side bag → goes in; a full bag shows the red circle and bounces; a valid target shows the green insert-arrow. WireMCP confirms `PutItemInContainer 0x0019` + the `0x0022` echo (and a `0x00A0` bounce on a full bag).
|
||||
- [ ] SSOT + roadmap updated.
|
||||
|
||||
## Open verification (at the gate)
|
||||
- Pin the green-arrow / red-circle sprite ids by dat-export + the visual gate.
|
||||
- Confirm ACE's `placement` interpretation (insert-before-N vs append) against a live capture; adjust the placement mapping if ACE packs differently.
|
||||
|
|
@ -0,0 +1,341 @@
|
|||
# D.2b Sub-phase C, Slice 1 — Paperdoll equip slots (design)
|
||||
|
||||
**Date:** 2026-06-22
|
||||
**Branch:** `claude/hopeful-maxwell-214a12` (tip `702058f`; `main` is a clean ff ancestor)
|
||||
**Status:** DESIGN — approved in brainstorm; spec under review before the implementation plan.
|
||||
**Supersedes the stale framing in:** `docs/research/2026-06-22-paperdoll-handoff.md` §"THE WIRE GAP"
|
||||
(the handoff quoted the 2026-06-16 deep-dive, which pre-dated B-Wire; the wire already exists — see §2).
|
||||
**Authoritative research:** `docs/research/2026-06-16-equipment-paperdoll-deep-dive.md` (§3a element-id→mask, §4 wire).
|
||||
**Line numbers drift — grep the symbol.**
|
||||
|
||||
---
|
||||
|
||||
## 1. Goal + scope
|
||||
|
||||
Bind the ~25 mounted paperdoll equip slots (under `0x100001CD` / `gmPaperDollUI 0x21000024`,
|
||||
already imported + positioned inside the inventory frame) to live equipped-item data and make them
|
||||
drag-drop **wield**/**unwield** targets. After this slice: you SEE your equipped gear as icons in the
|
||||
correct doll slots, dragging a wieldable item onto a slot wields it (optimistic + rollback), and
|
||||
dragging a slot's item to the pack unwields it.
|
||||
|
||||
**This is the lighter, functional half of Sub-phase C.** No 3D doll.
|
||||
|
||||
### Non-goals (Slice 2 or later — do NOT build here)
|
||||
|
||||
- The 3D doll `UIElement_Viewport` (Type `0xD`) + the Core→App `IUiViewportRenderer` seam.
|
||||
- Part-selection highlight, doll rotation, auto-wield-on-doll-body (drop on the body, not a slot).
|
||||
- Aetheria sigil slots (`0x10000595/96/97`; `SetVisible(0)` by default in retail — left unbound).
|
||||
- The richer `InventoryPlacement` priority list (PlayerDescription equipped section, deep-dive §3c) —
|
||||
the per-item `CurrentlyEquippedLocation` is sufficient for slot icons (each item has one location).
|
||||
- Dual-wield-into-shield-slot special-case (deep-dive §3b line 174302).
|
||||
|
||||
---
|
||||
|
||||
## 2. What already exists (reuse, do NOT rebuild)
|
||||
|
||||
Verified against source this session — the handoff's "build the wire gap" premise is **stale**.
|
||||
|
||||
| Capability | Where | Status |
|
||||
|---|---|---|
|
||||
| `GetAndWieldItem 0x001A` builder | `InventoryActions.BuildGetAndWieldItem(seq,itemGuid,equipMask)` `InventoryActions.cs:153` (20-byte body) | EXISTS |
|
||||
| `GetAndWieldItem` sender | `WorldSession.SendGetAndWieldItem(itemGuid,equipMask)` `WorldSession.cs:1205` | EXISTS |
|
||||
| Unwield wire | `WorldSession.SendPutItemInContainer(item,container,placement)` `:1230` → `BuildPickUp 0x0019` | EXISTS, wired |
|
||||
| Optimistic move + rollback | `ClientObjectTable.MoveItemOptimistic/ConfirmMove/RollbackMove` + `_pendingMoves` (outstanding-count, I1/I2-hardened) | EXISTS |
|
||||
| Drag-drop spine | `IItemListDragHandler`; `UiItemSlot.OnEvent` (DragBegin→OnDragLift, DragEnter→OnDragOver, DropReleased→HandleDropRelease) | EXISTS |
|
||||
| Pattern to mirror | `InventoryController : IItemListDragHandler` `InventoryController.cs` | EXISTS |
|
||||
| Equip-state per item | `ClientObject.CurrentlyEquippedLocation` + `ValidLocations` (parsed `CreateObject.cs:752-760` → `ObjectTableWiring` → `Ingest`) | EXISTS |
|
||||
| Wield confirm parse | `WieldObject 0x0023` handler `GameEventWiring.cs:238` → `MoveItem(item,wielder,equip)` | EXISTS (gap: see §5) |
|
||||
| Slots imported + positioned | inventory frame `GameWindow.cs:2145`; paperdoll mount `PinTopLeft(0x100001CD)` `:2208`; `InventoryController.Bind` `:2231` | EXISTS |
|
||||
| Transparent empty slot | `UiItemSlot.OnDraw:222` — `EmptySprite != 0` gates the draw; `EmptySprite = 0` ⇒ nothing drawn | EXISTS |
|
||||
|
||||
**Unwield is therefore free:** dragging an equipped item (a valid drag source, `IsDragSource ⇒ ItemId != 0`)
|
||||
onto the inventory grid already hits `InventoryController.HandleDropRelease` →
|
||||
`MoveItemOptimistic` (clears equip) + `SendPutItemInContainer`. The PaperdollController only implements **wield**.
|
||||
|
||||
---
|
||||
|
||||
## 3. The `EquipMask` enum is wrong — correct it first (Core)
|
||||
|
||||
acdream's `EquipMask` (`ClientObject.cs:65`) **diverges from canonical AC** starting at bit `0x2000`.
|
||||
It invented two phantom bits — `HandArmor = 0x2000` and `FootArmor = 0x10000` — that do not exist in
|
||||
retail's `INVENTORY_LOC` enum (verbatim header `docs/research/named-retail/acclient.h:3193`), shifting
|
||||
every slot above `0x1000` out of alignment. It has not bitten yet only because nothing compares against a
|
||||
**named** mask (`InventoryController` checks `!= None`, numeric-agnostic; wire values are stored as raw
|
||||
`(EquipMask)uint` casts that preserve the numeric bits). The paperdoll is the first code to compare
|
||||
against named masks, so it would expose the bug.
|
||||
|
||||
**Wrong vs right (sample):** acdream `0x200000 = RightRing`; retail `0x200000 = SHIELD_LOC`. acdream
|
||||
`0x800000 = Shield`; retail `0x800000 = MISSILE_AMMO_LOC`.
|
||||
|
||||
### Fix: replace the enum body with the verbatim retail `INVENTORY_LOC` values
|
||||
|
||||
```csharp
|
||||
[Flags]
|
||||
public enum EquipMask : uint
|
||||
{
|
||||
None = 0,
|
||||
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, // was wrongly 0x4000 (phantom HandArmor at 0x2000)
|
||||
LowerLegArmor = 0x00004000, // was wrongly 0x8000
|
||||
NeckWear = 0x00008000, // acdream had no NeckWear (called it "Necklace" at 0x20000)
|
||||
WristWearLeft = 0x00010000, // was wrongly "FootArmor"
|
||||
WristWearRight= 0x00020000,
|
||||
FingerWearLeft= 0x00040000,
|
||||
FingerWearRight=0x00080000,
|
||||
MeleeWeapon = 0x00100000, // was wrongly 0x400000
|
||||
Shield = 0x00200000, // was wrongly 0x800000
|
||||
MissileWeapon = 0x00400000,
|
||||
MissileAmmo = 0x00800000,
|
||||
Held = 0x01000000,
|
||||
TwoHanded = 0x02000000, // acdream lacked it (had Held here)
|
||||
TrinketOne = 0x04000000, // was wrongly 0x10000000
|
||||
Cloak = 0x08000000, // the ONLY high bit acdream had right
|
||||
SigilOne = 0x10000000,
|
||||
SigilTwo = 0x20000000,
|
||||
SigilThree = 0x40000000,
|
||||
}
|
||||
```
|
||||
|
||||
Removed (no longer exist): `HandArmor`, `FootArmor`, `Necklace`, `LeftBracelet`, `RightBracelet`,
|
||||
`LeftRing`, `RightRing`, `AetheriaRed/Yellow/Blue`. **Blast radius is safe:** the only references in
|
||||
the tree are 4 test files using `EquipMask.MeleeWeapon` in **round-trips** (write `(uint)…` to the wire,
|
||||
parse back, assert equality) — value-agnostic, so they stay green. No test pins a wrong numeric value
|
||||
against external truth.
|
||||
|
||||
**Anti-regression: a numeric-pin test** (`tests/AcDream.Core.Tests/Items/EquipMaskTests.cs`) asserting the
|
||||
exact value of every member against `acclient.h:3193` (e.g. `Assert.Equal(0x200000u, (uint)EquipMask.Shield)`).
|
||||
This converts the "named mask == canonical bit" contract into a hard test so it can never silently drift.
|
||||
|
||||
---
|
||||
|
||||
## 4. Optimistic wield (Core — `ClientObjectTable`)
|
||||
|
||||
`MoveItemOptimistic` is **unwield-shaped**: it hardcodes `CurrentlyEquippedLocation = None` (`:162`) and
|
||||
does a gapless container-index insert. Wield is the opposite. Add a sibling.
|
||||
|
||||
### 4a. Extend the pending-move snapshot to remember the pre-move equip location
|
||||
|
||||
Today `_pendingMoves` is `Dictionary<uint,(uint container,int slot,int outstanding)>`. Extend to
|
||||
`(uint container, int slot, EquipMask equip, int outstanding)`. Extract a shared private helper:
|
||||
|
||||
```csharp
|
||||
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);
|
||||
}
|
||||
```
|
||||
|
||||
`MoveItemOptimistic` calls `RecordPending` (unchanged behavior — it records `equip` which is `None` for
|
||||
pack items). `RollbackMove` restores all three via the existing `MoveItem` overload that already takes an
|
||||
`EquipMask`:
|
||||
|
||||
```csharp
|
||||
return MoveItem(itemId, pre.container, pre.slot, pre.equip);
|
||||
```
|
||||
|
||||
This makes rollback faithful in **both** directions (today an unwield-reject loses the item's slot mask).
|
||||
|
||||
### 4b. `WieldItemOptimistic`
|
||||
|
||||
```csharp
|
||||
/// <summary>Optimistic (instant) wield: snapshot the pre-wield (container, slot, equip), then set
|
||||
/// ContainerId = 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);
|
||||
return MoveItem(itemId, wielderGuid, newSlot: -1, newEquipLocation: equipMask);
|
||||
}
|
||||
```
|
||||
|
||||
`MoveItem` already sets `ContainerId` + `CurrentlyEquippedLocation` + reindexes + fires `ObjectMoved`.
|
||||
It does **not** write `WielderId` — acdream's `WieldObject 0x0023` confirm is also ContainerId-based (it
|
||||
never sets `WielderId`), so the optimistic state equals the confirmed state and rollback fully restores
|
||||
through `MoveItem` alone. (An equipped item is detected as the player's via `ContainerId == player`;
|
||||
login-equipped items match via `WielderId` from their own CreateObject. Decided at the Task-3 code review.)
|
||||
|
||||
---
|
||||
|
||||
## 5. Close the wield-confirm gap (Core.Net — `GameEventWiring`)
|
||||
|
||||
The `WieldObject 0x0023` handler (`GameEventWiring.cs:238`) does `MoveItem(item, wielder, equip)` but —
|
||||
unlike the `InventoryPutObjInContainer` handler at `:251` — does **not** call `ConfirmMove`. Add it so an
|
||||
optimistic wield's snapshot clears on the server echo (decrement the outstanding count). No-op when nothing
|
||||
is pending (server-initiated / login wields), so it's safe and unconditional:
|
||||
|
||||
```csharp
|
||||
items.MoveItem(p.Value.ItemGuid, newContainerId: p.Value.WielderGuid,
|
||||
newEquipLocation: (EquipMask)p.Value.EquipLoc);
|
||||
items.ConfirmMove(p.Value.ItemGuid); // Slice 1: confirm an optimistic wield (mirrors the 0x0022 handler)
|
||||
```
|
||||
|
||||
Wield **rollback** rides the existing `InventoryServerSaveFailed 0x00A0` handler (`:280` →
|
||||
`RollbackMove`) unchanged — verify ACE emits `0x00A0` for `GetAndWieldItem` rejections at the gate (§9).
|
||||
|
||||
---
|
||||
|
||||
## 6. `PaperdollController` (App — `UI/Layout/PaperdollController.cs`)
|
||||
|
||||
Mirrors `InventoryController`: a `gm*UI::PostInit`-style find-by-id binder that owns the equip slots,
|
||||
populates them from `ClientObjectTable`, and is their `IItemListDragHandler`.
|
||||
|
||||
### 6a. Element-id → EquipMask map (verified: dump `paperdoll-0x21000024.txt` ↔ deep-dive §3a ↔ `acclient.h:3193`)
|
||||
|
||||
A `static readonly (uint Element, EquipMask Mask)[]` of the 21 functional slots:
|
||||
|
||||
| element | mask | element | mask |
|
||||
|---|---|---|---|
|
||||
| `0x100005AB` HeadWear `0x1` | | `0x100005B2` LowerLegArmor `0x4000` | |
|
||||
| `0x100001E2` ChestWear `0x2` | | `0x100001DA` NeckWear `0x8000` | |
|
||||
| `0x100001E3` UpperLegWear `0x40` | | `0x100001DB` WristWearLeft `0x10000` | |
|
||||
| `0x100005B0` HandWear `0x20` | | `0x100001DD` WristWearRight `0x20000` | |
|
||||
| `0x100005B3` FootWear `0x100` | | `0x100001DC` FingerWearLeft `0x40000` | |
|
||||
| `0x100005AC` ChestArmor `0x200` | | `0x100001DE` FingerWearRight `0x80000` | |
|
||||
| `0x100005AD` AbdomenArmor `0x400` | | `0x100001E1` Shield `0x200000` | |
|
||||
| `0x100005AE` UpperArmArmor `0x800` | | `0x100001E0` MissileAmmo `0x800000` (LIKELY) | |
|
||||
| `0x100005AF` LowerArmArmor `0x1000` | | `0x100001DF` weapon composite `0x3500000` | |
|
||||
| `0x100005B1` UpperLegArmor `0x2000` | | `0x1000058E` TrinketOne `0x4000000` | |
|
||||
| | | `0x100005E9` Cloak `0x8000000` | |
|
||||
|
||||
Weapon composite `0x3500000` = `WEAPON_READY_SLOT_LOC` (`acclient.h:3235`) =
|
||||
`MeleeWeapon|MissileWeapon|Held|TwoHanded`. `0x100001E0`'s mask is LIKELY (the decomp immediate was
|
||||
corrupted; inferred MissileAmmo from the gap + neighbors — gate-verify, §9).
|
||||
|
||||
### 6b. Construction / binding
|
||||
|
||||
For each `(element, mask)`: `layout.FindElement(element) as UiItemList`; if non-null:
|
||||
`list.RegisterDragHandler(this)`; `list.Cell.SourceKind = ItemDragSource.Equipment`;
|
||||
`list.Cell.SlotIndex = <index in the map>`; **`list.Cell.EmptySprite = 0`** (transparent, per the
|
||||
brainstorm); `list.Cell.SpriteResolve = …` (the same chrome resolver the factory set). Leave the
|
||||
`UiItemSlot` **default** accept/reject sprites — the retail `ItemSlot_DragOver_Accept`/`_Reject`
|
||||
`0x060011F9`/`0x060011F8` (the discrete-slot ring/circle, NOT the inventory grid's insert-arrow
|
||||
`0x060011F7`, which is for insert-between-cells). Keep a `mask → UiItemList` map for populate (so
|
||||
`MaskFor(list)` in §6d is the reverse lookup). Subscribe `ObjectAdded/Moved/Removed/Updated`.
|
||||
|
||||
`Bind(layout, objects, playerGuid, iconIds, sendWield)` static factory mirroring `InventoryController.Bind`.
|
||||
|
||||
### 6c. Populate
|
||||
|
||||
One pass over `_objects.Objects`, building `equipped: List<(EquipMask loc, ClientObject item)>` of the
|
||||
**player's** gear: `o.CurrentlyEquippedLocation != None && (o.WielderId == player || o.ContainerId == player)`.
|
||||
Then for each slot: find the item whose `(loc & slotMask) != 0` (handles the weapon composite + paired
|
||||
jewelry — a single equip bit intersects exactly one slot mask). If found, `cell.SetItem(guid,
|
||||
iconIds(item.Type, item.IconId, item.IconUnderlayId, item.IconOverlayId, item.Effects))`; else
|
||||
`cell.Clear()` (transparent — nothing drawn).
|
||||
|
||||
`Concerns(o)`: repaint when `o.WielderId == player || o.ContainerId == player` (player-scoped — an NPC's
|
||||
wielded item carries `CurrentlyEquippedLocation` too and must NOT leak onto the doll; a player-equipped item
|
||||
always has `WielderId==player` or `ContainerId==player`). `OnObjectMoved` also repaints when from/to is the
|
||||
player (an item being wielded/unwielded into a side bag). Mirror `InventoryController`'s debounce.
|
||||
|
||||
### 6d. `IItemListDragHandler`
|
||||
|
||||
```csharp
|
||||
void OnDragLift(...) { } // no-op — item stays until the server confirms (same as inventory)
|
||||
|
||||
bool OnDragOver(UiItemList list, UiItemSlot cell, ItemDragPayload payload)
|
||||
{
|
||||
var item = _objects.Get(payload.ObjId);
|
||||
if (item is null) return false;
|
||||
return (item.ValidLocations & MaskFor(list)) != EquipMask.None;
|
||||
}
|
||||
|
||||
void HandleDropRelease(UiItemList list, UiItemSlot cell, ItemDragPayload payload)
|
||||
{
|
||||
var item = _objects.Get(payload.ObjId);
|
||||
if (item is null) return;
|
||||
EquipMask wieldMask = item.ValidLocations & MaskFor(list); // resolves the specific weapon/finger bit
|
||||
if (wieldMask == EquipMask.None) return;
|
||||
_objects.WieldItemOptimistic(payload.ObjId, _playerGuid(), wieldMask);
|
||||
_sendWield?.Invoke(payload.ObjId, (uint)wieldMask);
|
||||
}
|
||||
```
|
||||
|
||||
`wieldMask = item.ValidLocations & slotMask` is the uniform rule: a head item on the head slot → `0x1`; a
|
||||
sword on the weapon slot → `MeleeWeapon`; a ring valid in both fingers dropped on the left-finger slot →
|
||||
`FingerWearLeft`. (Holtburger's `resolve_and_clear_slots` intent.)
|
||||
|
||||
---
|
||||
|
||||
## 7. Wire it up (App — `GameWindow.cs`)
|
||||
|
||||
At the inventory bind site (`:2231`, right after `InventoryController.Bind`), add:
|
||||
|
||||
```csharp
|
||||
_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));
|
||||
```
|
||||
|
||||
(`invLayout` already contains the paperdoll subtree via the sub-window mount.)
|
||||
|
||||
---
|
||||
|
||||
## 8. Testing
|
||||
|
||||
- **Probe (plan task 1 — de-risk):** extend `InventoryFrameImportProbe` to assert every equip-slot id
|
||||
(`0x100005AB`, `0x100001E1`, `0x100001DF`, …) resolves to a `UiItemList` in the imported tree. High
|
||||
confidence (the contents grid proves the same `0x2100003D` base path), but load-bearing — if it fails,
|
||||
fix the importer/factory before continuing.
|
||||
- **Core — `EquipMaskTests`:** numeric pin of every member vs `acclient.h:3193`.
|
||||
- **Core — `ClientObjectTableTests`:** `WieldItemOptimistic` sets equip + container (ContainerId-based; does
|
||||
NOT write WielderId); `RollbackMove` restores the pre-wield equip mask (the new snapshot field);
|
||||
outstanding-count across wield+move of the same item.
|
||||
- **Core.Net — `GameEventWiringTests`:** `WieldObject 0x0023` now calls `ConfirmMove` (an optimistic wield's
|
||||
snapshot clears on the echo).
|
||||
- **App — `PaperdollControllerTests`:** element-id→mask map matches the dump; populate shows the equipped item
|
||||
in the right slot (incl. weapon composite + paired finger); `OnDragOver` gates on `ValidLocations`;
|
||||
`HandleDropRelease` computes the correct `wieldMask`, optimistically equips, and sends `SendGetAndWieldItem`.
|
||||
Reuse the slot-inside-a-Draggable-frame topology from the B-Drag tests.
|
||||
- Full suite green at the phase boundary (not just filtered subsets — the B-Wire process lesson).
|
||||
|
||||
---
|
||||
|
||||
## 9. Divergence register rows (add in the implementing commit)
|
||||
|
||||
- **AP-xx:** `0x100001E0` MissileAmmo `0x800000` mask is LIKELY (corrupted decomp immediate, deep-dive §7) — gate-verify.
|
||||
- **AP-xx:** dual-wield-into-shield-slot special (deep-dive §3b line 174302) not implemented; a melee weapon
|
||||
cannot be dropped on the Shield slot in acdream.
|
||||
- **AP-xx:** wield-reject rollback assumes ACE emits `InventoryServerSaveFailed 0x00A0` for `GetAndWieldItem`
|
||||
rejections; if it does not, an optimistic wield is corrected only by the next authoritative message — gate-verify via WireMCP.
|
||||
|
||||
(The `EquipMask` correction removes a latent bug rather than adding a deviation — it's locked by the
|
||||
numeric-pin test, no register row.)
|
||||
|
||||
---
|
||||
|
||||
## 10. Risks / gate-verify
|
||||
|
||||
1. **Equip slots resolve to `UiItemList`** — mitigated by probe task 1.
|
||||
2. **ACE wield-reject message** — gate-verify (§9); graceful fallback (next authoritative correction).
|
||||
3. **`0x100001E0` MissileAmmo mask** — gate-verify; ammo slot is low-priority for MVP.
|
||||
|
||||
---
|
||||
|
||||
## 11. Acceptance criteria
|
||||
|
||||
- F12 inventory: 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 (icon appears, instant), survives the
|
||||
server echo, and a wrong-slot/invalid drop is rejected (red) or rolled back.
|
||||
- Drag an equipped item from a slot to the pack → it unwields (via the existing inventory grid handler).
|
||||
- `dotnet build` + full `dotnet test` green. Visual gate by the user. Divergence rows added.
|
||||
Loading…
Add table
Add a link
Reference in a new issue