From 2de9cc1c1916af83e4be14833013297649ca0e99 Mon Sep 17 00:00:00 2001 From: Erik Date: Sat, 20 Jun 2026 12:23:44 +0200 Subject: [PATCH 001/133] docs(D.5.3/B.1): drag-drop spine design spec MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Stream B.1 of the 2026-06-18 handoff — the shared widget-level drag-drop infra that both shortcut-drag (B) and the inventory window (C) sit on. Four design decisions confirmed with the user this session: 1. Payload = typed ItemDragPayload record snapshotted at drag-begin (ObjId/SourceKind/SourceSlot/SourceCell); SourceContainer derived at drop from ClientObjectTable (single source of truth). 2. Cursor ghost painted by UiRoot via a generic UiElement.GetDragGhost() hook — keeps UiRoot item-agnostic; floats above all windows. 3. Cell (UiItemSlot) is the drop-target hit unit + accept/reject overlay owner, delegating the decision + dispatch UP to its parent UiItemList's registered IItemListDragHandler (faithful to retail cell->ItemList_DragOver ->m_dragHandler; scales to the inventory N-cell grid). 4. PR ships infra + a visible toolbar STUB handler (logs, no wire) so the ghost/overlay/dispatch are confirmable this session; AddShortcut/Remove wire is Stream B.2. Retail-grounded: InqDropIconInfo flags (&0xE==0 fresh / &4 reorder, reject state 0x10000040) confirmed live at gmToolbarUI 0x004bd162; the cell begin-drag/CatchDroppedItem/RegisterItemListDragHandler chain at decomp 229344/229744/230461. Planned register rows: AP-47 (ghost reuses full icon at reduced alpha vs retail m_pDragIcon) + TS-33 (toolbar drop stub pending B.2). No new wire format in the spine itself. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../2026-06-20-d2b-drag-drop-spine-design.md | 367 ++++++++++++++++++ 1 file changed, 367 insertions(+) create mode 100644 docs/superpowers/specs/2026-06-20-d2b-drag-drop-spine-design.md diff --git a/docs/superpowers/specs/2026-06-20-d2b-drag-drop-spine-design.md b/docs/superpowers/specs/2026-06-20-d2b-drag-drop-spine-design.md new file mode 100644 index 00000000..56cb25d6 --- /dev/null +++ b/docs/superpowers/specs/2026-06-20-d2b-drag-drop-spine-design.md @@ -0,0 +1,367 @@ +# D.2b drag-drop spine (Stream B.1) — design + +**Date:** 2026-06-20 +**Phase:** D.2b retail-UI engine → D.5 core panels. Stream **B.1** of the +2026-06-18 handoff (the shared drag-drop infra that BOTH the shortcut-drag stream +(B) and the inventory window (C) sit on). Build it once, well — it is the +critical-path lynchpin. +**Branch:** `claude/hopeful-maxwell-214a12` (== main == `31d7ffd`). +**Spec author:** lead-engineer brainstorm 2026-06-20 (4 design decisions confirmed +by the user). + +**Read alongside:** +- `docs/research/2026-06-16-ui-item-slot-icon-dragdrop-spine-deep-dive.md` §5 / §5.7 + (the pseudocode IS the spec; sub-element + flag anchors). +- `docs/research/2026-06-18-d53-bar-finish-and-inventory-handoff.md` §B.1 + (current-code readiness). +- `claude-memory/project_d2b_retail_ui.md` (the toolkit), `feedback_ui_resolve_zero_magenta` + (the 0-id → magenta footgun; guard on the id, not the GL handle). + +--- + +## 1. Goal & non-goals + +**Goal.** Complete the widget-level drag-drop machine so a player can pick up an +item icon, see a translucent ghost track the cursor, watch hovered slots flip to +**accept (green) / reject (red)**, and drop — at which point the drop is dispatched +to the owning panel's handler with a fully-resolved payload. `UiRoot` already holds +the *device-level* drag state machine (`BeginDrag`/`UpdateDragHover`/`FinishDrag`, +promoted on >3 px move, live-wired to Silk.NET). This stream supplies the five +missing pieces (handoff §B.1): payload injection, the cursor ghost, drop-target +hooks on the cell, the accept/reject overlay, and the panel-handler interface. + +**In scope (the spine — generic, shared):** +1. Payload injection into `UiRoot.BeginDrag` (today passes `payload: null`). +2. A cursor-following drag ghost, painted by `UiRoot`, item-agnostic. +3. Drop-target hooks on `UiItemSlot` (DragEnter/Over/DropReleased → accept/reject + overlay + dispatch). +4. The `IItemListDragHandler` interface + registration on `UiItemList`. +5. A typed `ItemDragPayload` describing what is dragged and where from. +6. Moving the item-cell's use-trigger from MouseDown → Click (drag/click + disambiguation; also more retail-faithful). +7. A **visible toolbar stub handler** so the chain is verifiable this session. + +**Out of scope (explicitly deferred — later streams):** +- The `AddShortcut 0x019C` / `RemoveShortcut 0x019D` wire and the mutable + `ShortcutStore` (Stream **B.2**). +- The inventory window, `UiItemList` N-cell grid mode, window manager + (Streams **C / window-mgr**). +- Stack-split drag (the entry/slider `0x100001A3/A4`), give-to-NPC, drop-to-ground, + wield wire — all per-panel **HandleDropRelease** opcode selection (the deep-dive + §5.7 opcode table; lands with each consuming panel). +- The faithful `m_pDragIcon` (base+overlay-no-underlay) drag composite — MVP reuses + the existing full icon at reduced alpha (AP-47). +- Esc-to-cancel-drag (retail has it; future polish). + +--- + +## 2. Retail grounding (the four confirmed decisions) + +The named decomp (`acclient_2013_pseudo_c.txt`) was greped to anchor each piece; +the spine introduces **no new wire format** (opcodes are B.2/C), so the workflow's +grep→cross-ref→pseudocode step applies to the *event-chain semantics* and +`InqDropIconInfo`, both confirmed below. + +### 2.1 The retail chain (deep-dive §5.1–5.5) +- The cell `UIElement_UIItem` is BOTH drag source and drop target. On + left-press-and-move it walks to its parent `UIElement_ItemList` and calls + `ItemList_BeginDrag` (`ListenToElementMessage` decomp 229344, msg `0x21`). +- Every cell is a drop target *by construction* — `PostInit` sets the + `CatchDroppedItem` attribute `0x36` true (decomp 229744). +- On drag-over the cell forwards to `ItemList_DragOver`; the LIST routes to its + registered `m_dragHandler` (`RegisterItemListDragHandler`, decomp 230461; + confirmed at acclient 0x004a539e + the gmToolbarUI block 0x004bdd89). +- `InqDropIconInfo(dragElement, &objId, &containerId, &flags)` reads the dragged + element's properties (decomp 230533); the **flags** word (confirmed live at + `gmToolbarUI` 0x004bd162 / 0x004bd1af): **`flags & 0xE == 0`** ⇒ fresh-from- + inventory; **`flags & 4`** ⇒ within-list reorder. Accept/reject overlay state ids: + neutral `0x1000003f`, **reject `0x10000040`**, accept `0x10000041` + (`SetDragAcceptState`, confirmed 0x004bd16d). +- The drag ghost `m_dragIcon` (id `0x10000345`) is a root-level translucent copy of + the icon, tracking the cursor (decomp 229738; §5.5). + +### 2.2 How the four picks map (all confirmed with the user) +| # | Decision | Pick | Retail basis | +|---|---|---|---| +| 1 | Payload shape | typed `ItemDragPayload` record, snapshotted at begin | mirrors `InqDropIconInfo`'s {objId, container, flags}; our `flags` derived at drop from SourceKind+target | +| 2 | Ghost render | painted by `UiRoot` via a generic `GetDragGhost()` hook | retail's root-level `m_dragElement` floats above all windows | +| 3 | Drop unit | **cell** hits + shows overlay; **list** owns the handler | retail cell→`ItemList_DragOver`→`m_dragHandler` | +| 4 | PR scope | infra + a **visible toolbar stub** handler | so the ghost/overlay/dispatch are confirmable now; wire is B.2 | + +--- + +## 3. Architecture — components & boundaries + +All code lives in `src/AcDream.App/UI/` (the retail UI tree; NOT the ImGui devtools +path). No new project references; `UiRoot` stays item-agnostic (it learns the +payload/ghost only through two `UiElement` virtuals). + +``` + Silk.NET mouse ─► UiRoot (device drag machine; UNCHANGED chain, + payload pull + ghost) + │ BeginDrag(source): payload = source.GetDragPayload(); cancel if null + │ Draw(): paint source.GetDragGhost() at the cursor (overlay layer) + ▼ + UiItemSlot (the CELL — drag source + drop target + overlay owner) + │ GetDragPayload() → ItemDragPayload | null (null = empty cell, no drag) + │ GetDragGhost() → (tex,w,h) | null + │ OnEvent: Click→use; DragEnter→ask handler→overlay; DropReleased→dispatch + ▼ (walks Parent to) + UiItemList (the GRID — owns the registered handler) + │ DragHandler : IItemListDragHandler + ▼ + IItemListDragHandler (a panel controller implements this) + bool OnDragOver(list, cell, payload) → accept/reject (overlay only) + void HandleDropRelease(list, cell, payload) → the action (wire = per panel) + ▲ + ToolbarController (registers itself; STUB this stream — logs, no wire = TS-33) +``` + +**Boundary rules honored:** Rule 1 (no fat feature body in `GameWindow` — all logic +in the UI classes + the controller); Rule 3 (panels target the toolkit, never a +backend); `UiRoot` depends on nothing item-specific (Rule-2-spirit: the generic +toolkit core doesn't reach up into the item layer — it pulls through virtuals). + +--- + +## 4. New & changed types (precise signatures) + +### 4.1 NEW — `ItemDragPayload` + `ItemDragSource` (`UI/ItemDragPayload.cs`) +```csharp +namespace AcDream.App.UI; + +/// Where a dragged item came from — the retail InqDropIconInfo flag +/// distinction (flags&0xE==0 fresh-from-inventory vs flags&4 within-list reorder) +/// expressed as a typed enum. The drop handler maps SourceKind+target back to the +/// fresh-vs-reorder decision. Decomp: gmToolbarUI 0x004bd162 / 0x004bd1af. +public enum ItemDragSource { Inventory, ShortcutBar, Equipment, Ground } + +/// Snapshot of a drag-in-progress, taken at drag-begin (so a server move +/// arriving mid-drag can't mutate it under us). Port of retail's m_dragElement + +/// InqDropIconInfo out-params (objId/container/flags, decomp 230533). +/// SourceContainer is NOT stored — the handler resolves the LIVE container from +/// ClientObjectTable.Get(ObjId).ContainerId at drop (single source of truth; same +/// value retail reads off the element, see §7). +public sealed record ItemDragPayload( + uint ObjId, // dragged weenie guid (retail itemID, +0x5FC) + ItemDragSource SourceKind, // what kind of slot it left + int SourceSlot, // the source cell's SlotIndex (retail m_lastShortcutNumDragged) + UiItemSlot SourceCell); // back-ref: reorder-restore / clear source state / ghost texture +``` + +### 4.2 CHANGED — `UiElement` (two new virtuals; default null) +```csharp +/// The data this element carries when a drag begins. UiRoot.BeginDrag pulls +/// this; a NULL return CANCELS the drag (retail: ItemList_BeginDrag only arms an +/// occupied cell). Default null = not draggable. +public virtual object? GetDragPayload() => null; + +/// The texture UiRoot paints at the cursor while this element is the drag +/// source: (GL handle, width, height). Null = no ghost. Keeps UiRoot item-agnostic. +public virtual (uint tex, int w, int h)? GetDragGhost() => null; +``` + +### 4.3 CHANGED — `UiRoot` +- `BeginDrag(UiElement source)` (drop the `payload` param): pull + `var payload = source.GetDragPayload();` → if `null`, set `_dragCandidate = false` + and **return without arming** (no `DragSource`, no event). Else set + `DragSource`/`DragPayload`, fire `DragBegin` with the payload. Call site (line 188) + becomes `BeginDrag(Captured);`. +- `Draw(ctx)`: after `DrawOverlays`, still inside the overlay layer, draw the ghost: + ```csharp + if (DragSource?.GetDragGhost() is { tex: var t, w: var gw, h: var gh } && t != 0) + ctx.DrawSprite(t, MouseX - gw/2f, MouseY - gh/2f, gw, gh, 0,0,1,1, + new Vector4(1,1,1, GhostAlpha)); // GhostAlpha = 0.6f + ``` + Root transform is at origin during `Draw`, so cursor coords are absolute. The + ghost is NOT a tree element → it never intercepts hit-tests. + +### 4.4 CHANGED — `UiItemSlot` +- Add `public int SlotIndex { get; set; } = -1;` (the cell's own index within its + panel — 0..17 toolbar, container slot for inventory; distinct from `ShortcutNum`, + the 1–9 LABEL which is -1 on the bottom row). +- Add `public ItemDragSource SourceKind { get; set; } = ItemDragSource.Inventory;` + (controller overrides; toolbar = `ShortcutBar`). +- Add drag-accept overlay sprites (confirmed ids; configurable; guard `id != 0` + before resolving — the magenta footgun): + ```csharp + public uint DragAcceptSprite { get; set; } = 0x060011F9u; // ItemSlot_DragOver_Accept + public uint DragRejectSprite { get; set; } = 0x060011F8u; // ItemSlot_DragOver_Reject + private enum DragAccept { None, Accept, Reject } + private DragAccept _dragAccept = DragAccept.None; + ``` +- `GetDragPayload()` → `ItemId != 0 ? new ItemDragPayload(ItemId, SourceKind, SlotIndex, this) : null`. +- `GetDragGhost()` → `ItemId != 0 && IconTexture != 0 ? (IconTexture, (int)Width, (int)Height) : null`. +- **Move use from MouseDown → Click** (the disambiguation fix): `OnEvent` keeps + `MouseDown` → `return true` (still consume the press, but DON'T fire use), and adds + `Click` (0x01) → `Clicked?.Invoke(); return true`. (`UiRoot` already suppresses the + post-drag Click at line 311, so a completed drag won't also use the item; a + press-release-without-move still emits Click → use.) `WireClick` in + `ToolbarController` is unchanged — it still assigns `Clicked`. +- `OnEvent` new cases (acdream's UiEventType is the contract — these are acdream's + DragEnter/DragOver/DropReleased, NOT retail's raw per-cell 0x21/0x3e/0x15 codes; + see §6 note): + ```csharp + case UiEventType.DragBegin: return true; // we're the source; payload already pulled + case UiEventType.DragEnter: // pointer entered me mid-drag + _dragAccept = (FindList() is {} l && l.DragHandler is {} h + && e.Payload is ItemDragPayload p && h.OnDragOver(l, this, p)) + ? DragAccept.Accept : DragAccept.Reject; + return true; + case UiEventType.DragOver: _dragAccept = DragAccept.None; return true; // UiRoot fires on LEAVE + case UiEventType.DropReleased: + _dragAccept = DragAccept.None; + if (e.Data0 == 1 // accepted = dropped on a DIFFERENT element (skips self/empty) + && FindList() is {} dl && dl.DragHandler is {} dh + && e.Payload is ItemDragPayload dp) + dh.HandleDropRelease(dl, this, dp); + return true; + ``` + `FindList()` walks `Parent` until a `UiItemList`. The accept/reject overlay draws + in `OnDraw` on top of the icon+digit, `id != 0` guarded. + +### 4.5 NEW — `IItemListDragHandler` (`UI/IItemListDragHandler.cs`) +```csharp +/// A panel controller implements this and registers itself on each of its +/// UiItemLists. Port of retail's m_dragHandler vtable (RegisterItemListDragHandler, +/// decomp 230461). OnDragOver decides the accept/reject OVERLAY only (advisory); +/// HandleDropRelease is authoritative — it does the action (or no-ops to reject). +public interface IItemListDragHandler +{ + bool OnDragOver(UiItemList targetList, UiItemSlot targetCell, ItemDragPayload payload); + void HandleDropRelease(UiItemList targetList, UiItemSlot targetCell, ItemDragPayload payload); +} +``` + +### 4.6 CHANGED — `UiItemList` +- Add `public IItemListDragHandler? DragHandler { get; private set; }` + + `public void RegisterDragHandler(IItemListDragHandler h) => DragHandler = h;`. + +### 4.7 CHANGED — `ToolbarController` (the stub handler — TS-33) +- `: IItemListDragHandler`. In the ctor, after caching slots, set each cell's + `SlotIndex = i` and `SourceKind = ItemDragSource.ShortcutBar`, and + `list.RegisterDragHandler(this)`. +- `OnDragOver` → `payload.ObjId != 0` (accept any real item; retail's + `IsShortcutEligible` gate is B.2). +- `HandleDropRelease` → `Console.WriteLine($"[B.2 TODO] drop obj 0x{payload.ObjId:X8} " + + $"from {payload.SourceKind} slot {payload.SourceSlot} → toolbar slot {targetCell.SlotIndex}");` + No store mutation, no wire. (TS-33; replaced wholesale in B.2.) + +--- + +## 5. Data flow — frame by frame + +1. **Press** on an occupied toolbar slot → `UiRoot.OnMouseDown` sets `Captured = cell`, + `_dragCandidate = true` (cell isn't `CapturesPointerDrag`, isn't a window-drag). +2. **Move > 3 px** → `UiRoot.OnMouseMove` calls `BeginDrag(cell)` → pulls + `cell.GetDragPayload()` = `ItemDragPayload{ObjId, ShortcutBar, srcSlot, cell}` + (non-null since occupied) → arms `DragSource`/`DragPayload`, fires `DragBegin`. +3. **Each move while dragging** → `UpdateDragHover(x,y)` hit-tests; on target CHANGE + it fires `DragOver` (LEAVE) on the old cell → its overlay resets to neutral, and + `DragEnter` on the new cell → it asks its list's handler `OnDragOver` → overlay + flips accept/reject. Meanwhile `UiRoot.Draw` paints the ghost at the cursor. +4. **Release** → `UiRoot.OnMouseUp`: `DragSource` set → `FinishDrag(x,y)` delivers + `DropReleased` to the cell under the cursor (`Data0 = 1` if a different element). + The cell → its list's `HandleDropRelease(list, cell, payload)` → (stub) logs. + `DragSource`/`DragPayload` cleared → ghost stops. Capture released. +5. **Empty-slot drag attempt** → step 2 `GetDragPayload()` returns null → + `_dragCandidate=false`, nothing armed; the eventual mouse-up emits Click (no-op, + `Clicked` guards `ItemId != 0`). + +--- + +## 6. Edge cases & notes + +- **Event-id namespace.** acdream's `UiEventType` (DragBegin 0x15, DragEnter 0x21, + DragOver 0x1C, DropReleased 0x3E) is the contract the cell handles — these are + `UiRoot`'s constants and differ from retail's raw per-cell `ListenToElementMessage` + codes (0x21 begin / 0x3e over / 0x15 drop). Do NOT try to match the retail per-cell + codes; the SEMANTICS match, the numbers are `UiRoot`'s. (IA-12 already covers "UI + toolkit mirrors behavior, not byte-layout.") +- **Hover re-eval cadence.** `UiRoot.UpdateDragHover` early-returns when the target + is unchanged, so accept/reject is computed once per cell-enter, not every move. + For item cells the decision is constant per (cell, payload), so this is + behavior-equivalent to retail's per-move `MouseOverTop` — no register row. +- **Self-drop / drop-on-nothing.** `FinishDrag` sets `Data0=0` when the target is the + source or null; the cell gates `HandleDropRelease` on `Data0==1`, so both are clean + no-ops (= "put it back"). +- **Magenta footgun.** `DragAcceptSprite`/`DragRejectSprite` default non-zero; the + `OnDraw` overlay still guards `if (id != 0)` before `SpriteResolve`, never on the + returned GL handle (`feedback_ui_resolve_zero_magenta`). +- **No heavy diagnostics in the render loop.** The stub's `Console.WriteLine` fires + only on a discrete drop event (not per frame), so it can't eat dt-based animations. + +--- + +## 7. Divergence register rows (add in the IMPLEMENTATION commit) + +- **AP-47** (Documented approximation): *Drag ghost reuses the full composited + `m_pIcon` at reduced alpha (`GhostAlpha=0.6`) instead of retail's dedicated + `m_pDragIcon` (base + custom-overlay, NO underlay).* Where: `UiRoot.Draw` + + `UiItemSlot.GetDragGhost`. Why safe: cosmetic only — the dragged item is still + unambiguously identifiable; building the second composite is deferred polish. Risk: + the ghost shows the opaque type-default underlay backing rather than retail's + underlay-less translucent copy. Oracle: `IconData::RenderIcons` 407594-407625 + (m_pDragIcon path); deep-dive §3.2/§5.5. +- **TS-33** (Temporary stopgap): *Toolbar `IItemListDragHandler.HandleDropRelease` + is a logging stub — no `AddShortcut 0x019C`/`RemoveShortcut 0x019D` wire, no + `ShortcutStore` mutation.* Where: `ToolbarController.HandleDropRelease`. Awaiting: + Stream B.2. Risk: a drop onto the bar visibly does nothing but log; reorder/add/ + remove are inert until B.2. Oracle: `gmToolbarUI::HandleDropRelease` + acclient_2013_pseudo_c.txt:197971. + +**Note (not a register row):** the payload omits `SourceContainer`; the handler +resolves the live container via `ClientObjectTable.Get(ObjId).ContainerId` at drop. +Same container id retail reads off the dragged element — single source of truth, no +stale snapshot → no observable behavior deviation. + +--- + +## 8. Testing (conformance) + +App-layer tests in `tests/AcDream.App.Tests/UI/` (no GL — pure logic): +1. `GetDragPayload` returns null for an empty cell, a correct `ItemDragPayload` for a + bound cell (ObjId/SourceKind/SourceSlot/SourceCell). +2. `BeginDrag` does NOT arm (`DragSource == null`) when the source's payload is null; + arms + fires `DragBegin` when non-null. +3. Drop dispatch: a fake `IItemListDragHandler` registered on a `UiItemList` receives + `OnDragOver` on DragEnter and `HandleDropRelease` on DropReleased with the right + `(list, cell, payload)`; NOT called on a `Data0==0` self/empty drop. +4. Accept/reject overlay state flips to Accept when the handler returns true, Reject + when false, None on leave. +5. Use-vs-drag: a Click (no intervening drag) fires `Clicked`; a completed drag does + not (drive `UiRoot` MouseDown→move>3px→MouseUp and assert `Clicked` didn't fire). +6. `FindList()` resolves the parent `UiItemList` through the cell's `Parent` chain. + +Drive these through `UiRoot`'s public `OnMouseDown/OnMouseMove/OnMouseUp` where +possible (integration-level), so the device chain is exercised, not just the cell in +isolation. + +--- + +## 9. Acceptance criteria + +- [ ] `dotnet build` green; `dotnet test` green (existing + the new §8 tests). +- [ ] `UiRoot` has zero compile-time dependency on `UiItemSlot`/`ItemDragPayload` + (item-agnostic; only the two `UiElement` virtuals bridge). +- [ ] AP-47 + TS-33 rows added in the implementation commit; counts bumped. +- [ ] **Visual (user):** with `ACDREAM_RETAIL_UI=1`, in-world, grab a hotbar item + and drag it: a translucent ghost follows the cursor; the hovered slot shows the + accept (green) frame, a slot that rejects shows red; on release over another + slot the log prints `[B.2 TODO] drop obj … → toolbar slot N`; the source item + stays put (no wire yet). A plain click still USES the item; a drag does not. +- [ ] Memory updated if a durable lesson emerged; roadmap/ISSUES note if needed. + +--- + +## 10. Subagent task slices (for the plan) + +1. **Toolkit core** — `ItemDragPayload`/`ItemDragSource`, the two `UiElement` + virtuals, `UiRoot.BeginDrag` payload pull + cancel, `UiRoot.Draw` ghost. +2. **Cell hooks** — `UiItemSlot`: `SlotIndex`/`SourceKind`, `GetDragPayload`/ + `GetDragGhost`, MouseDown→Click move, the DragEnter/Over/DropReleased cases + + accept/reject overlay; `IItemListDragHandler`; `UiItemList.DragHandler`. +3. **Toolbar stub + wiring** — `ToolbarController : IItemListDragHandler`, register + + set SlotIndex/SourceKind, the logging `HandleDropRelease`; register rows. +4. **Tests** — the §8 suite. + +(Slices 1→2 are sequential; 3 depends on 2; 4 can follow each.) From 24ce8a0b5e8e9aa3fbd95cc42a1434b1c2dbf710 Mon Sep 17 00:00:00 2001 From: Erik Date: Sat, 20 Jun 2026 12:31:37 +0200 Subject: [PATCH 002/133] docs(D.5.3/B.1): drag-drop spine implementation plan Bite-sized TDD plan for Stream B.1, four tasks matching the spec slices: 1. ItemDragPayload + ItemDragSource + IItemListDragHandler + UiItemList handler registration. 2. UiElement GetDragPayload/GetDragGhost virtuals + UiItemSlot drag source + drop target + accept/reject overlay; use moves MouseDown->Click (fixes the existing ToolbarControllerTests.Click test in-task to stay green). 3. UiRoot payload pull + cancel-on-null + cursor ghost (AP-47). 4. ToolbarController stub handler + spine wiring (TS-33) + registration tests. Exact code, commands, and the AP-47/TS-33 register rows inline. Plan + spec preapproved by the user; executing subagent-driven next. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../plans/2026-06-20-d2b-drag-drop-spine.md | 752 ++++++++++++++++++ 1 file changed, 752 insertions(+) create mode 100644 docs/superpowers/plans/2026-06-20-d2b-drag-drop-spine.md diff --git a/docs/superpowers/plans/2026-06-20-d2b-drag-drop-spine.md b/docs/superpowers/plans/2026-06-20-d2b-drag-drop-spine.md new file mode 100644 index 00000000..71a6ac50 --- /dev/null +++ b/docs/superpowers/plans/2026-06-20-d2b-drag-drop-spine.md @@ -0,0 +1,752 @@ +# Drag-drop spine (Stream B.1) Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Complete the widget-level drag-drop machine so an item icon can be picked up, dragged with a cursor-following ghost, hovered slots flip accept/reject, and the drop is dispatched to the owning panel's handler — with a visible toolbar stub proving the chain this session. + +**Architecture:** `UiRoot` already holds the device-level drag state machine. We add (1) a typed `ItemDragPayload` + `IItemListDragHandler`, (2) two `UiElement` virtuals (`GetDragPayload`/`GetDragGhost`) so `UiRoot` stays item-agnostic, (3) drag/drop event handling + an accept/reject overlay on the cell (`UiItemSlot`), which delegates the accept decision + dispatch UP to its parent `UiItemList`'s registered handler, and (4) a logging toolbar stub handler (wire deferred to B.2). Spec: `docs/superpowers/specs/2026-06-20-d2b-drag-drop-spine-design.md`. + +**Tech Stack:** C# / .NET 10, xUnit (`[Fact]`, `Assert.*`), the `src/AcDream.App/UI/` retained-mode toolkit. `InternalsVisibleTo("AcDream.App.Tests")` is set — internal members are test-visible. Tests are pure-logic (no GL); the cursor ghost render is visual-only (no unit test), confirmed by the user at the end. + +--- + +## File Structure + +**Create:** +- `src/AcDream.App/UI/ItemDragPayload.cs` — `ItemDragSource` enum + `ItemDragPayload` record (the drag snapshot). +- `src/AcDream.App/UI/IItemListDragHandler.cs` — the panel-handler interface. +- `tests/AcDream.App.Tests/UI/DragDropSpineTests.cs` — toolkit-level conformance. + +**Modify:** +- `src/AcDream.App/UI/UiItemList.cs` — `DragHandler` property + `RegisterDragHandler`. +- `src/AcDream.App/UI/UiElement.cs` — `GetDragPayload()` + `GetDragGhost()` virtuals. +- `src/AcDream.App/UI/UiItemSlot.cs` — `SlotIndex`, `SourceKind`, accept/reject overlay, the two overrides, drag/drop `OnEvent` cases, `FindList`, MouseDown→Click use move. +- `src/AcDream.App/UI/UiRoot.cs` — `BeginDrag` payload pull + cancel; cursor ghost in `Draw`. +- `src/AcDream.App/UI/Layout/ToolbarController.cs` — implement `IItemListDragHandler`, register on each slot list, set `SlotIndex`/`SourceKind`, stub `HandleDropRelease`. +- `tests/AcDream.App.Tests/UI/Layout/ToolbarControllerTests.cs` — fix `Click_emitsUseForBoundItem` (send Click not MouseDown), add registration tests. +- `docs/architecture/retail-divergence-register.md` — add **AP-47** (Task 3) + **TS-33** (Task 4). + +**Build/test commands** (run from repo root): +- Build: `dotnet build src/AcDream.App/AcDream.App.csproj -c Debug` +- Test a class: `dotnet test tests/AcDream.App.Tests/AcDream.App.Tests.csproj --filter "FullyQualifiedName~DragDropSpine"` +- Full app tests: `dotnet test tests/AcDream.App.Tests/AcDream.App.Tests.csproj` + +**Every commit ends with the trailer** `Co-Authored-By: Claude Opus 4.8 (1M context) ` (per CLAUDE.md). + +--- + +## Task 1: Payload types + handler interface + UiItemList registration + +**Files:** +- Create: `src/AcDream.App/UI/ItemDragPayload.cs` +- Create: `src/AcDream.App/UI/IItemListDragHandler.cs` +- Modify: `src/AcDream.App/UI/UiItemList.cs` +- Test: `tests/AcDream.App.Tests/UI/DragDropSpineTests.cs` + +- [ ] **Step 1: Write the failing test** + +Create `tests/AcDream.App.Tests/UI/DragDropSpineTests.cs`: + +```csharp +using AcDream.App.UI; +using Xunit; + +namespace AcDream.App.Tests.UI; + +public class DragDropSpineTests +{ + // A spy handler used across the spine tests. + private sealed class SpyHandler : IItemListDragHandler + { + public bool AcceptResult = true; + public (UiItemList list, UiItemSlot cell, ItemDragPayload payload)? LastOver; + public (UiItemList list, UiItemSlot cell, ItemDragPayload payload)? LastDrop; + + public bool OnDragOver(UiItemList list, UiItemSlot cell, ItemDragPayload p) + { LastOver = (list, cell, p); return AcceptResult; } + + public void HandleDropRelease(UiItemList list, UiItemSlot cell, ItemDragPayload p) + { LastDrop = (list, cell, p); } + } + + [Fact] + public void Payload_holdsAllFields() + { + var src = new UiItemSlot(); + var p = new ItemDragPayload(0x5001u, ItemDragSource.ShortcutBar, 3, src); + Assert.Equal(0x5001u, p.ObjId); + Assert.Equal(ItemDragSource.ShortcutBar, p.SourceKind); + Assert.Equal(3, p.SourceSlot); + Assert.Same(src, p.SourceCell); + } + + [Fact] + public void UiItemList_registerDragHandler_roundtrips() + { + var list = new UiItemList(_ => (0u, 0, 0)); + Assert.Null(list.DragHandler); + var h = new SpyHandler(); + list.RegisterDragHandler(h); + Assert.Same(h, list.DragHandler); + } +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `dotnet test tests/AcDream.App.Tests/AcDream.App.Tests.csproj --filter "FullyQualifiedName~DragDropSpine"` +Expected: FAIL to compile — `ItemDragPayload`, `ItemDragSource`, `IItemListDragHandler`, `UiItemList.DragHandler`, `RegisterDragHandler` not defined. + +- [ ] **Step 3: Create the payload + enum** + +Create `src/AcDream.App/UI/ItemDragPayload.cs`: + +```csharp +namespace AcDream.App.UI; + +/// +/// Where a dragged item came from — the retail InqDropIconInfo flag +/// distinction (flags & 0xE == 0 fresh-from-inventory vs +/// flags & 4 within-list reorder) expressed as a typed enum. The drop +/// handler maps SourceKind + target back to the fresh-vs-reorder decision. +/// Decomp anchors: gmToolbarUI 0x004bd162 / 0x004bd1af; InqDropIconInfo 230533. +/// +public enum ItemDragSource { Inventory, ShortcutBar, Equipment, Ground } + +/// +/// Snapshot of a drag-in-progress, taken at drag-begin (so a server move arriving +/// mid-drag can't mutate it under us). Port of retail's m_dragElement + +/// InqDropIconInfo out-params (objId/container/flags, decomp 230533). +/// SourceContainer is intentionally NOT stored: the handler resolves the +/// LIVE container via ClientObjectTable.Get(ObjId).ContainerId at drop — the +/// same container id retail reads off the dragged element, single source of truth. +/// +public sealed record ItemDragPayload( + uint ObjId, // dragged weenie guid (retail itemID, +0x5FC) + ItemDragSource SourceKind, // what kind of slot it left + int SourceSlot, // the source cell's SlotIndex (retail m_lastShortcutNumDragged) + UiItemSlot SourceCell); // back-ref: reorder-restore / clear source state / ghost +``` + +- [ ] **Step 4: Create the handler interface** + +Create `src/AcDream.App/UI/IItemListDragHandler.cs`: + +```csharp +namespace AcDream.App.UI; + +/// +/// A panel controller implements this and registers itself on each of its +/// s. Port of retail's m_dragHandler vtable +/// (RegisterItemListDragHandler, decomp 230461; confirmed acclient +/// 0x004a539e + the gmToolbarUI block 0x004bdd89). +/// decides the accept/reject OVERLAY only (advisory). +/// is authoritative — it performs the action, or +/// no-ops to reject. +/// +public interface IItemListDragHandler +{ + /// True ⇒ the target cell shows the accept (green) frame; false ⇒ reject (red). + bool OnDragOver(UiItemList targetList, UiItemSlot targetCell, ItemDragPayload payload); + + /// Perform the drop (issue the per-panel wire action), or no-op to reject. + void HandleDropRelease(UiItemList targetList, UiItemSlot targetCell, ItemDragPayload payload); +} +``` + +- [ ] **Step 5: Add registration to UiItemList** + +In `src/AcDream.App/UI/UiItemList.cs`, add inside the class (e.g. just after the `SpriteResolve` property at ~line 25): + +```csharp + /// The drag handler this list routes drops to (a panel controller). + /// Null = the list accepts no drops. Retail: UIElement_ItemList::m_dragHandler. + public IItemListDragHandler? DragHandler { get; private set; } + + /// Register the panel's drag handler on this list. Retail: + /// UIElement_ItemList::RegisterItemListDragHandler (decomp 230461). + public void RegisterDragHandler(IItemListDragHandler handler) => DragHandler = handler; +``` + +- [ ] **Step 6: Run test to verify it passes** + +Run: `dotnet test tests/AcDream.App.Tests/AcDream.App.Tests.csproj --filter "FullyQualifiedName~DragDropSpine"` +Expected: PASS (2 tests). + +- [ ] **Step 7: Commit** + +```bash +git add src/AcDream.App/UI/ItemDragPayload.cs src/AcDream.App/UI/IItemListDragHandler.cs src/AcDream.App/UI/UiItemList.cs tests/AcDream.App.Tests/UI/DragDropSpineTests.cs +git commit -m "feat(ui): D.5.3/B.1 — drag payload + handler interface + UiItemList registration" -m "Co-Authored-By: Claude Opus 4.8 (1M context) " +``` + +--- + +## Task 2: UiElement drag hooks + UiItemSlot drag/drop + overlay + +**Files:** +- Modify: `src/AcDream.App/UI/UiElement.cs` +- Modify: `src/AcDream.App/UI/UiItemSlot.cs` +- Modify: `tests/AcDream.App.Tests/UI/Layout/ToolbarControllerTests.cs` (fix the one MouseDown→Click test) +- Test: `tests/AcDream.App.Tests/UI/DragDropSpineTests.cs` (add cases) + +- [ ] **Step 1: Write the failing tests** (append to `DragDropSpineTests.cs`, inside the class) + +```csharp + // ── UiItemSlot drag-source payload/ghost ──────────────────────────────── + [Fact] + public void GetDragPayload_emptyCell_isNull() + => Assert.Null(new UiItemSlot().GetDragPayload()); + + [Fact] + public void GetDragPayload_boundCell_snapshotsFields() + { + var cell = new UiItemSlot { SlotIndex = 4, SourceKind = ItemDragSource.ShortcutBar }; + cell.SetItem(0x5001u, 0x99u); + var p = Assert.IsType(cell.GetDragPayload()); + Assert.Equal(0x5001u, p.ObjId); + Assert.Equal(ItemDragSource.ShortcutBar, p.SourceKind); + Assert.Equal(4, p.SourceSlot); + Assert.Same(cell, p.SourceCell); + } + + [Fact] + public void GetDragGhost_emptyCell_isNull() + => Assert.Null(new UiItemSlot().GetDragGhost()); + + [Fact] + public void GetDragGhost_boundCell_returnsIconTuple() + { + var cell = new UiItemSlot { Width = 32, Height = 32 }; + cell.SetItem(0x5001u, 0x99u); + var g = cell.GetDragGhost(); + Assert.NotNull(g); + Assert.Equal(0x99u, g!.Value.tex); + Assert.Equal(32, g.Value.w); + Assert.Equal(32, g.Value.h); + } + + // ── cell drop-target: DragEnter overlay + DropReleased dispatch ────────── + private static (UiItemList list, UiItemSlot cell, SpyHandler h) ListWithHandler() + { + var list = new UiItemList(_ => (1u, 1, 1)); // non-zero resolve so overlay draw is harmless + var h = new SpyHandler(); + list.RegisterDragHandler(h); + return (list, list.Cell, h); + } + + private static ItemDragPayload SomePayload() + => new(0x5001u, ItemDragSource.ShortcutBar, 0, new UiItemSlot()); + + [Fact] + public void DragEnter_setsAcceptOverlay_whenHandlerAccepts() + { + var (_, cell, h) = ListWithHandler(); + h.AcceptResult = true; + cell.OnEvent(new UiEvent(0u, cell, UiEventType.DragEnter, Payload: SomePayload())); + Assert.Equal(UiItemSlot.DragAcceptState.Accept, cell.DragAcceptVisual); + } + + [Fact] + public void DragEnter_setsRejectOverlay_whenHandlerRejects() + { + var (_, cell, h) = ListWithHandler(); + h.AcceptResult = false; + cell.OnEvent(new UiEvent(0u, cell, UiEventType.DragEnter, Payload: SomePayload())); + Assert.Equal(UiItemSlot.DragAcceptState.Reject, cell.DragAcceptVisual); + } + + [Fact] + public void DragOver_resetsOverlayToNeutral() + { + var (_, cell, h) = ListWithHandler(); + cell.OnEvent(new UiEvent(0u, cell, UiEventType.DragEnter, Payload: SomePayload())); + cell.OnEvent(new UiEvent(0u, cell, UiEventType.DragOver, Payload: SomePayload())); + Assert.Equal(UiItemSlot.DragAcceptState.None, cell.DragAcceptVisual); + } + + [Fact] + public void DropReleased_accepted_dispatchesToHandler() + { + var (list, cell, h) = ListWithHandler(); + var p = SomePayload(); + cell.OnEvent(new UiEvent(0u, cell, UiEventType.DropReleased, Data0: 1, Payload: p)); + Assert.NotNull(h.LastDrop); + Assert.Same(list, h.LastDrop!.Value.list); + Assert.Same(cell, h.LastDrop.Value.cell); + Assert.Same(p, h.LastDrop.Value.payload); + } + + [Fact] + public void DropReleased_notAccepted_skipsDispatch() + { + var (_, cell, h) = ListWithHandler(); + cell.OnEvent(new UiEvent(0u, cell, UiEventType.DropReleased, Data0: 0, Payload: SomePayload())); + Assert.Null(h.LastDrop); + } +``` + +- [ ] **Step 2: Run to verify it fails** + +Run: `dotnet test tests/AcDream.App.Tests/AcDream.App.Tests.csproj --filter "FullyQualifiedName~DragDropSpine"` +Expected: FAIL to compile — `GetDragPayload`/`GetDragGhost` not on `UiItemSlot`, `SlotIndex`/`SourceKind`/`DragAcceptVisual`/`DragAcceptState` not defined. + +- [ ] **Step 3: Add the two virtuals to UiElement** + +In `src/AcDream.App/UI/UiElement.cs`, add to the "Virtual overrides" region (e.g. after `OnEvent` at ~line 204): + +```csharp + /// The data this element carries when a drag begins. + /// pulls this on drag-promote; a NULL return CANCELS the drag (retail: + /// ItemList_BeginDrag only arms an occupied cell). Default null = not draggable. + public virtual object? GetDragPayload() => null; + + /// The texture paints at the cursor while this element + /// is the drag source: (GL handle, width, height). Null = no ghost. Keeps + /// item-agnostic. Retail analog: m_dragIcon (decomp 229738). + public virtual (uint tex, int w, int h)? GetDragGhost() => null; +``` + +- [ ] **Step 4: Add drag state + overrides + event handling to UiItemSlot** + +In `src/AcDream.App/UI/UiItemSlot.cs`: + +(a) Add fields/props after `SourceKind` area — insert after the `IconTexture` property (~line 22): + +```csharp + /// This cell's own index within its panel (0..17 toolbar; container slot + /// for inventory). Distinct from (the 1–9 label, -1 on the + /// bottom row). Set by the controller; used as the drag payload's SourceSlot and to + /// identify the drop TARGET slot. + public int SlotIndex { get; set; } = -1; + + /// What kind of slot this is, for the drag payload (retail InqDropIconInfo + /// flags). Controller overrides; default Inventory. + public ItemDragSource SourceKind { get; set; } = ItemDragSource.Inventory; + + /// Drag-rollover accept frame (retail ItemSlot_DragOver_Accept 0x060011F9, + /// state id 0x10000041). Configurable; guard id != 0 before resolving. + public uint DragAcceptSprite { get; set; } = 0x060011F9u; + /// Drag-rollover reject frame (retail ItemSlot_DragOver_Reject 0x060011F8, + /// state id 0x10000040). + public uint DragRejectSprite { get; set; } = 0x060011F8u; + + /// Accept/reject overlay state while a drag hovers this cell. + public enum DragAcceptState { None, Accept, Reject } + private DragAcceptState _dragAccept = DragAcceptState.None; + /// Current overlay state — internal so unit tests can assert it (InternalsVisibleTo). + internal DragAcceptState DragAcceptVisual => _dragAccept; +``` + +(b) Add the two overrides (anywhere in the class, e.g. after `Clear()`): + +```csharp + /// + public override object? GetDragPayload() + => ItemId != 0 ? new ItemDragPayload(ItemId, SourceKind, SlotIndex, this) : null; + + /// + public override (uint tex, int w, int h)? GetDragGhost() + => ItemId != 0 && IconTexture != 0 ? (IconTexture, (int)Width, (int)Height) : null; + + /// Walk up to the containing (the drop handler owner). + private UiItemList? FindList() + { + UiElement? e = Parent; + while (e is not null) { if (e is UiItemList l) return l; e = e.Parent; } + return null; + } +``` + +(c) Replace the `OnEvent` method (currently MouseDown→Clicked) with: + +```csharp + /// + public override bool OnEvent(in UiEvent e) + { + switch (e.Type) + { + // Use fires on CLICK (mouse-up over the same cell), not MouseDown — so a + // drag (press + >3px move) does NOT also use the item. UiRoot suppresses the + // post-drag Click (UiRoot.cs FinishDrag returns before the Click emit). + case UiEventType.MouseDown: + return true; // consume the press; no use here + case UiEventType.Click: + Clicked?.Invoke(); + return true; + + case UiEventType.DragBegin: + return true; // we're the source; payload already pulled by UiRoot + + case UiEventType.DragEnter: // pointer entered me mid-drag → ask the list's handler + _dragAccept = (FindList() is { DragHandler: { } h } list + && e.Payload is ItemDragPayload p && h.OnDragOver(list, this, p)) + ? DragAcceptState.Accept : DragAcceptState.Reject; + return true; + + case UiEventType.DragOver: // UiRoot fires this on LEAVE → neutral + _dragAccept = DragAcceptState.None; + return true; + + case UiEventType.DropReleased: + _dragAccept = DragAcceptState.None; + if (e.Data0 == 1 // accepted = dropped on a DIFFERENT element (skips self/empty) + && FindList() is { DragHandler: { } dh } dl + && e.Payload is ItemDragPayload dp) + dh.HandleDropRelease(dl, this, dp); + return true; + } + return false; + } +``` + +(d) In `OnDraw`, add the accept/reject overlay AFTER the digit-overlay block (at the very end of `OnDraw`): + +```csharp + // Drag-rollover accept/reject frame (retail SetDragAcceptState 0x10000041/40). + // Guard id != 0 BEFORE resolving — resolve(0) returns the 1×1 magenta placeholder + // with a non-zero GL handle (feedback_ui_resolve_zero_magenta). + if (_dragAccept != DragAcceptState.None && SpriteResolve is not null) + { + uint id = _dragAccept == DragAcceptState.Accept ? DragAcceptSprite : DragRejectSprite; + if (id != 0) + { + var (tex, _, _) = SpriteResolve(id); + if (tex != 0) + ctx.DrawSprite(tex, 0f, 0f, Width, Height, 0f, 0f, 1f, 1f, Vector4.One); + } + } +``` + +- [ ] **Step 5: Fix the existing controller test that sent MouseDown** + +In `tests/AcDream.App.Tests/UI/Layout/ToolbarControllerTests.cs`, in `Click_emitsUseForBoundItem`, change the event from `MouseDown` to `Click` (the test name already says "Click"): + +```csharp + // Use now fires on Click (mouse-up), not MouseDown — drag/click disambiguation. + slots[Row1[0]].Cell.OnEvent(new UiEvent(0u, null, UiEventType.Click)); +``` + +- [ ] **Step 6: Run to verify it passes** + +Run: `dotnet test tests/AcDream.App.Tests/AcDream.App.Tests.csproj --filter "FullyQualifiedName~DragDropSpine|FullyQualifiedName~ToolbarController|FullyQualifiedName~UiItemSlot"` +Expected: PASS (the new DragDropSpine cases, the fixed ToolbarController test, and all UiItemSlot tests). + +- [ ] **Step 7: Commit** + +```bash +git add src/AcDream.App/UI/UiElement.cs src/AcDream.App/UI/UiItemSlot.cs tests/AcDream.App.Tests/UI/DragDropSpineTests.cs tests/AcDream.App.Tests/UI/Layout/ToolbarControllerTests.cs +git commit -m "feat(ui): D.5.3/B.1 — UiItemSlot drag source + drop target + accept/reject overlay" -m "Co-Authored-By: Claude Opus 4.8 (1M context) " +``` + +--- + +## Task 3: UiRoot payload injection + cursor ghost (+ AP-47) + +**Files:** +- Modify: `src/AcDream.App/UI/UiRoot.cs` +- Modify: `docs/architecture/retail-divergence-register.md` +- Test: `tests/AcDream.App.Tests/UI/DragDropSpineTests.cs` (add full-chain cases) + +- [ ] **Step 1: Write the failing tests** (append to `DragDropSpineTests.cs`, inside the class) + +```csharp + // ── Full UiRoot chain: arming + use-vs-drag ───────────────────────────── + // A bound, hit-testable slot inside a list, sized for the hit-test. + private static (UiRoot root, UiItemList list, UiItemSlot cell) RootWithBoundSlot(uint itemId) + { + var root = new UiRoot { Width = 800, Height = 600 }; + var list = new UiItemList(_ => (1u, 1, 1)) { Left = 0, Top = 0, Width = 32, Height = 32 }; + // Tests don't run OnDraw (which sizes the cell), so size the cell explicitly. + list.Cell.Width = 32; list.Cell.Height = 32; + if (itemId != 0) list.Cell.SetItem(itemId, 0x99u); + root.AddChild(list); + return (root, list, list.Cell); + } + + [Fact] + public void BeginDrag_arms_whenPayloadNonNull() + { + var (root, _, cell) = RootWithBoundSlot(0x5001u); + root.OnMouseDown(UiMouseButton.Left, 10, 10); + root.OnMouseMove(20, 10); // >3px → promote to drag + Assert.Same(cell, root.DragSource); + Assert.IsType(root.DragPayload); + } + + [Fact] + public void BeginDrag_doesNotArm_whenPayloadNull_emptySlot() + { + var (root, _, _) = RootWithBoundSlot(0u); // empty cell → GetDragPayload null + root.OnMouseDown(UiMouseButton.Left, 10, 10); + root.OnMouseMove(20, 10); + Assert.Null(root.DragSource); // never armed + } + + [Fact] + public void Click_withoutDrag_firesUse() + { + var (root, _, cell) = RootWithBoundSlot(0x5001u); + bool used = false; + cell.Clicked = () => used = true; + root.OnMouseDown(UiMouseButton.Left, 10, 10); + root.OnMouseUp(UiMouseButton.Left, 10, 10); // no move → Click emitted + Assert.True(used); + } + + [Fact] + public void CompletedDrag_doesNotFireUse() + { + var (root, _, cell) = RootWithBoundSlot(0x5001u); + bool used = false; + cell.Clicked = () => used = true; + root.OnMouseDown(UiMouseButton.Left, 10, 10); + root.OnMouseMove(20, 10); // promote to drag + root.OnMouseUp(UiMouseButton.Left, 20, 10); // FinishDrag, NOT Click + Assert.False(used); + } +``` + +- [ ] **Step 2: Run to verify it fails** + +Run: `dotnet test tests/AcDream.App.Tests/AcDream.App.Tests.csproj --filter "FullyQualifiedName~DragDropSpine"` +Expected: FAIL — `BeginDrag_doesNotArm_whenPayloadNull_emptySlot` fails (current `BeginDrag` arms unconditionally with `payload: null`, so `DragSource` is non-null), and `CompletedDrag_doesNotFireUse` may behave wrong because a null-payload drag would still arm. + +- [ ] **Step 3: Change BeginDrag to pull the payload + cancel when null** + +In `src/AcDream.App/UI/UiRoot.cs`, replace the `BeginDrag` method (~line 450): + +```csharp + private void BeginDrag(UiElement source) + { + // Pull the payload from the source; a null payload (e.g. an empty item cell) + // CANCELS the drag — retail's ItemList_BeginDrag only arms an occupied cell. + var payload = source.GetDragPayload(); + if (payload is null) { _dragCandidate = false; return; } + + DragSource = source; + DragPayload = payload; + var e = new UiEvent(source.EventId, source, UiEventType.DragBegin, Payload: payload); + source.OnEvent(in e); + } +``` + +Update the single call site (~line 188 in `OnMouseMove`) from `BeginDrag(Captured, payload: null);` to: + +```csharp + BeginDrag(Captured); +``` + +- [ ] **Step 4: Add the cursor ghost to Draw** + +In `src/AcDream.App/UI/UiRoot.cs`, replace the `Draw` method (~line 133) so it paints the ghost in the overlay layer, and add the helper + constant: + +```csharp + public void Draw(UiRenderContext ctx) + { + DrawSelfAndChildren(ctx); + // Open popups/menus + the drag ghost draw ON TOP of the whole tree (overlay layer). + ctx.BeginOverlayLayer(); + DrawOverlays(ctx); + DrawDragGhost(ctx); + ctx.EndOverlayLayer(); + } + + /// Translucency of the cursor-following drag ghost. AP-47: we reuse the + /// item's full composited icon at this alpha rather than retail's dedicated + /// underlay-less m_pDragIcon. + private const float GhostAlpha = 0.6f; + + /// Paint the drag ghost at the cursor. The texture comes from the source + /// element's so UiRoot stays item-agnostic; the + /// ghost is NOT a tree element, so it never intercepts hit-tests. + private void DrawDragGhost(UiRenderContext ctx) + { + if (DragSource?.GetDragGhost() is not { } g || g.tex == 0) return; + ctx.DrawSprite(g.tex, MouseX - g.w / 2f, MouseY - g.h / 2f, g.w, g.h, + 0f, 0f, 1f, 1f, new Vector4(1f, 1f, 1f, GhostAlpha)); + } +``` + +- [ ] **Step 5: Run to verify it passes** + +Run: `dotnet test tests/AcDream.App.Tests/AcDream.App.Tests.csproj --filter "FullyQualifiedName~DragDropSpine"` +Expected: PASS (all DragDropSpine tests). + +- [ ] **Step 6: Add the AP-47 divergence row** + +In `docs/architecture/retail-divergence-register.md`: bump the AP section header count `## 3. Documented approximation (AP) — 42 rows` → `— 43 rows`, and append this row at the END of the AP table (after the `AP-46` row, before the section's `---`): + +```markdown +| AP-47 | Cursor drag ghost reuses the full composited `m_pIcon` at reduced alpha (`GhostAlpha=0.6`) instead of retail's dedicated `m_pDragIcon` (base + custom-overlay, NO type-default underlay) | `src/AcDream.App/UI/UiRoot.cs` (`DrawDragGhost`/`GhostAlpha`) → `src/AcDream.App/UI/UiItemSlot.cs` (`GetDragGhost`) | Cosmetic only — the dragged item is still unambiguously identifiable; building the second underlay-less composite is deferred polish; the ghost is item-agnostic in UiRoot via `GetDragGhost()` | The ghost shows the opaque type-default underlay backing rather than retail's underlay-less translucent copy — a subtle look difference while dragging, no functional effect | `IconData::RenderIcons` acclient_2013_pseudo_c.txt:407594-407625 (m_pDragIcon path); deep-dive §3.2/§5.5 | +``` + +- [ ] **Step 7: Commit** + +```bash +git add src/AcDream.App/UI/UiRoot.cs docs/architecture/retail-divergence-register.md tests/AcDream.App.Tests/UI/DragDropSpineTests.cs +git commit -m "feat(ui): D.5.3/B.1 — UiRoot payload injection + cursor drag ghost (AP-47)" -m "Co-Authored-By: Claude Opus 4.8 (1M context) " +``` + +--- + +## Task 4: Toolbar stub handler + wiring (+ TS-33 + registration tests) + +**Files:** +- Modify: `src/AcDream.App/UI/Layout/ToolbarController.cs` +- Modify: `tests/AcDream.App.Tests/UI/Layout/ToolbarControllerTests.cs` +- Modify: `docs/architecture/retail-divergence-register.md` + +- [ ] **Step 1: Write the failing tests** (append to `ToolbarControllerTests.cs`, inside the class) + +```csharp + // ── B.1: drag-drop spine wiring ────────────────────────────────────────── + + /// Bind registers the controller as each slot list's drag handler and + /// stamps every cell's SlotIndex + SourceKind=ShortcutBar. + [Fact] + public void Bind_registersDragHandler_andStampsSlots() + { + var (layout, slots, _) = FakeToolbar(); + var repo = new ClientObjectTable(); + + var ctrl = ToolbarController.Bind(layout, repo, + () => Array.Empty(), + iconIds: (_,_,_,_,_) => 0u, useItem: _ => { }); + + for (int i = 0; i < Row1.Length; i++) + { + Assert.Same(ctrl, slots[Row1[i]].DragHandler); + Assert.Equal(i, slots[Row1[i]].Cell.SlotIndex); + Assert.Equal(ItemDragSource.ShortcutBar, slots[Row1[i]].Cell.SourceKind); + } + // Bottom row slots are indices 9..17. + for (int j = 0; j < Row2.Length; j++) + Assert.Equal(9 + j, slots[Row2[j]].Cell.SlotIndex); + } + + /// OnDragOver accepts a real item (ObjId != 0). Eligibility (IsShortcutEligible) + /// is Stream B.2; the stub accepts any non-empty payload. + [Fact] + public void OnDragOver_acceptsRealItem() + { + var (layout, slots, _) = FakeToolbar(); + var ctrl = ToolbarController.Bind(layout, new ClientObjectTable(), + () => Array.Empty(), + iconIds: (_,_,_,_,_) => 0u, useItem: _ => { }); + + var list = slots[Row1[0]]; + var payload = new ItemDragPayload(0x5001u, ItemDragSource.Inventory, 0, new UiItemSlot()); + Assert.True(ctrl.OnDragOver(list, list.Cell, payload)); + } + + /// HandleDropRelease is a logging stub (TS-33): it must not throw and must not + /// mutate the target slot (no wire / no store yet). + [Fact] + public void HandleDropRelease_isInertStub() + { + var (layout, slots, _) = FakeToolbar(); + var ctrl = ToolbarController.Bind(layout, new ClientObjectTable(), + () => Array.Empty(), + iconIds: (_,_,_,_,_) => 0u, useItem: _ => { }); + + var list = slots[Row1[2]]; + var payload = new ItemDragPayload(0x5001u, ItemDragSource.ShortcutBar, 0, new UiItemSlot()); + var ex = Record.Exception(() => ctrl.HandleDropRelease(list, list.Cell, payload)); + Assert.Null(ex); + Assert.Equal(0u, list.Cell.ItemId); // unchanged — inert stub + } +``` + +- [ ] **Step 2: Run to verify it fails** + +Run: `dotnet test tests/AcDream.App.Tests/AcDream.App.Tests.csproj --filter "FullyQualifiedName~ToolbarController"` +Expected: FAIL to compile — `ToolbarController` doesn't implement `IItemListDragHandler` (`OnDragOver`/`HandleDropRelease`), `DragHandler` not registered. + +- [ ] **Step 3: Make ToolbarController implement the handler + wire registration** + +In `src/AcDream.App/UI/Layout/ToolbarController.cs`: + +(a) Change the class declaration: + +```csharp +public sealed class ToolbarController : IItemListDragHandler +``` + +(b) In the constructor, extend the slot loop (currently `_slots[i] = layout.FindElement(SlotIds[i]) as UiItemList; if (_slots[i] is { } list) WireClick(list);`) to also register the handler + stamp the cell: + +```csharp + for (int i = 0; i < SlotIds.Length; i++) + { + _slots[i] = layout.FindElement(SlotIds[i]) as UiItemList; + if (_slots[i] is { } list) + { + WireClick(list); + // B.1 drag-drop spine: this controller is the drop handler for every + // toolbar slot list; each cell knows its slot index + that it's a + // shortcut-bar source (retail UIElement_ItemList::RegisterItemListDragHandler). + list.RegisterDragHandler(this); + list.Cell.SlotIndex = i; + list.Cell.SourceKind = AcDream.App.UI.ItemDragSource.ShortcutBar; + } + } +``` + +(c) Add the interface implementation (e.g. at the end of the class): + +```csharp + // ── IItemListDragHandler (B.1 spine) ───────────────────────────────────── + // Retail: gmToolbarUI is the m_dragHandler for every shortcut slot list. + // The accept/reject gate (IsShortcutEligible) and the AddShortcut/RemoveShortcut + // wire are Stream B.2; this stub accepts any real item and LOGS the drop (TS-33). + + /// + public bool OnDragOver(UiItemList targetList, UiItemSlot targetCell, ItemDragPayload payload) + => payload.ObjId != 0; + + /// + public void HandleDropRelease(UiItemList targetList, UiItemSlot targetCell, ItemDragPayload payload) + { + // TS-33: no wire yet. Stream B.2 replaces this with the ShortcutStore mutation + + // AddShortcut 0x019C / RemoveShortcut 0x019D sends (gmToolbarUI::HandleDropRelease :197971). + Console.WriteLine($"[B.2 TODO] drop obj 0x{payload.ObjId:X8} from {payload.SourceKind} " + + $"slot {payload.SourceSlot} → toolbar slot {targetCell.SlotIndex}"); + } +``` + +(d) Confirm `using AcDream.App.UI;` is present at the top (it is). The `ItemDragSource`/`ItemDragPayload`/`UiItemSlot`/`UiItemList`/`IItemListDragHandler` types are all in `AcDream.App.UI`. + +- [ ] **Step 4: Run to verify it passes** + +Run: `dotnet test tests/AcDream.App.Tests/AcDream.App.Tests.csproj --filter "FullyQualifiedName~ToolbarController"` +Expected: PASS (the 3 new B.1 tests + all existing ToolbarController tests, including the fixed Click test). + +- [ ] **Step 5: Add the TS-33 divergence row** + +In `docs/architecture/retail-divergence-register.md`: bump the TS section header count `## 4. Temporary stopgap (TS) — 31 rows` → `— 32 rows`, and append this row at the END of the TS table (after the `TS-32` row, before the section's `---`): + +```markdown +| TS-33 | Toolbar `IItemListDragHandler.HandleDropRelease` is a logging stub — no `AddShortcut 0x019C` / `RemoveShortcut 0x019D` wire, no mutable `ShortcutStore`; a drop onto the bar logs and does nothing | `src/AcDream.App/UI/Layout/ToolbarController.cs` (`HandleDropRelease`) | The B.1 spine ships the drag machine (payload/ghost/overlay/dispatch) independent of the wire; the shortcut store + add/remove/reorder sends are Stream B.2, which needs the inventory window as a drag source too | A player dragging onto/within the hotbar sees the ghost + accept overlay but the drop is inert until B.2 — add/remove/reorder don't persist | `gmToolbarUI::HandleDropRelease` acclient_2013_pseudo_c.txt:197971; `AddShortcut`/`RemoveShortcut` 0x019C/0x019D | +``` + +- [ ] **Step 6: Run the FULL app test suite (no regressions)** + +Run: `dotnet build src/AcDream.App/AcDream.App.csproj -c Debug` then `dotnet test tests/AcDream.App.Tests/AcDream.App.Tests.csproj` +Expected: build green; all tests pass. + +- [ ] **Step 7: Commit** + +```bash +git add src/AcDream.App/UI/Layout/ToolbarController.cs tests/AcDream.App.Tests/UI/Layout/ToolbarControllerTests.cs docs/architecture/retail-divergence-register.md +git commit -m "feat(ui): D.5.3/B.1 — toolbar drag handler stub + spine wiring (TS-33)" -m "Co-Authored-By: Claude Opus 4.8 (1M context) " +``` + +--- + +## Final verification + +- [ ] **Full solution build + test:** `dotnet build AcDream.slnx -c Debug` and `dotnet test tests/AcDream.App.Tests/AcDream.App.Tests.csproj` — both green. +- [ ] **Self-check the acceptance criteria** in the spec §9 (UiRoot item-agnostic; AP-47 + TS-33 added; counts bumped). +- [ ] **Hand back for visual verification** (the user runs the client): with `ACDREAM_RETAIL_UI=1`, in-world — grab a hotbar item and drag: a translucent ghost follows the cursor; the hovered slot shows the accept (green) frame; on release over another slot the log prints `[B.2 TODO] drop obj … → toolbar slot N`; the source item stays put; a plain click still USES the item. (Launch per CLAUDE.md "Running the client" — build green first.) +- [ ] **Update memory** if a durable lesson emerged; note in `docs/ISSUES.md` / roadmap only if scope shifted. +``` From 9d483468d99de2e6ae3131809b7cd3b44f671621 Mon Sep 17 00:00:00 2001 From: Erik Date: Sat, 20 Jun 2026 12:34:21 +0200 Subject: [PATCH 003/133] =?UTF-8?q?feat(ui):=20D.5.3/B.1=20=E2=80=94=20dra?= =?UTF-8?q?g=20payload=20+=20handler=20interface=20+=20UiItemList=20regist?= =?UTF-8?q?ration?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 (1M context) --- src/AcDream.App/UI/IItemListDragHandler.cs | 19 +++++++++ src/AcDream.App/UI/ItemDragPayload.cs | 24 +++++++++++ src/AcDream.App/UI/UiItemList.cs | 8 ++++ .../UI/DragDropSpineTests.cs | 42 +++++++++++++++++++ 4 files changed, 93 insertions(+) create mode 100644 src/AcDream.App/UI/IItemListDragHandler.cs create mode 100644 src/AcDream.App/UI/ItemDragPayload.cs create mode 100644 tests/AcDream.App.Tests/UI/DragDropSpineTests.cs diff --git a/src/AcDream.App/UI/IItemListDragHandler.cs b/src/AcDream.App/UI/IItemListDragHandler.cs new file mode 100644 index 00000000..817effa0 --- /dev/null +++ b/src/AcDream.App/UI/IItemListDragHandler.cs @@ -0,0 +1,19 @@ +namespace AcDream.App.UI; + +/// +/// A panel controller implements this and registers itself on each of its +/// s. Port of retail's m_dragHandler vtable +/// (RegisterItemListDragHandler, decomp 230461; confirmed acclient +/// 0x004a539e + the gmToolbarUI block 0x004bdd89). +/// decides the accept/reject OVERLAY only (advisory). +/// is authoritative — it performs the action, or +/// no-ops to reject. +/// +public interface IItemListDragHandler +{ + /// True ⇒ the target cell shows the accept (green) frame; false ⇒ reject (red). + bool OnDragOver(UiItemList targetList, UiItemSlot targetCell, ItemDragPayload payload); + + /// Perform the drop (issue the per-panel wire action), or no-op to reject. + void HandleDropRelease(UiItemList targetList, UiItemSlot targetCell, ItemDragPayload payload); +} diff --git a/src/AcDream.App/UI/ItemDragPayload.cs b/src/AcDream.App/UI/ItemDragPayload.cs new file mode 100644 index 00000000..b06728d4 --- /dev/null +++ b/src/AcDream.App/UI/ItemDragPayload.cs @@ -0,0 +1,24 @@ +namespace AcDream.App.UI; + +/// +/// Where a dragged item came from — the retail InqDropIconInfo flag +/// distinction (flags & 0xE == 0 fresh-from-inventory vs +/// flags & 4 within-list reorder) expressed as a typed enum. The drop +/// handler maps SourceKind + target back to the fresh-vs-reorder decision. +/// Decomp anchors: gmToolbarUI 0x004bd162 / 0x004bd1af; InqDropIconInfo 230533. +/// +public enum ItemDragSource { Inventory, ShortcutBar, Equipment, Ground } + +/// +/// Snapshot of a drag-in-progress, taken at drag-begin (so a server move arriving +/// mid-drag can't mutate it under us). Port of retail's m_dragElement + +/// InqDropIconInfo out-params (objId/container/flags, decomp 230533). +/// SourceContainer is intentionally NOT stored: the handler resolves the +/// LIVE container via ClientObjectTable.Get(ObjId).ContainerId at drop — the +/// same container id retail reads off the dragged element, single source of truth. +/// +public sealed record ItemDragPayload( + uint ObjId, // dragged weenie guid (retail itemID, +0x5FC) + ItemDragSource SourceKind, // what kind of slot it left + int SourceSlot, // the source cell's SlotIndex (retail m_lastShortcutNumDragged) + UiItemSlot SourceCell); // back-ref: reorder-restore / clear source state / ghost diff --git a/src/AcDream.App/UI/UiItemList.cs b/src/AcDream.App/UI/UiItemList.cs index 761d4db3..6c4ce9bf 100644 --- a/src/AcDream.App/UI/UiItemList.cs +++ b/src/AcDream.App/UI/UiItemList.cs @@ -24,6 +24,14 @@ public sealed class UiItemList : UiElement public Func? SpriteResolve { get; set; } + /// The drag handler this list routes drops to (a panel controller). + /// Null = the list accepts no drops. Retail: UIElement_ItemList::m_dragHandler. + public IItemListDragHandler? DragHandler { get; private set; } + + /// Register the panel's drag handler on this list. Retail: + /// UIElement_ItemList::RegisterItemListDragHandler (decomp 230461). + public void RegisterDragHandler(IItemListDragHandler handler) => DragHandler = handler; + /// Convenience for single-cell slots (the toolbar): the first cell. /// Valid only while the list has at least one cell; after /// (the inventory-phase rebuild path) the list is empty until diff --git a/tests/AcDream.App.Tests/UI/DragDropSpineTests.cs b/tests/AcDream.App.Tests/UI/DragDropSpineTests.cs new file mode 100644 index 00000000..b6f67bfc --- /dev/null +++ b/tests/AcDream.App.Tests/UI/DragDropSpineTests.cs @@ -0,0 +1,42 @@ +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); + } +} From 1672eaa6208b446c9172d28448eea9af1d48101d Mon Sep 17 00:00:00 2001 From: Erik Date: Sat, 20 Jun 2026 12:40:53 +0200 Subject: [PATCH 004/133] =?UTF-8?q?feat(ui):=20D.5.3/B.1=20=E2=80=94=20UiI?= =?UTF-8?q?temSlot=20drag=20source=20+=20drop=20target=20+=20accept/reject?= =?UTF-8?q?=20overlay?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - UiElement: two new virtuals GetDragPayload()/GetDragGhost() (default null) keep UiRoot item-agnostic; any leaf can opt into drag by overriding these. - UiItemSlot: SlotIndex + SourceKind properties for payload identity; two overrides return ItemDragPayload / icon ghost when the slot is occupied. FindList() walks the parent chain to locate the owning UiItemList and its registered IItemListDragHandler. - UiItemSlot.OnEvent: MouseDown now just consumes the press; use-item fires on Click (mouse-up) so a drag doesn't also trigger the use-item callback. DragEnter → ask handler, set Accept/Reject overlay. DragOver → reset to None (fires on leave). DropReleased → clear overlay + dispatch to handler when Data0 == 1 (accepted). DragBegin consumed (source). - OnDraw: accept/reject sprite overlay drawn last, guarded on id != 0 to avoid the resolve(0)-→-magenta footgun. - ToolbarControllerTests: Click_emitsUseForBoundItem changed from MouseDown to Click to match the new dispatch. - 12 new DragDropSpineTests pass; full suite 481/483 (2 pre-existing skips). Co-Authored-By: Claude Opus 4.8 (1M context) --- src/AcDream.App/UI/UiElement.cs | 10 ++ src/AcDream.App/UI/UiItemSlot.cs | 86 ++++++++++++++++- .../UI/DragDropSpineTests.cs | 92 +++++++++++++++++++ .../UI/Layout/ToolbarControllerTests.cs | 4 +- 4 files changed, 189 insertions(+), 3 deletions(-) diff --git a/src/AcDream.App/UI/UiElement.cs b/src/AcDream.App/UI/UiElement.cs index b22da24e..04f37772 100644 --- a/src/AcDream.App/UI/UiElement.cs +++ b/src/AcDream.App/UI/UiElement.cs @@ -203,6 +203,16 @@ public abstract class UiElement /// public virtual bool OnEvent(in UiEvent e) => false; + /// The data this element carries when a drag begins. + /// pulls this on drag-promote; a NULL return CANCELS the drag (retail: + /// ItemList_BeginDrag only arms an occupied cell). Default null = not draggable. + public virtual object? GetDragPayload() => null; + + /// The texture paints at the cursor while this element + /// is the drag source: (GL handle, width, height). Null = no ghost. Keeps + /// item-agnostic. Retail analog: m_dragIcon (decomp 229738). + public virtual (uint tex, int w, int h)? GetDragGhost() => null; + /// /// Tooltip text for this widget. Retail fires event 0x07 after /// ~1000ms hover, then queries the widget's virtual "GetString" diff --git a/src/AcDream.App/UI/UiItemSlot.cs b/src/AcDream.App/UI/UiItemSlot.cs index d3ff3b7d..21cbc11f 100644 --- a/src/AcDream.App/UI/UiItemSlot.cs +++ b/src/AcDream.App/UI/UiItemSlot.cs @@ -21,6 +21,29 @@ public sealed class UiItemSlot : UiElement /// Pre-composited icon GL texture for the bound item (0 = none). public uint IconTexture { get; private set; } + /// This cell's own index within its panel (0..17 toolbar; container slot + /// for inventory). Distinct from (the 1–9 label, -1 on the + /// bottom row). Set by the controller; used as the drag payload's SourceSlot and to + /// identify the drop TARGET slot. + public int SlotIndex { get; set; } = -1; + + /// What kind of slot this is, for the drag payload (retail InqDropIconInfo + /// flags). Controller overrides; default Inventory. + public ItemDragSource SourceKind { get; set; } = ItemDragSource.Inventory; + + /// Drag-rollover accept frame (retail ItemSlot_DragOver_Accept 0x060011F9, + /// state id 0x10000041). Configurable; guard id != 0 before resolving. + public uint DragAcceptSprite { get; set; } = 0x060011F9u; + /// Drag-rollover reject frame (retail ItemSlot_DragOver_Reject 0x060011F8, + /// state id 0x10000040). + public uint DragRejectSprite { get; set; } = 0x060011F8u; + + /// Accept/reject overlay state while a drag hovers this cell. + public enum DragAcceptState { None, Accept, Reject } + private DragAcceptState _dragAccept = DragAcceptState.None; + /// Current overlay state — internal so unit tests can assert it (InternalsVisibleTo). + internal DragAcceptState DragAcceptVisual => _dragAccept; + /// Empty-slot sprite. Default = the generic toolbar empty-slot border /// 0x060074CF (uiitem template 0x21000037, state ItemSlot_Empty). Configurable so /// paperdoll equip slots can use their per-slot silhouettes later. @@ -37,6 +60,22 @@ public sealed class UiItemSlot : UiElement public void Clear() { ItemId = 0; IconTexture = 0; } + /// + public override object? GetDragPayload() + => ItemId != 0 ? new ItemDragPayload(ItemId, SourceKind, SlotIndex, this) : null; + + /// + public override (uint tex, int w, int h)? GetDragGhost() + => ItemId != 0 && IconTexture != 0 ? (IconTexture, (int)Width, (int)Height) : null; + + /// Walk up to the containing (the drop handler owner). + private UiItemList? FindList() + { + UiElement? e = Parent; + while (e is not null) { if (e is UiItemList l) return l; e = e.Parent; } + return null; + } + // ── Shortcut number (slot label) ───────────────────────────────────────── // Port of UIElement_UIItem::SetShortcutNum (acclient_2013_pseudo_c.txt:229465). // Retail draws the digit on the cell's ShortcutNum sub-element, picking the @@ -100,7 +139,38 @@ public sealed class UiItemSlot : UiElement /// public override bool OnEvent(in UiEvent e) { - if (e.Type == UiEventType.MouseDown) { Clicked?.Invoke(); return true; } + switch (e.Type) + { + // Use fires on CLICK (mouse-up over the same cell), not MouseDown — so a + // drag (press + >3px move) does NOT also use the item. UiRoot suppresses the + // post-drag Click (UiRoot.cs FinishDrag returns before the Click emit). + case UiEventType.MouseDown: + return true; // consume the press; no use here + case UiEventType.Click: + Clicked?.Invoke(); + return true; + + case UiEventType.DragBegin: + 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; } @@ -139,5 +209,19 @@ public sealed class UiItemSlot : UiElement } } } + + // Drag-rollover accept/reject frame (retail SetDragAcceptState 0x10000041/40). + // Guard id != 0 BEFORE resolving — resolve(0) returns the 1×1 magenta placeholder + // with a non-zero GL handle (feedback_ui_resolve_zero_magenta). + if (_dragAccept != DragAcceptState.None && SpriteResolve is not null) + { + uint id = _dragAccept == DragAcceptState.Accept ? DragAcceptSprite : DragRejectSprite; + if (id != 0) + { + var (tex, _, _) = SpriteResolve(id); + if (tex != 0) + ctx.DrawSprite(tex, 0f, 0f, Width, Height, 0f, 0f, 1f, 1f, Vector4.One); + } + } } } diff --git a/tests/AcDream.App.Tests/UI/DragDropSpineTests.cs b/tests/AcDream.App.Tests/UI/DragDropSpineTests.cs index b6f67bfc..d37bc66a 100644 --- a/tests/AcDream.App.Tests/UI/DragDropSpineTests.cs +++ b/tests/AcDream.App.Tests/UI/DragDropSpineTests.cs @@ -39,4 +39,96 @@ public class DragDropSpineTests list.RegisterDragHandler(h); Assert.Same(h, list.DragHandler); } + + // ── UiItemSlot drag-source payload/ghost ──────────────────────────────── + [Fact] + public void GetDragPayload_emptyCell_isNull() + => Assert.Null(new UiItemSlot().GetDragPayload()); + + [Fact] + public void GetDragPayload_boundCell_snapshotsFields() + { + var cell = new UiItemSlot { SlotIndex = 4, SourceKind = ItemDragSource.ShortcutBar }; + cell.SetItem(0x5001u, 0x99u); + var p = Assert.IsType(cell.GetDragPayload()); + Assert.Equal(0x5001u, p.ObjId); + Assert.Equal(ItemDragSource.ShortcutBar, p.SourceKind); + Assert.Equal(4, p.SourceSlot); + Assert.Same(cell, p.SourceCell); + } + + [Fact] + public void GetDragGhost_emptyCell_isNull() + => Assert.Null(new UiItemSlot().GetDragGhost()); + + [Fact] + public void GetDragGhost_boundCell_returnsIconTuple() + { + var cell = new UiItemSlot { Width = 32, Height = 32 }; + cell.SetItem(0x5001u, 0x99u); + var g = cell.GetDragGhost(); + Assert.NotNull(g); + Assert.Equal(0x99u, g!.Value.tex); + Assert.Equal(32, g.Value.w); + Assert.Equal(32, g.Value.h); + } + + // ── cell drop-target: DragEnter overlay + DropReleased dispatch ────────── + private static (UiItemList list, UiItemSlot cell, SpyHandler h) ListWithHandler() + { + var list = new UiItemList(_ => (1u, 1, 1)); // non-zero resolve so overlay draw is harmless + var h = new SpyHandler(); + list.RegisterDragHandler(h); + return (list, list.Cell, h); + } + + private static ItemDragPayload SomePayload() + => new(0x5001u, ItemDragSource.ShortcutBar, 0, new UiItemSlot()); + + [Fact] + public void DragEnter_setsAcceptOverlay_whenHandlerAccepts() + { + var (_, cell, h) = ListWithHandler(); + h.AcceptResult = true; + cell.OnEvent(new UiEvent(0u, cell, UiEventType.DragEnter, Payload: SomePayload())); + Assert.Equal(UiItemSlot.DragAcceptState.Accept, cell.DragAcceptVisual); + } + + [Fact] + public void DragEnter_setsRejectOverlay_whenHandlerRejects() + { + var (_, cell, h) = ListWithHandler(); + h.AcceptResult = false; + cell.OnEvent(new UiEvent(0u, cell, UiEventType.DragEnter, Payload: SomePayload())); + Assert.Equal(UiItemSlot.DragAcceptState.Reject, cell.DragAcceptVisual); + } + + [Fact] + public void DragOver_resetsOverlayToNeutral() + { + var (_, cell, h) = ListWithHandler(); + cell.OnEvent(new UiEvent(0u, cell, UiEventType.DragEnter, Payload: SomePayload())); + cell.OnEvent(new UiEvent(0u, cell, UiEventType.DragOver, Payload: SomePayload())); + Assert.Equal(UiItemSlot.DragAcceptState.None, cell.DragAcceptVisual); + } + + [Fact] + public void DropReleased_accepted_dispatchesToHandler() + { + var (list, cell, h) = ListWithHandler(); + var p = SomePayload(); + cell.OnEvent(new UiEvent(0u, cell, UiEventType.DropReleased, Data0: 1, Payload: p)); + Assert.NotNull(h.LastDrop); + Assert.Same(list, h.LastDrop!.Value.list); + Assert.Same(cell, h.LastDrop.Value.cell); + Assert.Same(p, h.LastDrop.Value.payload); + } + + [Fact] + public void DropReleased_notAccepted_skipsDispatch() + { + var (_, cell, h) = ListWithHandler(); + cell.OnEvent(new UiEvent(0u, cell, UiEventType.DropReleased, Data0: 0, Payload: SomePayload())); + Assert.Null(h.LastDrop); + } } diff --git a/tests/AcDream.App.Tests/UI/Layout/ToolbarControllerTests.cs b/tests/AcDream.App.Tests/UI/Layout/ToolbarControllerTests.cs index 9668a586..b4204f4d 100644 --- a/tests/AcDream.App.Tests/UI/Layout/ToolbarControllerTests.cs +++ b/tests/AcDream.App.Tests/UI/Layout/ToolbarControllerTests.cs @@ -89,8 +89,8 @@ public class ToolbarControllerTests ToolbarController.Bind(layout, repo, () => shortcuts, iconIds: (_,_,_,_,_) => 0x77u, useItem: g => used = g); - // UiEvent is a positional record struct: (SourceId, Target, Type, Data0..3, Payload) - slots[Row1[0]].Cell.OnEvent(new UiEvent(0u, null, UiEventType.MouseDown)); + // Use now fires on Click (mouse-up), not MouseDown — drag/click disambiguation. + slots[Row1[0]].Cell.OnEvent(new UiEvent(0u, null, UiEventType.Click)); Assert.Equal(0x5001u, used); } From 595c4bdac4919a937222642bcd29ea467f7d06ff Mon Sep 17 00:00:00 2001 From: Erik Date: Sat, 20 Jun 2026 12:51:01 +0200 Subject: [PATCH 005/133] =?UTF-8?q?feat(ui):=20D.5.3/B.1=20=E2=80=94=20UiR?= =?UTF-8?q?oot=20payload=20injection=20+=20cursor=20drag=20ghost=20(AP-47)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 (1M context) --- .../retail-divergence-register.md | 3 +- src/AcDream.App/UI/UiRoot.cs | 25 ++++++- .../UI/DragDropSpineTests.cs | 72 +++++++++++++++++++ 3 files changed, 97 insertions(+), 3 deletions(-) diff --git a/docs/architecture/retail-divergence-register.md b/docs/architecture/retail-divergence-register.md index 903a682b..34c14a31 100644 --- a/docs/architecture/retail-divergence-register.md +++ b/docs/architecture/retail-divergence-register.md @@ -97,7 +97,7 @@ accepted-divergence entries (#96, #49, #50). --- -## 3. Documented approximation (AP) — 42 rows +## 3. Documented approximation (AP) — 43 rows | # | Divergence | Where (file:line) | Why it is safe / justified | Risk if assumption breaks | Retail oracle | |---|---|---|---|---|---| @@ -146,6 +146,7 @@ accepted-divergence entries (#96, #49, #50). | AP-42 | `UiMenu` item model is flat (label + opaque payload, single-level popup); retail `UIElement_Menu::MakePopup @0x46d310` supports hierarchical nested submenus via recursive popup chain | `src/AcDream.App/UI/UiMenu.cs` | The chat talk-focus menu is single-level (14 rows, 2 columns, no submenu); hierarchy is latent and unreachable through the chat window — no behavioral difference in the current usage | A future menu with nested submenus would render flat (only the top-level items drawn, no drill-down) | `UIElement_Menu::MakePopup` @0x46d310 | | AP-45 | `PublicUpdatePropertyInt (0x02CE)` sequence byte parsed-past but not honored; last update wins (no freshness check against sequence number) | `src/AcDream.Core.Net/Messages/PublicUpdatePropertyInt.cs` | Loopback ACE rarely reorders; latest-wins matches `PrivateUpdateVital`/`UpdatePosition`'s existing non-sequence behavior. Sequence tracking added when needed alongside TS-26. | A reordered 0x02CE on a real network could apply a stale UiEffects value — item icon temporarily shows the wrong effect state, corrected on next update | `PublicUpdatePropertyInt` sequence byte (ACE GameMessagePublicUpdatePropertyInt) | | AP-46 | Health-meter gate approximation: retail shows the health meter for `IsPlayer() || pet_owner || ClientCombatSystem::ObjectIsAttackable()` (full PK/faction logic); acdream's `GameWindow.IsHealthBarTarget` uses the server PWD bits `BF_ATTACKABLE (0x10)` OR `BF_PLAYER (0x8)` | `src/AcDream.App/Rendering/GameWindow.cs` (`IsHealthBarTarget`) → `SelectedObjectController` | The PWD `BF_ATTACKABLE`/`BF_PLAYER` bits distinguish monsters + players (bar) from friendly/vendor NPCs (name-only) for the M1.5 dev loop; the pet case and the full ObjectIsAttackable PK/faction refinement (free-PK, PK-vs-PK, PKLite) are not ported | A PK/faction edge (e.g. a hostile-flagged player whose `BF_ATTACKABLE` is unset, or a pet) could show/hide the bar where retail differs — no impact on the non-PK PvE dev loop | `ClientCombatSystem::ObjectIsAttackable` acclient_2013_pseudo_c.txt:375385; `BF_ATTACKABLE` acclient.h:6437 | +| AP-47 | Cursor drag ghost reuses the full composited `m_pIcon` 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 | --- diff --git a/src/AcDream.App/UI/UiRoot.cs b/src/AcDream.App/UI/UiRoot.cs index 91fd219d..e1a54c24 100644 --- a/src/AcDream.App/UI/UiRoot.cs +++ b/src/AcDream.App/UI/UiRoot.cs @@ -141,9 +141,25 @@ public sealed class UiRoot : UiElement // beats even rect backgrounds. Faithful to retail's root-level MakePopup. ctx.BeginOverlayLayer(); DrawOverlays(ctx); + DrawDragGhost(ctx); ctx.EndOverlayLayer(); } + /// Translucency of the cursor-following drag ghost. AP-47: we reuse the + /// item's full composited icon at this alpha rather than retail's dedicated + /// underlay-less m_pDragIcon. + private const float GhostAlpha = 0.6f; + + /// Paint the drag ghost at the cursor. The texture comes from the source + /// element's so UiRoot stays item-agnostic; the + /// ghost is NOT a tree element, so it never intercepts hit-tests. + private void DrawDragGhost(UiRenderContext ctx) + { + if (DragSource?.GetDragGhost() is not { } g || g.tex == 0) return; + ctx.DrawSprite(g.tex, MouseX - g.w / 2f, MouseY - g.h / 2f, g.w, g.h, + 0f, 0f, 1f, 1f, new Vector4(1f, 1f, 1f, GhostAlpha)); + } + // ── Input entry points (called from GameWindow's Silk.NET handlers) ── public void OnMouseMove(int x, int y) @@ -185,7 +201,7 @@ public sealed class UiRoot : UiElement if (Math.Abs(x - _pressX) > DragDistanceThreshold || Math.Abs(y - _pressY) > DragDistanceThreshold) { - BeginDrag(Captured, payload: null); + BeginDrag(Captured); } } if (DragSource is not null) @@ -447,8 +463,13 @@ public sealed class UiRoot : UiElement // ── Drag-drop (retail event chain 0x15 → 0x21 → 0x1C → 0x3E) ──────── - private void BeginDrag(UiElement source, object? payload) + private void BeginDrag(UiElement source) { + // 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); diff --git a/tests/AcDream.App.Tests/UI/DragDropSpineTests.cs b/tests/AcDream.App.Tests/UI/DragDropSpineTests.cs index d37bc66a..26719d1f 100644 --- a/tests/AcDream.App.Tests/UI/DragDropSpineTests.cs +++ b/tests/AcDream.App.Tests/UI/DragDropSpineTests.cs @@ -131,4 +131,76 @@ public class DragDropSpineTests cell.OnEvent(new UiEvent(0u, cell, UiEventType.DropReleased, Data0: 0, Payload: SomePayload())); Assert.Null(h.LastDrop); } + + // ── Full UiRoot chain: arming + use-vs-drag ───────────────────────────── + // A bound, hit-testable slot inside a list, sized for the hit-test. + private static (UiRoot root, UiItemList list, UiItemSlot cell) RootWithBoundSlot(uint itemId) + { + var root = new UiRoot { Width = 800, Height = 600 }; + var list = new UiItemList(_ => (1u, 1, 1)) { Left = 0, Top = 0, Width = 32, Height = 32 }; + // Tests don't run OnDraw (which sizes the cell), so size the cell explicitly. + list.Cell.Width = 32; list.Cell.Height = 32; + if (itemId != 0) list.Cell.SetItem(itemId, 0x99u); + root.AddChild(list); + return (root, list, list.Cell); + } + + [Fact] + public void BeginDrag_arms_whenPayloadNonNull() + { + var (root, _, cell) = RootWithBoundSlot(0x5001u); + root.OnMouseDown(UiMouseButton.Left, 10, 10); + root.OnMouseMove(20, 10); // >3px → promote to drag + Assert.Same(cell, root.DragSource); + Assert.IsType(root.DragPayload); + } + + [Fact] + public void BeginDrag_doesNotArm_whenPayloadNull_emptySlot() + { + var (root, _, _) = RootWithBoundSlot(0u); // empty cell → GetDragPayload null + root.OnMouseDown(UiMouseButton.Left, 10, 10); + root.OnMouseMove(20, 10); + Assert.Null(root.DragSource); // never armed + } + + [Fact] + public void Click_withoutDrag_firesUse() + { + var (root, _, cell) = RootWithBoundSlot(0x5001u); + bool used = false; + cell.Clicked = () => used = true; + root.OnMouseDown(UiMouseButton.Left, 10, 10); + root.OnMouseUp(UiMouseButton.Left, 10, 10); // no move → Click emitted + Assert.True(used); + } + + [Fact] + public void CompletedDrag_doesNotFireUse() + { + var (root, _, cell) = RootWithBoundSlot(0x5001u); + bool used = false; + cell.Clicked = () => used = true; + root.OnMouseDown(UiMouseButton.Left, 10, 10); + root.OnMouseMove(20, 10); // promote to drag + root.OnMouseUp(UiMouseButton.Left, 20, 10); // FinishDrag, NOT Click + Assert.False(used); + } + + // ── no-handler / orphan-cell DragEnter defaults to Reject (review carry-forward) ── + [Fact] + public void DragEnter_orphanCell_noList_defaultsToReject() + { + var cell = new UiItemSlot(); // no parent list → FindList() null + cell.OnEvent(new UiEvent(0u, cell, UiEventType.DragEnter, Payload: SomePayload())); + Assert.Equal(UiItemSlot.DragAcceptState.Reject, cell.DragAcceptVisual); + } + + [Fact] + public void DragEnter_listWithoutHandler_defaultsToReject() + { + var list = new UiItemList(_ => (1u, 1, 1)); // no RegisterDragHandler + list.Cell.OnEvent(new UiEvent(0u, list.Cell, UiEventType.DragEnter, Payload: SomePayload())); + Assert.Equal(UiItemSlot.DragAcceptState.Reject, list.Cell.DragAcceptVisual); + } } From 3d04f614514914cd1b049a6ad766c50d348654d1 Mon Sep 17 00:00:00 2001 From: Erik Date: Sat, 20 Jun 2026 13:00:20 +0200 Subject: [PATCH 006/133] =?UTF-8?q?feat(ui):=20D.5.3/B.1=20=E2=80=94=20too?= =?UTF-8?q?lbar=20drag=20handler=20stub=20+=20spine=20wiring=20(TS-33)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements Task 4 of the drag-drop spine: ToolbarController now satisfies IItemListDragHandler. The ctor loop wires RegisterDragHandler(this) on every slot list and stamps Cell.SlotIndex + Cell.SourceKind=ShortcutBar, matching retail's gmToolbarUI::PostInit/RegisterItemListDragHandler pattern. OnDragOver accepts any non-empty payload (TS-33 stub; eligibility gate is Stream B.2). HandleDropRelease logs and returns (no AddShortcut 0x019C / RemoveShortcut 0x019D wire yet). Three new B.1 xUnit tests cover handler registration, accept, and inert-stub semantics. All 490 app tests pass. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../retail-divergence-register.md | 3 +- .../UI/Layout/ToolbarController.cs | 28 ++++++++- .../UI/Layout/ToolbarControllerTests.cs | 57 +++++++++++++++++++ 3 files changed, 86 insertions(+), 2 deletions(-) diff --git a/docs/architecture/retail-divergence-register.md b/docs/architecture/retail-divergence-register.md index 34c14a31..195f7046 100644 --- a/docs/architecture/retail-divergence-register.md +++ b/docs/architecture/retail-divergence-register.md @@ -150,7 +150,7 @@ accepted-divergence entries (#96, #49, #50). --- -## 4. Temporary stopgap (TS) — 31 rows +## 4. Temporary stopgap (TS) — 32 rows | # | Divergence | Where (file:line) | Why it is safe / justified | Risk if assumption breaks | Retail oracle | |---|---|---|---|---|---| @@ -186,6 +186,7 @@ accepted-divergence entries (#96, #49, #50). | TS-30 | Numbered chat tabs (element ids `0x10000522`–`0x10000525`) render as clickable buttons but do not switch channel filter or affect the transcript — tab state is a no-op | `src/AcDream.App/UI/Layout/ChatWindowController.cs:210` | Retail's tab switching routes transcript lines by chat channel (`gmMainChatUI::gmScrollWindow` sub-windows per tab); the tab wiring is D.5 scope | Tab clicks produce no visible transcript change; retail would filter to the selected channel — all chat always shows in all tabs | `gmMainChatUI::PostInit` tab setup @0x4ce2a0; holtburger chat tab handling | | TS-31 | Squelch toggle absent (no `/squelch` slash command, no clickable name-tags to silence); retail's squelch list filters incoming chat lines | `src/AcDream.Core/Chat/ChatLog.cs` | Squelch is a social / moderation feature deferred to post-M1.5; the data structure (`ChatLog`) has no squelch set today | Any player can spam all clients; clickable-name-tag contextual menu (used in retail to squelch, tell, add-to-friends) is absent | `ChatFilter::IsSquelched`; retail right-click player name → Squelch menu | | TS-32 | `ClientObjectTable` has no pre-queue for a child `CreateObject` that arrives before its parent (out-of-order PARENTED create); such objects are ingested as root objects and their `ContainerId` links a not-yet-known container. Retail's `null_object_table` + `null_weenie_object_table` hold unresolvable objects until the parent arrives | `src/AcDream.Core/Items/ClientObjectTable.cs` (`Ingest`) | PD↔`CreateObject` ordering is handled (upsert semantics); out-of-order PARENTED creates are observed only at high packet loss or in vendor/corpse multi-object bursts on non-loopback links; deferred to D.5.5+ | A container's child object arriving before the container is ingested as a root item — it won't appear in `GetContents` until the next `RecordMembership` or a move event corrects the parent link | `CObjectMaint::null_object_table` / `null_weenie_object_table` (acclient.h / named-retail pc) | +| 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 | --- diff --git a/src/AcDream.App/UI/Layout/ToolbarController.cs b/src/AcDream.App/UI/Layout/ToolbarController.cs index 1279328a..9cb43471 100644 --- a/src/AcDream.App/UI/Layout/ToolbarController.cs +++ b/src/AcDream.App/UI/Layout/ToolbarController.cs @@ -23,7 +23,7 @@ namespace AcDream.App.UI.Layout; /// CreateObject resolves a formerly-unknown guid. /// /// -public sealed class ToolbarController +public sealed class ToolbarController : IItemListDragHandler { // Slot element ids in slot-index order (toolbar LayoutDesc 0x21000016, pre-dump). // Row 1 = slots 0-8 (0x100001A7..0x100001AF), Row 2 = slots 9-17 (0x100006B7..0x100006BF). @@ -96,7 +96,15 @@ public sealed class ToolbarController { _slots[i] = layout.FindElement(SlotIds[i]) as UiItemList; if (_slots[i] is { } list) + { WireClick(list); + // B.1 drag-drop spine: this controller is the drop handler for every + // toolbar slot list; each cell knows its slot index + that it's a + // shortcut-bar source (retail UIElement_ItemList::RegisterItemListDragHandler). + list.RegisterDragHandler(this); + list.Cell.SlotIndex = i; + list.Cell.SourceKind = ItemDragSource.ShortcutBar; + } } // Cache the four mutually-exclusive combat-mode indicator elements. @@ -287,4 +295,22 @@ public sealed class ToolbarController _useItem(list.Cell.ItemId); }; } + + // ── IItemListDragHandler (B.1 spine) ───────────────────────────────────── + // Retail: gmToolbarUI is the m_dragHandler for every shortcut slot list. + // The accept/reject gate (IsShortcutEligible) and the AddShortcut/RemoveShortcut + // wire are Stream B.2; this stub accepts any real item and LOGS the drop (TS-33). + + /// + public bool OnDragOver(UiItemList targetList, UiItemSlot targetCell, ItemDragPayload payload) + => payload.ObjId != 0; + + /// + public void HandleDropRelease(UiItemList targetList, UiItemSlot targetCell, ItemDragPayload payload) + { + // TS-33: no wire yet. Stream B.2 replaces this with the ShortcutStore mutation + + // AddShortcut 0x019C / RemoveShortcut 0x019D sends (gmToolbarUI::HandleDropRelease :197971). + Console.WriteLine($"[B.2 TODO] drop obj 0x{payload.ObjId:X8} from {payload.SourceKind} " + + $"slot {payload.SourceSlot} → toolbar slot {targetCell.SlotIndex}"); + } } diff --git a/tests/AcDream.App.Tests/UI/Layout/ToolbarControllerTests.cs b/tests/AcDream.App.Tests/UI/Layout/ToolbarControllerTests.cs index b4204f4d..6a599ecd 100644 --- a/tests/AcDream.App.Tests/UI/Layout/ToolbarControllerTests.cs +++ b/tests/AcDream.App.Tests/UI/Layout/ToolbarControllerTests.cs @@ -421,4 +421,61 @@ public class ToolbarControllerTests Assert.Equal(callsAfterBind, iconCallCount); // unchanged } + + // ── B.1: drag-drop spine wiring ────────────────────────────────────────── + + /// Bind registers the controller as each slot list's drag handler and + /// stamps every cell's SlotIndex + SourceKind=ShortcutBar. + [Fact] + public void Bind_registersDragHandler_andStampsSlots() + { + var (layout, slots, _) = FakeToolbar(); + var repo = new ClientObjectTable(); + + var ctrl = ToolbarController.Bind(layout, repo, + () => Array.Empty(), + iconIds: (_,_,_,_,_) => 0u, useItem: _ => { }); + + for (int i = 0; i < Row1.Length; i++) + { + Assert.Same(ctrl, slots[Row1[i]].DragHandler); + Assert.Equal(i, slots[Row1[i]].Cell.SlotIndex); + Assert.Equal(ItemDragSource.ShortcutBar, slots[Row1[i]].Cell.SourceKind); + } + // Bottom row slots are indices 9..17. + for (int j = 0; j < Row2.Length; j++) + Assert.Equal(9 + j, slots[Row2[j]].Cell.SlotIndex); + } + + /// OnDragOver accepts a real item (ObjId != 0). Eligibility (IsShortcutEligible) + /// is Stream B.2; the stub accepts any non-empty payload. + [Fact] + public void OnDragOver_acceptsRealItem() + { + var (layout, slots, _) = FakeToolbar(); + var ctrl = ToolbarController.Bind(layout, new ClientObjectTable(), + () => Array.Empty(), + iconIds: (_,_,_,_,_) => 0u, useItem: _ => { }); + + var list = slots[Row1[0]]; + var payload = new ItemDragPayload(0x5001u, ItemDragSource.Inventory, 0, new UiItemSlot()); + Assert.True(ctrl.OnDragOver(list, list.Cell, payload)); + } + + /// HandleDropRelease is a logging stub (TS-33): it must not throw and must not + /// mutate the target slot (no wire / no store yet). + [Fact] + public void HandleDropRelease_isInertStub() + { + var (layout, slots, _) = FakeToolbar(); + var ctrl = ToolbarController.Bind(layout, new ClientObjectTable(), + () => Array.Empty(), + iconIds: (_,_,_,_,_) => 0u, useItem: _ => { }); + + var list = slots[Row1[2]]; + var payload = new ItemDragPayload(0x5001u, ItemDragSource.ShortcutBar, 0, new UiItemSlot()); + var ex = Record.Exception(() => ctrl.HandleDropRelease(list, list.Cell, payload)); + Assert.Null(ex); + Assert.Equal(0u, list.Cell.ItemId); // unchanged — inert stub + } } From 35ebb3c8f00c713284441cf1589867a6586e905f Mon Sep 17 00:00:00 2001 From: Erik Date: Sat, 20 Jun 2026 13:06:00 +0200 Subject: [PATCH 007/133] =?UTF-8?q?test(ui):=20D.5.3/B.1=20=E2=80=94=20str?= =?UTF-8?q?engthen=20toolbar=20drag-handler=20test=20coverage?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cover OnDragOver ObjId==0 reject branch, make HandleDropRelease inertness assertion meaningful (populate first), and assert DragHandler/SourceKind on the bottom row too. Test-only; addresses code-review coverage gaps. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../UI/Layout/ToolbarControllerTests.cs | 23 +++++++++++++++++-- 1 file changed, 21 insertions(+), 2 deletions(-) diff --git a/tests/AcDream.App.Tests/UI/Layout/ToolbarControllerTests.cs b/tests/AcDream.App.Tests/UI/Layout/ToolbarControllerTests.cs index 6a599ecd..7401eb93 100644 --- a/tests/AcDream.App.Tests/UI/Layout/ToolbarControllerTests.cs +++ b/tests/AcDream.App.Tests/UI/Layout/ToolbarControllerTests.cs @@ -442,9 +442,13 @@ public class ToolbarControllerTests Assert.Equal(i, slots[Row1[i]].Cell.SlotIndex); Assert.Equal(ItemDragSource.ShortcutBar, slots[Row1[i]].Cell.SourceKind); } - // Bottom row slots are indices 9..17. + // Bottom row slots are indices 9..17; same handler + source kind as the top row. for (int j = 0; j < Row2.Length; j++) + { + Assert.Same(ctrl, slots[Row2[j]].DragHandler); Assert.Equal(9 + j, slots[Row2[j]].Cell.SlotIndex); + Assert.Equal(ItemDragSource.ShortcutBar, slots[Row2[j]].Cell.SourceKind); + } } /// OnDragOver accepts a real item (ObjId != 0). Eligibility (IsShortcutEligible) @@ -462,6 +466,20 @@ public class ToolbarControllerTests Assert.True(ctrl.OnDragOver(list, list.Cell, payload)); } + /// OnDragOver rejects a ghost payload with ObjId 0 (the guard against an + /// empty cell that somehow produced a payload). Complements OnDragOver_acceptsRealItem. + [Fact] + public void OnDragOver_rejectsZeroObjId() + { + var (layout, slots, _) = FakeToolbar(); + var ctrl = ToolbarController.Bind(layout, new ClientObjectTable(), + () => Array.Empty(), + iconIds: (_,_,_,_,_) => 0u, useItem: _ => { }); + var list = slots[Row1[0]]; + var payload = new ItemDragPayload(0u, ItemDragSource.Inventory, 0, new UiItemSlot()); + Assert.False(ctrl.OnDragOver(list, list.Cell, payload)); + } + /// HandleDropRelease is a logging stub (TS-33): it must not throw and must not /// mutate the target slot (no wire / no store yet). [Fact] @@ -473,9 +491,10 @@ public class ToolbarControllerTests iconIds: (_,_,_,_,_) => 0u, useItem: _ => { }); var list = slots[Row1[2]]; + list.Cell.SetItem(0x5001u, 0x77u); // populate so "unchanged" is a meaningful assertion 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 + Assert.Equal(0x5001u, list.Cell.ItemId); // unchanged — inert stub did not clear/mutate } } From 1e53820dd7c0ec5a68a080abb105a0453bdafbbb Mon Sep 17 00:00:00 2001 From: Erik Date: Sat, 20 Jun 2026 13:12:48 +0200 Subject: [PATCH 008/133] =?UTF-8?q?docs(issues):=20#142=20=E2=80=94=20empt?= =?UTF-8?q?y=20item-slot=20press+drag+release=20emits=20a=20Click=20(laten?= =?UTF-8?q?t)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Filed from the B.1 spine code review. No-op today (toolbar Clicked guards ItemId!=0); deliberately NOT guarded with a speculative _dragCancelled bit (would make item-slots differ from every other widget + guess at retail). Verify retail's empty-cell press+move+release before changing. Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/ISSUES.md | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/docs/ISSUES.md b/docs/ISSUES.md index 2a6f243c..83702af0 100644 --- a/docs/ISSUES.md +++ b/docs/ISSUES.md @@ -46,6 +46,23 @@ Copy this block when adding a new issue: --- +## #142 — Empty item-slot press+drag+release still emits a Click + +**Status:** OPEN +**Severity:** LOW +**Filed:** 2026-06-20 +**Component:** ui — D.2b drag-drop spine (B.1) + +**Description:** Pressing an EMPTY `UiItemSlot`, moving the cursor >3 px (which makes `UiRoot.BeginDrag` cancel the drag because the empty cell's `GetDragPayload()` returns null), then releasing over the same cell still emits a `Click` (the press becomes a click on release, since no drag armed). This is the toolkit's general click semantics — every non-draggable widget (buttons included) fires Click after sub-gesture mouse movement; only an *armed* drag suppresses the click. Today it is a **no-op**: the toolbar's `Clicked` handler guards `if (Cell.ItemId != 0)`, so an empty-slot click uses nothing. + +**Root cause / status:** Surfaced in the B.1 spine code review (Task 3). Whether retail fires nothing vs. a no-op click on an empty cell after a >3 px move is **unverified** — deliberately NOT "fixed" with a speculative `_dragCancelled` guard, because that would make empty item-slots behave differently from every other widget and would be guessing at retail behavior (CLAUDE.md forbids both). Becomes relevant only if a future panel (e.g. the inventory grid in Stream C) wires an empty-cell click handler that does NOT guard on occupancy. Action when touched: verify retail's empty-cell press+move+release behavior (cdb/decomp) before changing anything; if it must differ, it earns a divergence-register row. + +**Files:** `src/AcDream.App/UI/UiRoot.cs` (`BeginDrag` cancel path, `OnMouseUp` Click emit), `src/AcDream.App/UI/UiItemSlot.cs` (`OnEvent` Click→Clicked). + +**Research:** `docs/superpowers/specs/2026-06-20-d2b-drag-drop-spine-design.md` §6. + +--- + ## #141 — Toolbar interactivity — selected-object display **Status:** IN PROGRESS (D.5.3a health + name + flash — DONE & visually confirmed 2026-06-20; mana + stack slider still deferred). Renumbered from #140 on the 2026-06-20 main merge — A7 Fix D held #140 on main; this branch's commits/spec still reference #140. From acdefc2e217c29c8d7933cd36b099ebe672ee228 Mon Sep 17 00:00:00 2001 From: Erik Date: Sat, 20 Jun 2026 14:26:53 +0200 Subject: [PATCH 009/133] =?UTF-8?q?fix(ui):=20D.5.3/B.1=20=E2=80=94=20drag?= =?UTF-8?q?ging=20a=20toolbar=20item=20moved=20the=20window=20instead=20of?= =?UTF-8?q?=20the=20item?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Found at visual verification: an occupied UiItemSlot sits inside the Draggable toolbar frame (UiNineSlicePanel.Draggable=true), so UiRoot.OnMouseDown's FindWindow returned the frame and the window-move branch won — press+drag on a slot moved the whole bar instead of picking up the item. The slot wasn't CapturesPointerDrag (that path is for self-driven text-selection and suppresses the BeginDrag promotion), and UiRoot had no path for "a drag-source inside a draggable window." Fix: add UiElement.IsDragSource (virtual, default false); UiItemSlot overrides it to `ItemId != 0` (occupancy-gated). UiRoot.OnMouseDown now prioritizes IsDragSource over window-move — an OCCUPIED slot starts the item drag (promotes to BeginDrag on >3px), an EMPTY slot falls through to the IA-12 whole-window-drag so the bar stays movable by its empty cells / chrome. UiRoot stays item-agnostic (reads only the bool). This REDUCES divergence (occupied cells now drag like retail) within IA-12's umbrella — no new register row. Regression tests reproduce the LIVE topology (slot inside a Draggable frame); the earlier RootWithBoundSlot tests put the slot directly under the root, so they could not catch it. Full suite 493 pass / 0 fail / 2 skip. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/AcDream.App/UI/UiElement.cs | 8 ++++ src/AcDream.App/UI/UiItemSlot.cs | 8 ++++ src/AcDream.App/UI/UiRoot.cs | 11 +++++ .../UI/DragDropSpineTests.cs | 42 +++++++++++++++++++ 4 files changed, 69 insertions(+) diff --git a/src/AcDream.App/UI/UiElement.cs b/src/AcDream.App/UI/UiElement.cs index 04f37772..84e5f8bd 100644 --- a/src/AcDream.App/UI/UiElement.cs +++ b/src/AcDream.App/UI/UiElement.cs @@ -113,6 +113,14 @@ public abstract class UiElement /// drag-drop candidacy is suppressed in favour of the element's own handling. public bool CapturesPointerDrag { get; set; } + /// If true, a left-press-and-move on this element starts a DRAG-DROP + /// ( promotes to BeginDrag) rather than moving a Draggable + /// ancestor window — so an item cell inside the toolbar frame drags the item, not + /// the window. Distinct from (a self-driven + /// interior drag like text selection, which does NOT promote to BeginDrag). Default + /// false; overridden by drag sources (e.g. an occupied ). + public virtual bool IsDragSource => false; + /// Minimum size enforced while resizing. public float MinWidth { get; set; } = 40f; public float MinHeight { get; set; } = 40f; diff --git a/src/AcDream.App/UI/UiItemSlot.cs b/src/AcDream.App/UI/UiItemSlot.cs index 21cbc11f..bae35fe4 100644 --- a/src/AcDream.App/UI/UiItemSlot.cs +++ b/src/AcDream.App/UI/UiItemSlot.cs @@ -68,6 +68,14 @@ public sealed class UiItemSlot : UiElement public override (uint tex, int w, int h)? GetDragGhost() => ItemId != 0 && IconTexture != 0 ? (IconTexture, (int)Width, (int)Height) : null; + /// An OCCUPIED slot is a drag source — a press-and-move picks up the item + /// rather than moving the toolbar window. An EMPTY slot is NOT a drag source, so a + /// press-and-move there falls through to the IA-12 whole-window-drag, keeping the bar + /// movable by its empty cells / chrome. Drives 's mousedown + /// window-vs-item disambiguation (retail moves the window via a dragbar, never cells; + /// our whole-window-drag approximation reconciles by gating on occupancy). + public override bool IsDragSource => ItemId != 0; + /// Walk up to the containing (the drop handler owner). private UiItemList? FindList() { diff --git a/src/AcDream.App/UI/UiRoot.cs b/src/AcDream.App/UI/UiRoot.cs index e1a54c24..267b5688 100644 --- a/src/AcDream.App/UI/UiRoot.cs +++ b/src/AcDream.App/UI/UiRoot.cs @@ -260,6 +260,17 @@ public sealed class UiRoot : UiElement _resizeMouseX = x; _resizeMouseY = y; _dragCandidate = false; } + else if (target.IsDragSource) + { + // A drag SOURCE (e.g. an occupied item cell) inside a Draggable window + // starts an item drag-drop, NOT a window move. UiRoot stays item-agnostic: + // it only reads the IsDragSource flag (the cell decides occupancy). The + // BeginDrag promotion happens on the >3px move (and cancels if the source's + // GetDragPayload() returns null). Empty cells are NOT drag sources, so they + // fall through to window.Draggable below (IA-12 whole-window-drag), keeping + // the bar movable by its empty cells / chrome. + _dragCandidate = true; + } else if (target.CapturesPointerDrag) { // The pressed widget owns interior drags (e.g. text selection): diff --git a/tests/AcDream.App.Tests/UI/DragDropSpineTests.cs b/tests/AcDream.App.Tests/UI/DragDropSpineTests.cs index 26719d1f..175c6e56 100644 --- a/tests/AcDream.App.Tests/UI/DragDropSpineTests.cs +++ b/tests/AcDream.App.Tests/UI/DragDropSpineTests.cs @@ -203,4 +203,46 @@ public class DragDropSpineTests list.Cell.OnEvent(new UiEvent(0u, list.Cell, UiEventType.DragEnter, Payload: SomePayload())); Assert.Equal(UiItemSlot.DragAcceptState.Reject, list.Cell.DragAcceptVisual); } + + // ── item drag inside a Draggable window (the LIVE toolbar topology) ────── + // Regression (visual gate 2026-06-20): the slot sits inside the Draggable toolbar + // frame, so FindWindow returns the frame. An OCCUPIED slot must start an ITEM drag + // (IsDragSource), NOT move the window; an EMPTY slot falls through to whole-window + // drag (IA-12) so the bar stays movable by its empty cells / chrome. The earlier + // RootWithBoundSlot tests put the slot directly under the root (no draggable + // ancestor), so they could not catch this. + private static (UiRoot root, UiPanel frame, UiItemList list) DraggableFrameWithSlot(uint itemId) + { + var root = new UiRoot { Width = 800, Height = 600 }; + var frame = new UiPanel { Left = 10, Top = 300, Width = 200, Height = 60, Draggable = true }; + var list = new UiItemList(_ => (1u, 1, 1)) { Left = 5, Top = 5, Width = 32, Height = 32 }; + list.Cell.Width = 32; list.Cell.Height = 32; + if (itemId != 0) list.Cell.SetItem(itemId, 0x99u); + frame.AddChild(list); + root.AddChild(frame); + return (root, frame, list); + } + + [Fact] + public void OccupiedSlotInsideDraggableWindow_armsItemDrag_doesNotMoveWindow() + { + var (root, frame, list) = DraggableFrameWithSlot(0x5001u); + // Slot screen rect = frame(10,300)+list(5,5) → (15,305)..(47,337). Press inside, drag >3px. + root.OnMouseDown(UiMouseButton.Left, 20, 310); + root.OnMouseMove(40, 310); + Assert.Same(list.Cell, root.DragSource); // item drag armed + Assert.Equal(10f, frame.Left); // window did NOT move + Assert.Equal(300f, frame.Top); + } + + [Fact] + public void EmptySlotInsideDraggableWindow_movesWindow_notItemDrag() + { + var (root, frame, _) = DraggableFrameWithSlot(0u); // empty slot → not a drag source + root.OnMouseDown(UiMouseButton.Left, 20, 310); + root.OnMouseMove(40, 310); + Assert.Null(root.DragSource); // no item drag + Assert.Equal(30f, frame.Left); // window moved (offX=20-10=10; new Left=40-10=30) + Assert.Equal(300f, frame.Top); // y unchanged (310-10=300) + } } From 19f1b8b614ab5deac5b35b1fa0f02e4968bdef79 Mon Sep 17 00:00:00 2001 From: Erik Date: Sat, 20 Jun 2026 14:57:31 +0200 Subject: [PATCH 010/133] docs(D.5.3/B.2): toolbar shortcut drag interactivity design spec MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit From the user's visual-gate feedback + a retail decomp trace this session. Retail's model is remove-on-lift / place-on-drop / no-restore (confirmed: RecvNotice_ItemListBeginDrag 0x004bd930 → RemoveShortcut 0x004bd450 at lift; HandleDropRelease 0x004be7c0 places + bumps displaced → source; off-bar drop leaves it removed). This unifies the user's points 3/4/5 into one mechanism. Verified the drop sprite against client_portal.dat: 0x060011F9 = green RING (inventory), 0x060011FA = green CROSS (toolbar) — user was right. Scope (toolbar-internal; drag-from-inventory is Stream C): spine extensions (lift hook on IItemListDragHandler; ghost snapshotted at BeginDrag at full opacity so it survives the source emptying; FinishDrag delivers a drop only on a real hit, off-bar = nothing); a mutable 18-slot ShortcutStore; ToolbarController as the live handler (OnDragLift removes; HandleDropRelease places + bumps); the AddShortcut 0x019C / RemoveShortcut 0x019D wire (fix the BuildAddShortcut param names; SendAddShortcut/SendRemoveShortcut on WorldSession); green-cross overlay. Amends AP-47 (ghost now full opacity), retires TS-33 (real wire replaces the stub). Co-Authored-By: Claude Opus 4.8 (1M context) --- ...-06-20-d2b-toolbar-shortcut-drag-design.md | 386 ++++++++++++++++++ 1 file changed, 386 insertions(+) create mode 100644 docs/superpowers/specs/2026-06-20-d2b-toolbar-shortcut-drag-design.md diff --git a/docs/superpowers/specs/2026-06-20-d2b-toolbar-shortcut-drag-design.md b/docs/superpowers/specs/2026-06-20-d2b-toolbar-shortcut-drag-design.md new file mode 100644 index 00000000..994f05c7 --- /dev/null +++ b/docs/superpowers/specs/2026-06-20-d2b-toolbar-shortcut-drag-design.md @@ -0,0 +1,386 @@ +# D.2b toolbar shortcut drag interactivity (Stream B.2) — design + +**Date:** 2026-06-20 +**Phase:** D.2b retail-UI → D.5 core panels. Stream **B.2** of the 2026-06-18 handoff — make the +toolbar shortcut drag *functional* (reorder / remove) and *retail-faithful*, on top of the B.1 +spine (`9d48346..acdefc2`, shipped + visually confirmed this session). +**Branch:** `claude/hopeful-maxwell-214a12`. +**Driver:** user visual-gate feedback 2026-06-20 (6 points) + the retail research below. + +**Read alongside:** +- `docs/research/2026-06-16-action-bar-toolbar-deep-dive.md` §5 (HandleDropRelease/RemoveShortcut/wire). +- `docs/research/2026-06-16-ui-item-slot-icon-dragdrop-spine-deep-dive.md` §5 (the cell drag chain). +- `docs/superpowers/specs/2026-06-20-d2b-drag-drop-spine-design.md` (B.1 — what this extends). +- `claude-memory/project_d2b_retail_ui.md`. + +--- + +## 1. Goal & non-goals + +**Goal.** A player drags a hotbar shortcut, sees the item icon lift off the slot (slot empties) and +follow the cursor at full opacity with a green-cross drop indicator; dropping on another slot moves +it there (reordering, bumping any occupant back to the source slot); dropping anywhere else removes +it from the bar. All changes are sent to the server (`AddShortcut 0x019C` / `RemoveShortcut 0x019D`) +so they persist. This realizes the user's 6 visual-gate points as retail's single **remove-on-lift / +place-on-drop / no-restore** mechanism. + +**In scope:** +1. **Spine extensions** (small, to the B.1 toolkit): a drag-LIFT hook to the source's handler; + full-opacity ghost snapshotted at drag-begin (survives the source emptying); `FinishDrag` + delivers a drop only when released on a real element (off-bar = no drop fires). +2. **`ShortcutStore`** — a mutable 18-slot model (port of retail `ShortCutManager::shortCuts_[18]`). +3. **`ToolbarController` as the live drag handler** — `OnDragLift` removes; `HandleDropRelease` + places + bumps displaced → source; both drive the store, the wire, and a re-`Populate()`. +4. **The wire** — fix `BuildAddShortcut` param names; add `WorldSession.SendAddShortcut` / + `SendRemoveShortcut`. +5. **Green-cross accept overlay** (`0x060011FA`) on toolbar cells (verified against the dat). + +**Out of scope (later streams):** +- **Drag FROM inventory onto the bar** (the `flags & 0xE == 0` "fresh-from-inventory" branch) — needs + the inventory window as a drag source (Stream C). This spec is reorder-within-bar + remove only + (the `flags & 4` branch). +- Spell shortcuts (the `SpellId|Layer` payload) — items only (`spellId=layer=0`); the store carries a + spell field for forward-compat but the toolbar binds item guids. +- Server-pushed shortcut mutations after login (the store is client-authoritative post-`Load`). +- The selected-object mana meter / stack-split UI (issue #141 deferred bits). + +--- + +## 2. Retail grounding (the model — CONFIRMED by research 2026-06-20) + +Retail's toolbar drag is **remove-on-lift, place-on-drop, no-restore** (decomp trace, this session): + +- **On drag-begin** (`UIElement_ItemList::ItemList_BeginDrag` 0x004e32d0 → broadcasts msg `0x21` → + `gmToolbarUI::RecvNotice_ItemListBeginDrag` 0x004bd930): `PrepareDragIcon` makes the **full icon** + the cursor ghost (m_dragIcon), then `gmToolbarUI::RemoveShortcut` (0x004bd450) **removes the + shortcut**: `ItemList_Flush` → cell → empty state `0x1000001c`, `Event_RemoveShortCut` sends + `0x019D`, and `m_lastShortcutNumDragged` = the source slot. The slot is now empty; the item is "in + hand." +- **On mouse-up over a slot** (`UIElementManager::StopDragandDrop` 0x00459810 → success → + `gmToolbarUI::HandleDropRelease` 0x004be7c0): within-bar reorder branch (`flags & 4`): + `RemoveShortcutInSlotNum(target)` (evict the occupant, get its objId) → `AddShortcut(draggedObjId, + target)` (`0x019C`); if an item was displaced and the source slot is free, `AddShortcut(displaced, + m_lastShortcutNumDragged)` — the bumped item lands in the vacated source slot. (deep-dive §5.) +- **On mouse-up over nothing** (`m_pElementLastDragCursorOver == null` → success=0): no + `HandleDropRelease`, no re-add — the shortcut stays gone (already removed + `0x019D` sent at lift). +- **No cancel/restore path.** `gmToolbarUI` has no end-drag/restore vtable entry. Lifted-then-not- + landed = permanently removed (server already told). +- **Drop indicator** is a per-slot OVERLAY sprite on `m_elem_Icon_DragAccept` (`0x1000045A`), NOT a + cursor change (`UpdateCursorState` is combat/target-only). The toolbar accept sprite is the **green + cross `0x060011FA`** (verified by exporting the `0x060011F7..FA` family from `client_portal.dat`: + F7=green move-arrow, F8=red ∅ reject, F9=green **ring** [inventory], FA=green **cross** [toolbar]); + reject = `0x060011F8`. + +**Wire (`ShortCutData`, CONFIRMED 3 refs — deep-dive §131-145):** +- `AddShortCut 0x019C` body after the 12-byte GameAction envelope: `Index(u32), ObjectId(u32), + SpellId(u16), Layer(u16)` (item → `ObjectId=guid, SpellId=Layer=0`). +- `RemoveShortCut 0x019D` body: `Index(u32)`. +- ACE handles the remove-then-add reorder pattern (`Player_Character.cs:254`). + +--- + +## 3. Architecture — components & boundaries + +``` + press+move on OCCUPIED slot ─► UiRoot.BeginDrag + │ snapshot ghost (full icon) BEFORE the lift clears the source cell + │ fire DragBegin on the source cell + ▼ + UiItemSlot.OnEvent(DragBegin) ─► FindList().DragHandler.OnDragLift(list, cell, payload) + ▼ + ToolbarController.OnDragLift ─► ShortcutStore.Remove(srcSlot); SendRemoveShortcut(srcSlot); Populate() + (source slot now empty; ghost still shows the snapshot) + drag-over target slot ─► UiItemSlot DragEnter ─► handler.OnDragOver → green-cross accept overlay (FA) + release ─► UiRoot.FinishDrag: + ├─ over a slot ─► that cell.OnEvent(DropReleased) ─► handler.HandleDropRelease(target, payload) + │ ShortcutStore place + bump displaced→srcSlot; SendAddShortcut×N; Populate() + └─ over nothing ─► no DropReleased delivered → lift's removal stands (item gone) +``` + +**Boundaries:** `UiRoot`/`UiElement`/`UiItemSlot`/`UiItemList` stay item-agnostic — they gain a lift +*hook* and a ghost snapshot, nothing item-specific. `ShortcutStore` is pure logic in `AcDream.Core` +(testable, no GL). The wire lives in `AcDream.Core.Net`. `ToolbarController` (App) orchestrates. +Honors structure Rules 1/2/3/6. + +--- + +## 4. New & changed types (precise) + +### 4.1 Spine — `IItemListDragHandler` gains the lift hook (`UI/IItemListDragHandler.cs`) +```csharp +public interface IItemListDragHandler +{ + /// The drag STARTED from a cell in this list — retail's + /// RecvNotice_ItemListBeginDrag → RemoveShortcut (decomp 0x004bd930/0x004bd450): the handler + /// removes the lifted item from its model + wire, so the source slot empties immediately. The + /// item is "in hand" until HandleDropRelease (place) or the drag ends off-target (stays removed). + void OnDragLift(UiItemList sourceList, UiItemSlot sourceCell, ItemDragPayload payload); + + bool OnDragOver(UiItemList targetList, UiItemSlot targetCell, ItemDragPayload payload); + void HandleDropRelease(UiItemList targetList, UiItemSlot targetCell, ItemDragPayload payload); +} +``` + +### 4.2 Spine — `UiItemSlot` (`UI/UiItemSlot.cs`) +- `OnEvent` `DragBegin` case changes from `return true` (no-op) to: notify the list's handler so it + can lift: + ```csharp + case UiEventType.DragBegin: + if (FindList() is { DragHandler: { } h } list && e.Payload is ItemDragPayload p) + h.OnDragLift(list, this, p); + return true; + ``` +- `DropReleased` case: drop the `e.Data0 == 1` gate — reaching the cell means a real slot was hit + (`FinishDrag` only delivers on a hit). The handler is authoritative (place; drop-on-self re-adds): + ```csharp + case UiEventType.DropReleased: + _dragAccept = DragAcceptState.None; + if (FindList() is { DragHandler: { } dh } dl && e.Payload is ItemDragPayload dp) + dh.HandleDropRelease(dl, this, dp); + return true; + ``` + (`DragEnter`/`DragOver` overlay handling unchanged from B.1.) + +### 4.3 Spine — `UiRoot` (`UI/UiRoot.cs`) +- Add `private (uint tex, int w, int h)? _dragGhost;`. In `BeginDrag`, **snapshot the ghost before + firing `DragBegin`** (the lift will empty the source cell, so a live re-read would vanish): + ```csharp + private void BeginDrag(UiElement source) + { + var payload = source.GetDragPayload(); + if (payload is null) { _dragCandidate = false; return; } + DragSource = source; + DragPayload = payload; + _dragGhost = source.GetDragGhost(); // snapshot NOW — survives the source emptying + var e = new UiEvent(source.EventId, source, UiEventType.DragBegin, Payload: payload); + source.OnEvent(in e); // → cell → handler.OnDragLift (clears the source slot) + } + ``` +- `DrawDragGhost` uses the snapshot, not a live read; `GhostAlpha` → **1.0** (full opacity, retail + m_dragIcon): + ```csharp + private const float GhostAlpha = 1.0f; + private void DrawDragGhost(UiRenderContext ctx) + { + if (_dragGhost is not { } g || g.tex == 0) return; + ctx.DrawSprite(g.tex, MouseX - g.w / 2f, MouseY - g.h / 2f, g.w, g.h, + 0f, 0f, 1f, 1f, new Vector4(1f, 1f, 1f, GhostAlpha)); + } + ``` +- `FinishDrag` delivers a drop ONLY to a real hit element; off-bar (null hit) fires nothing; clear + the ghost: + ```csharp + private void FinishDrag(int x, int y) + { + var (t, lx, ly) = HitTestTopDown(x, y); + if (t is not null) + { + var e = new UiEvent(DragSource!.EventId, t, UiEventType.DropReleased, + Data1: (int)lx, Data2: (int)ly, Payload: DragPayload); + t.OnEvent(in e); // the hit cell's handler places; a non-item target ignores it → item stays removed + } + // else: dropped off any element — no drop fires; the lift's removal stands (retail). + DragSource = null; DragPayload = null; _dragGhost = null; _lastDragHoverTarget = null; + } + ``` + (`GetDragGhost()` on `UiElement`/`UiItemSlot` stays as-is — `BeginDrag` calls it once.) + +### 4.4 NEW — `ShortcutStore` (`src/AcDream.Core/Items/ShortcutStore.cs`) +Port of retail `ShortCutManager::shortCuts_[18]` (`acclient.h:36492`). Pure logic. +```csharp +public sealed class ShortcutStore +{ + public const int SlotCount = 18; + private readonly uint[] _objIds = new uint[SlotCount]; // 0 = empty + + /// Replace all slots from the login PlayerDescription shortcut list. + public void Load(IReadOnlyList entries) + { + System.Array.Clear(_objIds); + foreach (var e in entries) + if (e.Index < SlotCount && e.ObjectGuid != 0) _objIds[e.Index] = e.ObjectGuid; + } + + public uint Get(int slot) => (uint)slot < SlotCount ? _objIds[slot] : 0u; + public bool IsEmpty(int slot) => Get(slot) == 0u; + public void Set(int slot, uint objId) { if ((uint)slot < SlotCount) _objIds[slot] = objId; } + public void Remove(int slot) { if ((uint)slot < SlotCount) _objIds[slot] = 0u; } +} +``` + +### 4.5 Wire — `InventoryActions.BuildAddShortcut` rename (`Core.Net/Messages/InventoryActions.cs`) +Byte layout is already correct; FIX the misleading param names (handoff §B.2; deep-dive §131-145): +```csharp +/// Pin an item/spell to a quickbar slot. ShortCutData = Index(u32),ObjectId(u32), +/// SpellId(u16),Layer(u16). For an item: objectGuid + spellId=layer=0. +public static byte[] BuildAddShortcut(uint seq, uint index, uint objectGuid, ushort spellId, ushort layer) +{ + byte[] body = new byte[24]; + BinaryPrimitives.WriteUInt32LittleEndian(body, GameActionEnvelope); + BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(4), seq); + BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(8), AddShortcutOpcode); // 0x019C + BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(12), index); + BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(16), objectGuid); + BinaryPrimitives.WriteUInt16LittleEndian(body.AsSpan(20), spellId); + BinaryPrimitives.WriteUInt16LittleEndian(body.AsSpan(22), layer); + return body; +} +``` +(`BuildRemoveShortcut(seq, slotIndex)` is correct — unchanged.) Any existing caller of the old +4-arg `BuildAddShortcut` must be updated; grep first (likely none wired today). + +### 4.6 Wire — `WorldSession` sends (`Core.Net/WorldSession.cs`, mirror `SendChangeCombatMode` :1134) +```csharp +public void SendAddShortcut(uint index, uint objectGuid, ushort spellId = 0, ushort layer = 0) +{ + uint seq = NextGameActionSequence(); + SendGameAction(InventoryActions.BuildAddShortcut(seq, index, objectGuid, spellId, layer)); +} +public void SendRemoveShortcut(uint index) +{ + uint seq = NextGameActionSequence(); + SendGameAction(InventoryActions.BuildRemoveShortcut(seq, index)); +} +``` + +### 4.7 `ToolbarController` — the live handler (`UI/Layout/ToolbarController.cs`) +- Holds a `ShortcutStore _store`. `Populate()` reads `_store` (18 slots → `repo.Get(objId)` → icon) + INSTEAD of the read-only `_shortcuts()` provider. **Lazy-load-once:** the PlayerDescription shortcut + list arrives AFTER Bind (at login), so load the store the first time `_shortcuts()` is non-empty, + then treat it as authoritative: + ```csharp + // top of Populate(), before rendering: + if (!_storeLoaded && _shortcuts().Count > 0) { _store.Load(_shortcuts()); _storeLoaded = true; } + ``` + Bind's initial `Populate` (pre-login) sees an empty list and skips the load; the existing + `repo.ObjectAdded → Populate` path (shortcut items arriving via `CreateObject`, by which time the PD + shortcut list is set) triggers the one-time load. After load, drag ops mutate `_store` directly and + it is never reloaded within the session (a relog = a fresh process → `_storeLoaded` resets). No + GameWindow change is needed for loading. +- `IsShortcutGuid(guid)` (the repo-event gate) checks `_store` (any slot holds `guid`) rather than + `_shortcuts()`, so an item dragged ONTO the bar (a new guid) still gets its later updates rendered. +- Each toolbar cell: `DragAcceptSprite = 0x060011FAu` (green cross). (`DragRejectSprite` stays + `0x060011F8`.) +- Inject two `Action`s for the wire (so the controller stays testable without a live session): + `Action sendAdd` = `(index, objId) => session.SendAddShortcut(index, objId)`, + `Action sendRemove` = `index => session.SendRemoveShortcut(index)`. Null in tests. +- `OnDragLift(sourceList, sourceCell, payload)` — retail `RemoveShortcut`: + ```csharp + _store.Remove(payload.SourceSlot); + _sendRemove?.Invoke((uint)payload.SourceSlot); + Populate(); + ``` +- `HandleDropRelease(targetList, targetCell, payload)` — retail reorder branch (deep-dive §5): + ```csharp + int target = targetCell.SlotIndex; + uint evicted = _store.Get(target); // RemoveShortcutInSlotNum + if (evicted != 0) { _store.Remove(target); _sendRemove?.Invoke((uint)target); } + _store.Set(target, payload.ObjId); _sendAdd?.Invoke((uint)target, payload.ObjId); // AddShortcut + if (evicted != 0 && evicted != payload.ObjId && _store.IsEmpty(payload.SourceSlot)) + { // bump displaced → vacated source slot + _store.Set(payload.SourceSlot, evicted); + _sendAdd?.Invoke((uint)payload.SourceSlot, evicted); + } + Populate(); + ``` +- `OnDragOver` unchanged from B.1 (accept any real item → green-cross overlay). + +--- + +## 5. Data flow — frame by frame (reorder slot 3 → occupied slot 5) + +1. Press+move on slot 3 (objId A). `BeginDrag`: snapshot ghost = A's icon; fire `DragBegin`. +2. Cell 3 → `OnDragLift`: `_store.Remove(3)` + `SendRemoveShortcut(3)` (0x019D) + `Populate()` → + slot 3 shows empty. Ghost (A's full icon) follows the cursor. +3. Drag over slot 5 (objId B) → green-cross accept overlay on slot 5. +4. Release on slot 5 → `FinishDrag` delivers `DropReleased` to cell 5 → `HandleDropRelease`: + `evicted=B`; `Remove(5)`+`SendRemoveShortcut(5)`; `Set(5,A)`+`SendAddShortcut(5,A)`; source slot 3 + is empty → `Set(3,B)`+`SendAddShortcut(3,B)`; `Populate()` → slot 5 = A, slot 3 = B (swapped). +5. (Off-bar variant) release over the 3D world → no `DropReleased` → A stays removed → slot 3 empty, + A gone from the bar (server already told at step 2). + +--- + +## 6. Edge cases + +- **Drop on self (slot 3 → slot 3):** at lift slot 3 emptied; at drop `evicted=0`, `Set(3,A)` + + `SendAddShortcut(3,A)` → A back at 3. Net no-op (2 wire msgs — retail does the same). +- **Drop on a non-item element** (chrome / another window): `FinishDrag` delivers `DropReleased` to + it; its `OnEvent` doesn't handle it → no place → A stays removed (off-bar). Correct. +- **Ghost lifetime:** snapshotted at `BeginDrag`, cleared in `FinishDrag` — independent of the source + cell emptying. A cancelled drag (mouse-up off-bar) still clears `_dragGhost`. +- **Empty-slot lift:** an empty slot is not a drag source (`IsDragSource => ItemId != 0` from B.1) → + no drag arms → no lift. (And empty slots still move the window — IA-12, from B.1.) +- **Spell shortcuts in the loaded list** (`ObjectGuid == 0`, a spell): `ShortcutStore.Load` skips + them (item-only this stream); they neither render nor drag. Forward-compat: the store could hold + spell entries later. + +--- + +## 7. Divergence register (update in the implementation commits) + +- **AP-47 (amend):** the ghost is now **full opacity** (retail m_dragIcon) — retire the + `GhostAlpha=0.6` reduced-alpha approximation. The row's REMAINING approximation: the ghost reuses + the full composited `m_pIcon` (which includes the type-default underlay) rather than retail's + dedicated underlay-less `m_pDragIcon`. Reword AP-47 to drop the alpha claim, keep the underlay note. +- **TS-33 (retire):** the toolbar `HandleDropRelease` logging stub is replaced by the real + store-mutation + `AddShortcut`/`RemoveShortcut` wire. Delete the row. +- **No new row** for remove-on-lift / off-bar-remove — it is the faithful retail mechanism (it + *reduces* divergence). The store being client-authoritative post-login matches retail's + `ShortCutManager` (client owns the array; server is notified via the wire). + +--- + +## 8. Testing (conformance) + +Core tests (`tests/AcDream.Core.Tests/`): +1. `ShortcutStore`: `Load` maps Index→ObjId (skips ObjectGuid==0 + out-of-range); `Set`/`Remove`/ + `Get`/`IsEmpty`; bounds-safe. +2. `BuildAddShortcut`: 24-byte body, envelope+seq+`0x019C`+index+objectGuid+spellId(u16)+layer(u16) at + the right offsets (golden bytes). `BuildRemoveShortcut`: 16 bytes, index at +12. + +App tests (`tests/AcDream.App.Tests/`): +3. Spine: `BeginDrag` snapshots the ghost so it survives the source cell being cleared mid-drag (clear + the source cell after begin; assert the drawn ghost source is still the snapshot — assert via a + testable `UiRoot` accessor for `_dragGhost`). +4. Spine: `FinishDrag` over null delivers NO `DropReleased` (a spy cell/handler isn't called); over a + real cell, delivers it (drop the Data0 gate — drop-on-self now dispatches). +5. `ToolbarController.OnDragLift`: removes the source slot from the store, invokes `sendRemove(src)`, + and the source cell empties after `Populate`. +6. `ToolbarController.HandleDropRelease` reorder onto an OCCUPIED target: store ends with dragged@target + + displaced@source; `sendRemove`+`sendAdd` called with the retail sequence/args. Onto an EMPTY + target: dragged@target, source stays empty, displaced-branch not taken. Drop-on-self: re-adds to + source. +7. Update the B.1 tests changed by §4.2/§4.3: `DropReleased_notAccepted_skipsDispatch` → + `DropReleased_alwaysDispatchesToHandler` (the cell dispatches whenever it receives a DropReleased); + the full-chain drag tests still pass (a completed reorder fires `HandleDropRelease`, not `Clicked`). + +--- + +## 9. Acceptance criteria + +- [ ] `dotnet build` + `dotnet test` green (Core + App). +- [ ] AP-47 reworded (no alpha); TS-33 deleted; wire builder renamed; no stray old-signature callers. +- [ ] `UiRoot`/`UiElement`/`UiItemSlot` remain item-agnostic (the lift hook is generic; only + `ToolbarController` knows shortcuts). +- [ ] **Visual (user), `ACDREAM_RETAIL_UI=1`, in-world:** lift a hotbar item → **slot empties + immediately**, **full-opacity** icon follows the cursor with a **green cross** over hovered + slots; drop on another slot → it **moves there** (occupant bumps to the source slot); drop + off-bar → it's **removed**; a single click still **uses** the item. Changes **persist after + relog** (the wire reached ACE). +- [ ] Memory crib + roadmap/ISSUES updated; #141's note refreshed if relevant. + +--- + +## 10. Subagent task slices + +1. **Core: `ShortcutStore` + wire** — `ShortcutStore.cs` + `BuildAddShortcut` rename + + `WorldSession.SendAddShortcut`/`SendRemoveShortcut` + Core tests (§8.1-2). +2. **Spine extensions** — `IItemListDragHandler.OnDragLift`; `UiItemSlot` DragBegin→lift + DropReleased + ungate; `UiRoot` ghost snapshot + full opacity + `FinishDrag` deliver-on-hit-only; update the B.1 + tests (§8.3-4, §8.7). +3. **ToolbarController handler** — `_store` + `Load` + `Populate`-from-store; green-cross sprite; + `OnDragLift`/`HandleDropRelease`; wire `Action`s; GameWindow injects the session sends; controller + tests (§8.5-6). Update AP-47, delete TS-33. + +(1 and 2 are independent; 3 depends on both.) From 7193bed309c0f808db1d85c3d25f5da568100d04 Mon Sep 17 00:00:00 2001 From: Erik Date: Sat, 20 Jun 2026 15:05:02 +0200 Subject: [PATCH 011/133] docs(D.5.3/B.2): toolbar shortcut drag implementation plan MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bite-sized TDD plan, three slices: (1) Core ShortcutStore + the AddShortcut 0x019C/RemoveShortcut 0x019D wire (rename BuildAddShortcut to the real Index/ObjectId/SpellId/Layer fields; SendAddShortcut/SendRemoveShortcut); (2) spine extensions (OnDragLift hook; ghost snapshotted at BeginDrag at full opacity; FinishDrag delivers a drop only on a real hit); (3) ToolbarController as the live handler (store-driven Populate w/ lazy-load; green-cross FA overlay; OnDragLift removes + HandleDropRelease places + bumps displaced→source; wire actions injected from GameWindow). Amends AP-47, deletes TS-33. Spec + plan preapproved; executing subagent-driven next. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../2026-06-20-d2b-toolbar-shortcut-drag.md | 699 ++++++++++++++++++ 1 file changed, 699 insertions(+) create mode 100644 docs/superpowers/plans/2026-06-20-d2b-toolbar-shortcut-drag.md diff --git a/docs/superpowers/plans/2026-06-20-d2b-toolbar-shortcut-drag.md b/docs/superpowers/plans/2026-06-20-d2b-toolbar-shortcut-drag.md new file mode 100644 index 00000000..f0054329 --- /dev/null +++ b/docs/superpowers/plans/2026-06-20-d2b-toolbar-shortcut-drag.md @@ -0,0 +1,699 @@ +# Toolbar shortcut drag interactivity (Stream B.2) Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Make toolbar shortcut drag functional + retail-faithful — lift empties the source slot, the full-opacity icon follows the cursor with a green-cross indicator, dropping on another slot reorders (bumping the occupant to the source slot), dropping off-bar removes it, and all changes go to ACE via `AddShortcut 0x019C`/`RemoveShortcut 0x019D`. + +**Architecture:** Retail's **remove-on-lift / place-on-drop / no-restore** model. Three slices: (1) Core `ShortcutStore` + the wire builders/senders; (2) small spine extensions (a lift hook, a ghost snapshot, drop-on-hit-only); (3) `ToolbarController` as the live drag handler driving the store + wire. Spec: `docs/superpowers/specs/2026-06-20-d2b-toolbar-shortcut-drag-design.md`. + +**Tech Stack:** C# / .NET 10, xUnit. Core logic in `AcDream.Core`, wire in `AcDream.Core.Net`, UI toolkit in `AcDream.App/UI`. `InternalsVisibleTo("AcDream.App.Tests")` + `("AcDream.Core.Tests")` are set. + +--- + +## File Structure + +**Create:** +- `src/AcDream.Core/Items/ShortcutStore.cs` — mutable 18-slot shortcut model. +- `tests/AcDream.Core.Tests/Items/ShortcutStoreTests.cs` + +**Modify:** +- `src/AcDream.Core.Net/Messages/InventoryActions.cs` — `BuildAddShortcut` signature/field fix. +- `src/AcDream.Core.Net/WorldSession.cs` — `SendAddShortcut`/`SendRemoveShortcut`. +- `tests/AcDream.Core.Net.Tests/Messages/InventoryActionsTests.cs` — update `BuildAddShortcut_ThreeFields`. +- `src/AcDream.App/UI/IItemListDragHandler.cs` — add `OnDragLift`. +- `src/AcDream.App/UI/UiItemSlot.cs` — `DragBegin`→lift; `DropReleased` ungate. +- `src/AcDream.App/UI/UiRoot.cs` — `_dragGhost` snapshot; `GhostAlpha=1`; `FinishDrag` deliver-on-hit-only; test accessor. +- `tests/AcDream.App.Tests/UI/DragDropSpineTests.cs` — update B.1 tests + add lift/ghost/off-bar. +- `src/AcDream.App/UI/Layout/ToolbarController.cs` — store + handler + green cross + wire actions. +- `src/AcDream.App/Rendering/GameWindow.cs` — inject the session sends at the toolbar Bind. +- `tests/AcDream.App.Tests/UI/Layout/ToolbarControllerTests.cs` — lift/drop/store tests. +- `docs/architecture/retail-divergence-register.md` — reword AP-47, delete TS-33. + +**Commands** (from repo root): +- Build all: `dotnet build AcDream.slnx -c Debug` +- Core tests: `dotnet test tests/AcDream.Core.Tests/AcDream.Core.Tests.csproj` +- Net tests: `dotnet test tests/AcDream.Core.Net.Tests/AcDream.Core.Net.Tests.csproj` +- App tests: `dotnet test tests/AcDream.App.Tests/AcDream.App.Tests.csproj` + +**Every commit ends with** `Co-Authored-By: Claude Opus 4.8 (1M context) `. + +--- + +## Task 1: Core — ShortcutStore + the AddShortcut/RemoveShortcut wire + +**Files:** +- Create: `src/AcDream.Core/Items/ShortcutStore.cs`, `tests/AcDream.Core.Tests/Items/ShortcutStoreTests.cs` +- Modify: `src/AcDream.Core.Net/Messages/InventoryActions.cs`, `src/AcDream.Core.Net/WorldSession.cs`, `tests/AcDream.Core.Net.Tests/Messages/InventoryActionsTests.cs` + +- [ ] **Step 1: Write the failing ShortcutStore test** + +Create `tests/AcDream.Core.Tests/Items/ShortcutStoreTests.cs`: + +```csharp +using System.Collections.Generic; +using AcDream.Core.Items; +using AcDream.Core.Net.Messages; +using Xunit; + +namespace AcDream.Core.Tests.Items; + +public class ShortcutStoreTests +{ + [Fact] + public void Load_mapsIndexToObjId_skipsEmptyAndOutOfRange() + { + var store = new ShortcutStore(); + var entries = new List + { + new(Index: 0, ObjectGuid: 0x5001u, SpellId: 0, Layer: 0), + new(Index: 3, ObjectGuid: 0x5002u, SpellId: 0, Layer: 0), + new(Index: 5, ObjectGuid: 0u, SpellId: 7, Layer: 1), // spell-only → skipped (item store) + new(Index: 99, ObjectGuid: 0x5003u, SpellId: 0, Layer: 0), // out of range → skipped + }; + store.Load(entries); + Assert.Equal(0x5001u, store.Get(0)); + Assert.Equal(0x5002u, store.Get(3)); + Assert.Equal(0u, store.Get(5)); + Assert.True(store.IsEmpty(1)); + } + + [Fact] + public void SetRemoveGet_roundtrip_andBoundsSafe() + { + var store = new ShortcutStore(); + store.Set(4, 0xABCDu); + Assert.Equal(0xABCDu, store.Get(4)); + Assert.False(store.IsEmpty(4)); + store.Remove(4); + Assert.True(store.IsEmpty(4)); + // bounds-safe: out-of-range Get/Set/Remove never throw + Assert.Equal(0u, store.Get(-1)); + Assert.Equal(0u, store.Get(18)); + store.Set(18, 1u); // no-op, no throw + store.Remove(-1); // no-op, no throw + } +} +``` + +- [ ] **Step 2: Run to verify it fails** + +Run: `dotnet test tests/AcDream.Core.Tests/AcDream.Core.Tests.csproj --filter "FullyQualifiedName~ShortcutStore"` +Expected: FAIL to compile — `ShortcutStore` not defined. + +- [ ] **Step 3: Create ShortcutStore** + +Create `src/AcDream.Core/Items/ShortcutStore.cs`: + +```csharp +using System.Collections.Generic; +using AcDream.Core.Net.Messages; + +namespace AcDream.Core.Items; + +/// +/// Mutable client-side model of the 18 toolbar shortcut slots — port of retail +/// ShortCutManager::shortCuts_[18] (acclient.h:36492). Holds the bound object guid per +/// slot (0 = empty). Loaded from the login PlayerDescription, then mutated by drag-drop +/// (lift removes, drop places); the server is notified via AddShortcut/RemoveShortcut so the +/// store stays the client's source of truth within a session (retail: client owns the array). +/// Item shortcuts only — spell shortcuts (ObjectGuid 0) are skipped on Load. +/// +public sealed class ShortcutStore +{ + public const int SlotCount = 18; + private readonly uint[] _objIds = new uint[SlotCount]; + + /// Replace all slots from the login PlayerDescription shortcut list (item entries only). + public void Load(IReadOnlyList entries) + { + System.Array.Clear(_objIds); + foreach (var e in entries) + if (e.Index < SlotCount && e.ObjectGuid != 0) _objIds[(int)e.Index] = e.ObjectGuid; + } + + /// Bound object guid at , or 0 (empty / out of range). + public uint Get(int slot) => (uint)slot < SlotCount ? _objIds[slot] : 0u; + public bool IsEmpty(int slot) => Get(slot) == 0u; + public void Set(int slot, uint objId) { if ((uint)slot < SlotCount) _objIds[slot] = objId; } + public void Remove(int slot) { if ((uint)slot < SlotCount) _objIds[slot] = 0u; } +} +``` + +(`PlayerDescriptionParser.ShortcutEntry` has `uint Index, uint ObjectGuid, ushort SpellId, ushort Layer`. Verify the field types when you open the file; if `Index` is `uint`, the `e.Index < SlotCount` compare and the `(int)e.Index` cast as written are correct.) + +- [ ] **Step 4: Run to verify it passes** + +Run: `dotnet test tests/AcDream.Core.Tests/AcDream.Core.Tests.csproj --filter "FullyQualifiedName~ShortcutStore"` +Expected: PASS (2 tests). + +- [ ] **Step 5: Write the failing wire test (new BuildAddShortcut signature)** + +Replace the existing `BuildAddShortcut_ThreeFields` test in `tests/AcDream.Core.Net.Tests/Messages/InventoryActionsTests.cs` with: + +```csharp + [Fact] + public void BuildAddShortcut_ItemShortcut_FieldLayout() + { + // ShortCutData = Index(u32), ObjectId(u32), SpellId(u16), Layer(u16). Item → spell/layer 0. + byte[] body = InventoryActions.BuildAddShortcut(seq: 1, index: 0, objectGuid: 0x3E1, spellId: 0, layer: 0); + Assert.Equal(24, body.Length); + Assert.Equal(InventoryActions.AddShortcutOpcode, + System.Buffers.Binary.BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(8))); + Assert.Equal(0u, System.Buffers.Binary.BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(12))); // index + Assert.Equal(0x3E1u, System.Buffers.Binary.BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(16))); // objectGuid + Assert.Equal((ushort)0, System.Buffers.Binary.BinaryPrimitives.ReadUInt16LittleEndian(body.AsSpan(20))); // spellId + Assert.Equal((ushort)0, System.Buffers.Binary.BinaryPrimitives.ReadUInt16LittleEndian(body.AsSpan(22))); // layer + } + + [Fact] + public void BuildAddShortcut_SpellShortcut_PacksSpellAndLayerAsU16s() + { + byte[] body = InventoryActions.BuildAddShortcut(seq: 1, index: 2, objectGuid: 0, spellId: 0x1234, layer: 3); + Assert.Equal(0x1234, System.Buffers.Binary.BinaryPrimitives.ReadUInt16LittleEndian(body.AsSpan(20))); + Assert.Equal(3, System.Buffers.Binary.BinaryPrimitives.ReadUInt16LittleEndian(body.AsSpan(22))); + } +``` + +(The file already has `using System.Buffers.Binary;` per the other tests — if so, use the short `BinaryPrimitives.` form to match; the fully-qualified form above is safe regardless.) + +- [ ] **Step 6: Run to verify it fails** + +Run: `dotnet test tests/AcDream.Core.Net.Tests/AcDream.Core.Net.Tests.csproj --filter "FullyQualifiedName~BuildAddShortcut"` +Expected: FAIL to compile — `BuildAddShortcut` has no `(seq, index, objectGuid, spellId, layer)` overload (old signature is `(seq, slotIndex, objectType, targetId)`). + +- [ ] **Step 7: Fix BuildAddShortcut signature + field packing** + +In `src/AcDream.Core.Net/Messages/InventoryActions.cs`, replace the `BuildAddShortcut` method (the `(uint seq, uint slotIndex, uint objectType, uint targetId)` one) with: + +```csharp + /// Pin an item/spell to a quickbar slot. ShortCutData = Index(u32), ObjectId(u32), + /// SpellId(u16), Layer(u16) — CONFIRMED across ACE/Chorizite/holtburger (action-bar deep-dive + /// §131-145). For an ITEM: objectGuid = item guid, spellId = layer = 0. + public static byte[] BuildAddShortcut( + uint seq, uint index, uint objectGuid, ushort spellId, ushort layer) + { + byte[] body = new byte[24]; + BinaryPrimitives.WriteUInt32LittleEndian(body, GameActionEnvelope); + BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(4), seq); + BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(8), AddShortcutOpcode); + BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(12), index); + BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(16), objectGuid); + BinaryPrimitives.WriteUInt16LittleEndian(body.AsSpan(20), spellId); + BinaryPrimitives.WriteUInt16LittleEndian(body.AsSpan(22), layer); + return body; + } +``` + +- [ ] **Step 8: Add the WorldSession send wrappers** + +In `src/AcDream.Core.Net/WorldSession.cs`, after `SendChangeCombatMode` (~line 1141), add: + +```csharp + /// Send AddShortcut (0x019C) — pin an item to toolbar slot . + /// Retail: CM_Character::Event_AddShortCut. Mirrors the SendChangeCombatMode pattern. + public void SendAddShortcut(uint index, uint objectGuid, ushort spellId = 0, ushort layer = 0) + { + uint seq = NextGameActionSequence(); + SendGameAction(InventoryActions.BuildAddShortcut(seq, index, objectGuid, spellId, layer)); + } + + /// Send RemoveShortcut (0x019D) — clear toolbar slot . + /// Retail: CM_Character::Event_RemoveShortCut. + public void SendRemoveShortcut(uint index) + { + uint seq = NextGameActionSequence(); + SendGameAction(InventoryActions.BuildRemoveShortcut(seq, index)); + } +``` + +(Confirm `InventoryActions` is in scope — `WorldSession` already calls other `*Actions.Build*` builders; add a `using AcDream.Core.Net.Messages;` only if not already present.) + +- [ ] **Step 9: Run to verify all Core/Net tests pass** + +Run: `dotnet test tests/AcDream.Core.Net.Tests/AcDream.Core.Net.Tests.csproj --filter "FullyQualifiedName~InventoryActions"` then `dotnet build src/AcDream.Core.Net/AcDream.Core.Net.csproj -c Debug` +Expected: PASS; build green (no stale callers of the old 4-arg `BuildAddShortcut` — there were none besides the test). + +- [ ] **Step 10: Commit** + +```bash +git add src/AcDream.Core/Items/ShortcutStore.cs tests/AcDream.Core.Tests/Items/ShortcutStoreTests.cs src/AcDream.Core.Net/Messages/InventoryActions.cs src/AcDream.Core.Net/WorldSession.cs tests/AcDream.Core.Net.Tests/Messages/InventoryActionsTests.cs +git commit -m "feat(core): D.5.3/B.2 — ShortcutStore + AddShortcut/RemoveShortcut wire + senders" -m "Co-Authored-By: Claude Opus 4.8 (1M context) " +``` + +--- + +## Task 2: Spine extensions — lift hook + ghost snapshot + drop-on-hit-only + +**Files:** +- Modify: `src/AcDream.App/UI/IItemListDragHandler.cs`, `src/AcDream.App/UI/UiItemSlot.cs`, `src/AcDream.App/UI/UiRoot.cs` +- Test: `tests/AcDream.App.Tests/UI/DragDropSpineTests.cs` + +- [ ] **Step 1: Write/adjust the failing tests** in `tests/AcDream.App.Tests/UI/DragDropSpineTests.cs` + +First, the `SpyHandler` (nested in `DragDropSpineTests`) needs to record `OnDragLift`. UPDATE the `SpyHandler` class to add: + +```csharp + public (UiItemList list, UiItemSlot cell, ItemDragPayload payload)? LastLift; + public void OnDragLift(UiItemList list, UiItemSlot cell, ItemDragPayload p) + { LastLift = (list, cell, p); } +``` + +Then REPLACE the existing `DropReleased_notAccepted_skipsDispatch` test (its premise — Data0==0 skips — is gone under the retail model) with: + +```csharp + [Fact] + public void DropReleased_dispatchesToHandler_regardlessOfData0() + { + // Retail model: reaching the cell means a real slot was hit (FinishDrag only delivers on a + // hit), so the handler is authoritative — it dispatches whether or not Data0 is set. + var (list, cell, h) = ListWithHandler(); + cell.OnEvent(new UiEvent(0u, cell, UiEventType.DropReleased, Data0: 0, Payload: SomePayload())); + Assert.NotNull(h.LastDrop); + } +``` + +Then ADD these new tests inside the class: + +```csharp + [Fact] + public void DragBegin_callsHandlerOnDragLift() + { + var (list, cell, h) = ListWithHandler(); + var p = SomePayload(); + cell.OnEvent(new UiEvent(0u, cell, UiEventType.DragBegin, Payload: p)); + Assert.NotNull(h.LastLift); + Assert.Same(list, h.LastLift!.Value.list); + Assert.Same(cell, h.LastLift.Value.cell); + Assert.Same(p, h.LastLift.Value.payload); + } + + [Fact] + public void Ghost_isSnapshottedAtBeginDrag_survivesSourceCellClearing() + { + // The lift empties the source cell; the ghost must persist (snapshot at BeginDrag), not + // re-read the now-empty cell. Drive the full UiRoot chain, then clear the source cell. + var (root, _, cell) = RootWithBoundSlot(0x5001u); // icon tex 0x99 + root.OnMouseDown(UiMouseButton.Left, 10, 10); + root.OnMouseMove(20, 10); // BeginDrag → snapshot ghost + cell.Clear(); // simulate the lift emptying the source + Assert.Equal((0x99u, 32, 32), root.DragGhostForTest); + } + + [Fact] + public void FinishDrag_overNothing_deliversNoDrop_butLiftStands() + { + // Drag from a slot with a handler, release over empty space → no HandleDropRelease (off-bar), + // but OnDragLift already fired at begin (the lift). Requires the slot inside a list w/ handler. + var root = new UiRoot { Width = 800, Height = 600 }; + var list = new UiItemList(_ => (1u, 1, 1)) { Left = 0, Top = 0, Width = 32, Height = 32 }; + list.Cell.Width = 32; list.Cell.Height = 32; + list.Cell.SetItem(0x5001u, 0x99u); + var h = new SpyHandler(); + list.RegisterDragHandler(h); + root.AddChild(list); + + root.OnMouseDown(UiMouseButton.Left, 10, 10); + root.OnMouseMove(20, 10); // BeginDrag → OnDragLift + root.OnMouseUp(UiMouseButton.Left, 600, 500); // release over empty space + Assert.NotNull(h.LastLift); // lift happened + Assert.Null(h.LastDrop); // no drop dispatched (off-bar) + Assert.Null(root.DragSource); // cleaned up + } +``` + +- [ ] **Step 2: Run to verify it fails** + +Run: `dotnet test tests/AcDream.App.Tests/AcDream.App.Tests.csproj --filter "FullyQualifiedName~DragDropSpine"` +Expected: FAIL to compile — `IItemListDragHandler.OnDragLift` not defined (SpyHandler doesn't satisfy the interface), `root.DragGhostForTest` not defined. + +- [ ] **Step 3: Add OnDragLift to the interface** + +In `src/AcDream.App/UI/IItemListDragHandler.cs`, add as the FIRST member of the interface: + +```csharp + /// The drag STARTED from a cell in this list — retail's RecvNotice_ItemListBeginDrag + /// → RemoveShortcut (decomp 0x004bd930/0x004bd450): the handler removes the lifted item from its + /// model + wire so the source slot empties immediately. The item is "in hand" until + /// HandleDropRelease (place) or the drag ends off-target (stays removed). No restore on cancel. + void OnDragLift(UiItemList sourceList, UiItemSlot sourceCell, ItemDragPayload payload); +``` + +- [ ] **Step 4: UiItemSlot — DragBegin fires the lift; DropReleased ungated** + +In `src/AcDream.App/UI/UiItemSlot.cs` `OnEvent`, change the `DragBegin` case from `return true;` to: + +```csharp + case UiEventType.DragBegin: + // Notify the source list's handler so it can lift (remove + wire) — retail + // RecvNotice_ItemListBeginDrag → RemoveShortcut. UiRoot snapshotted the ghost first. + if (FindList() is { DragHandler: { } lh } liftList && e.Payload is ItemDragPayload lp) + lh.OnDragLift(liftList, this, lp); + return true; +``` + +And change the `DropReleased` case — drop the `e.Data0 == 1` gate (reaching the cell = a real slot was hit; the handler is authoritative): + +```csharp + case UiEventType.DropReleased: + _dragAccept = DragAcceptState.None; + if (FindList() is { DragHandler: { } dh } dl && e.Payload is ItemDragPayload dp) + dh.HandleDropRelease(dl, this, dp); + return true; +``` + +- [ ] **Step 5: UiRoot — snapshot the ghost, full opacity, deliver-on-hit-only** + +In `src/AcDream.App/UI/UiRoot.cs`: + +(a) Add the snapshot field near `DragPayload` (~line 73): `private (uint tex, int w, int h)? _dragGhost;` and a test accessor near it: `internal (uint tex, int w, int h)? DragGhostForTest => _dragGhost;` + +(b) In `BeginDrag`, snapshot the ghost BEFORE firing `DragBegin`: + +```csharp + private void BeginDrag(UiElement source) + { + var payload = source.GetDragPayload(); + if (payload is null) { _dragCandidate = false; return; } + DragSource = source; + DragPayload = payload; + _dragGhost = source.GetDragGhost(); // snapshot NOW — the DragBegin handler may empty the source cell + var e = new UiEvent(source.EventId, source, UiEventType.DragBegin, Payload: payload); + source.OnEvent(in e); + } +``` + +(c) Change `GhostAlpha` to `1.0f` and make `DrawDragGhost` use the snapshot: + +```csharp + private const float GhostAlpha = 1.0f; // retail m_dragIcon is the full icon, no fade + + private void DrawDragGhost(UiRenderContext ctx) + { + if (_dragGhost is not { } g || g.tex == 0) return; + ctx.DrawSprite(g.tex, MouseX - g.w / 2f, MouseY - g.h / 2f, g.w, g.h, + 0f, 0f, 1f, 1f, new Vector4(1f, 1f, 1f, GhostAlpha)); + } +``` + +(d) Replace `FinishDrag` to deliver only on a real hit + clear the ghost: + +```csharp + private void FinishDrag(int x, int y) + { + var (t, lx, ly) = HitTestTopDown(x, y); + if (t is not null) + { + // Dropped on a real element — deliver DropReleased; the hit cell's handler places. + // A non-item target's OnEvent ignores it, so an off-bar drop leaves the lift's removal. + var e = new UiEvent(DragSource!.EventId, t, UiEventType.DropReleased, + Data1: (int)lx, Data2: (int)ly, Payload: DragPayload); + t.OnEvent(in e); + } + // else: dropped on nothing — no drop fires; the lift (drag-begin removal) stands (retail). + DragSource = null; + DragPayload = null; + _dragGhost = null; + _lastDragHoverTarget = null; + } +``` + +- [ ] **Step 6: Run to verify it passes** + +Run: `dotnet test tests/AcDream.App.Tests/AcDream.App.Tests.csproj --filter "FullyQualifiedName~DragDropSpine"` +Expected: PASS (the updated + new spine tests; the existing arming, use-vs-drag, overlay, and window-topology tests still pass — `CompletedDrag_doesNotFireUse` still holds because a completed drag takes the `FinishDrag` path before any Click). + +- [ ] **Step 7: Run the full app suite (no regressions)** + +Run: `dotnet test tests/AcDream.App.Tests/AcDream.App.Tests.csproj` +Expected: all pass (the ToolbarController tests still pass — `ToolbarController` already implements the interface from B.1; you'll add the `OnDragLift` body in Task 3, but a default-throwing stub isn't present, so confirm: if the build fails because `ToolbarController` doesn't implement the new `OnDragLift` member, add a temporary `public void OnDragLift(...) {}` no-op in `ToolbarController` now and flesh it out in Task 3. Prefer to just do Task 3's `OnDragLift` body — but to keep Task 2 self-contained + green, a one-line empty `OnDragLift` is acceptable here and gets its real body in Task 3.) + +- [ ] **Step 8: Commit** + +```bash +git add src/AcDream.App/UI/IItemListDragHandler.cs src/AcDream.App/UI/UiItemSlot.cs src/AcDream.App/UI/UiRoot.cs tests/AcDream.App.Tests/UI/DragDropSpineTests.cs src/AcDream.App/UI/Layout/ToolbarController.cs +git commit -m "feat(ui): D.5.3/B.2 — spine: drag-lift hook + ghost snapshot (full opacity) + drop-on-hit-only" -m "Co-Authored-By: Claude Opus 4.8 (1M context) " +``` + +--- + +## Task 3: ToolbarController — live handler (store + reorder/remove + wire + green cross) + +**Files:** +- Modify: `src/AcDream.App/UI/Layout/ToolbarController.cs`, `src/AcDream.App/Rendering/GameWindow.cs`, `docs/architecture/retail-divergence-register.md` +- Test: `tests/AcDream.App.Tests/UI/Layout/ToolbarControllerTests.cs` + +- [ ] **Step 1: Write the failing tests** — append inside `ToolbarControllerTests` class + +```csharp + // ── B.2: live drag handler (store + reorder/remove + wire) ─────────────── + private static (System.Collections.Generic.List<(uint i,uint g)> adds, + System.Collections.Generic.List removes) NewSpies(out System.Action add, out System.Action rem) + { + var adds = new System.Collections.Generic.List<(uint,uint)>(); + var removes = new System.Collections.Generic.List(); + add = (i, g) => adds.Add((i, g)); + rem = i => removes.Add(i); + return (adds, removes); + } + + [Fact] + public void OnDragLift_removesSourceSlot_sendsRemove_emptiesCell() + { + var (layout, slots, _) = FakeToolbar(); + var repo = new ClientObjectTable(); + repo.AddOrUpdate(new ClientObject { ObjectId = 0x5001u, WeenieClassId = 1u, IconId = 0x06001234u }); + var shortcuts = new System.Collections.Generic.List + { new(Index: 3, ObjectGuid: 0x5001u, SpellId: 0, Layer: 0) }; + var (adds, removes) = NewSpies(out var add, out var rem); + + var ctrl = ToolbarController.Bind(layout, repo, () => shortcuts, + iconIds: (_,_,_,_,_) => 0x77u, useItem: _ => { }, + sendAddShortcut: add, sendRemoveShortcut: rem); + Assert.Equal(0x5001u, slots[Row1[3]].Cell.ItemId); // bound at slot 3 + + var payload = new ItemDragPayload(0x5001u, ItemDragSource.ShortcutBar, 3, slots[Row1[3]].Cell); + ctrl.OnDragLift(slots[Row1[3]], slots[Row1[3]].Cell, payload); + + Assert.Contains(3u, removes); // RemoveShortcut(3) sent + Assert.Equal(0u, slots[Row1[3]].Cell.ItemId); // source slot now empty + } + + [Fact] + public void HandleDropRelease_ontoOccupied_swaps_andSendsRetailSequence() + { + var (layout, slots, _) = FakeToolbar(); + var repo = new ClientObjectTable(); + repo.AddOrUpdate(new ClientObject { ObjectId = 0x5001u, WeenieClassId = 1u, IconId = 0x06001234u }); // A + repo.AddOrUpdate(new ClientObject { ObjectId = 0x5002u, WeenieClassId = 1u, IconId = 0x06005678u }); // B + var shortcuts = new System.Collections.Generic.List + { new(Index: 3, ObjectGuid: 0x5001u, SpellId: 0, Layer: 0), + new(Index: 5, ObjectGuid: 0x5002u, SpellId: 0, Layer: 0) }; + var (adds, removes) = NewSpies(out var add, out var rem); + var ctrl = ToolbarController.Bind(layout, repo, () => shortcuts, + iconIds: (_,_,_,_,_) => 0x77u, useItem: _ => { }, + sendAddShortcut: add, sendRemoveShortcut: rem); + + // Lift A from slot 3, then drop on occupied slot 5 (holds B). + var payload = new ItemDragPayload(0x5001u, ItemDragSource.ShortcutBar, 3, slots[Row1[3]].Cell); + ctrl.OnDragLift(slots[Row1[3]], slots[Row1[3]].Cell, payload); + ctrl.HandleDropRelease(slots[Row1[5]], slots[Row1[5]].Cell, payload); + + // End state: A at slot 5, B bumped to the vacated source slot 3. + Assert.Equal(0x5001u, slots[Row1[5]].Cell.ItemId); + Assert.Equal(0x5002u, slots[Row1[3]].Cell.ItemId); + // Wire (retail sequence): RemoveShortcut(3)[lift], RemoveShortcut(5)[evict B], AddShortcut(5,A), AddShortcut(3,B) + Assert.Equal(new[] { 3u, 5u }, removes.ToArray()); + Assert.Contains((5u, 0x5001u), adds); + Assert.Contains((3u, 0x5002u), adds); + } + + [Fact] + public void HandleDropRelease_ontoEmpty_placesOnly_sourceStaysEmpty() + { + var (layout, slots, _) = FakeToolbar(); + var repo = new ClientObjectTable(); + repo.AddOrUpdate(new ClientObject { ObjectId = 0x5001u, WeenieClassId = 1u, IconId = 0x06001234u }); + var shortcuts = new System.Collections.Generic.List + { new(Index: 3, ObjectGuid: 0x5001u, SpellId: 0, Layer: 0) }; + var (adds, removes) = NewSpies(out var add, out var rem); + var ctrl = ToolbarController.Bind(layout, repo, () => shortcuts, + iconIds: (_,_,_,_,_) => 0x77u, useItem: _ => { }, + sendAddShortcut: add, sendRemoveShortcut: rem); + + var payload = new ItemDragPayload(0x5001u, ItemDragSource.ShortcutBar, 3, slots[Row1[3]].Cell); + ctrl.OnDragLift(slots[Row1[3]], slots[Row1[3]].Cell, payload); + ctrl.HandleDropRelease(slots[Row1[7]], slots[Row1[7]].Cell, payload); // slot 7 empty + + Assert.Equal(0x5001u, slots[Row1[7]].Cell.ItemId); // A placed at 7 + Assert.Equal(0u, slots[Row1[3]].Cell.ItemId); // source 3 stays empty + Assert.Contains((7u, 0x5001u), adds); + Assert.DoesNotContain(adds, a => a.i == 3u); // no bump (target was empty) + } + + [Fact] + public void ToolbarSlots_useGreenCrossAcceptSprite() + { + var (layout, slots, _) = FakeToolbar(); + ToolbarController.Bind(layout, new ClientObjectTable(), + () => System.Array.Empty(), + iconIds: (_,_,_,_,_) => 0u, useItem: _ => { }); + Assert.Equal(0x060011FAu, slots[Row1[0]].Cell.DragAcceptSprite); // green cross, not the ring F9 + } +``` + +- [ ] **Step 2: Run to verify it fails** + +Run: `dotnet test tests/AcDream.App.Tests/AcDream.App.Tests.csproj --filter "FullyQualifiedName~ToolbarController"` +Expected: FAIL to compile — `ToolbarController.Bind` has no `sendAddShortcut`/`sendRemoveShortcut` params; `OnDragLift` is a no-op stub (from Task 2); `DragAcceptSprite` not set to FA. + +- [ ] **Step 3: Add the store + wire fields + Bind params** + +In `src/AcDream.App/UI/Layout/ToolbarController.cs`: + +(a) Add fields near the other private fields: + +```csharp + private readonly AcDream.Core.Items.ShortcutStore _store = new(); + private bool _storeLoaded; + private readonly Action? _sendAddShortcut; // (index, objectGuid) + private readonly Action? _sendRemoveShortcut; // (index) +``` + +(b) Add the two params to the private ctor signature and the `Bind` factory (both, at the end, optional), and assign them in the ctor: + +```csharp + // ctor params (add after emptyDigits): + Action? sendAddShortcut = null, + Action? sendRemoveShortcut = null, + // ctor body (with the other assignments): + _sendAddShortcut = sendAddShortcut; + _sendRemoveShortcut = sendRemoveShortcut; +``` + +(Mirror the same two params on the `public static ToolbarController Bind(...)` signature and forward them into the `new ToolbarController(...)` call.) + +(c) In the ctor's slot loop (where B.1 already does `RegisterDragHandler(this)` + `SlotIndex` + `SourceKind`), add the green-cross sprite: + +```csharp + list.Cell.DragAcceptSprite = 0x060011FAu; // green cross (toolbar), not the ring 0x060011F9 (inventory) +``` + +- [ ] **Step 4: Switch Populate + IsShortcutGuid to the store; add the handler bodies** + +(a) Replace `Populate()` with the store-driven version (lazy-load once): + +```csharp + public void Populate() + { + // Lazy-load the store from the login shortcut list the first time it's present (PD arrives + // after Bind); thereafter the store is authoritative and drag ops mutate it directly. + if (!_storeLoaded && _shortcuts().Count > 0) { _store.Load(_shortcuts()); _storeLoaded = true; } + + foreach (var list in _slots) list?.Cell.Clear(); + + for (int slot = 0; slot < _slots.Length; slot++) + { + uint guid = _store.Get(slot); + if (guid == 0) continue; + var list = _slots[slot]; + if (list is null) continue; + var item = _repo.Get(guid); + if (item is null) continue; // deferred: ObjectAdded re-calls Populate + uint tex = _iconIds(item.Type, item.IconId, item.IconUnderlayId, item.IconOverlayId, item.Effects); + list.Cell.SetItem(guid, tex); + } + RestampShortcutNumbers(); + } +``` + +(b) Replace `IsShortcutGuid` to read the store: + +```csharp + private bool IsShortcutGuid(uint guid) + { + for (int s = 0; s < AcDream.Core.Items.ShortcutStore.SlotCount; s++) + if (_store.Get(s) == guid) return true; + return false; + } +``` + +(c) Replace the Task-2 stub `OnDragLift` (and the B.1 `HandleDropRelease` logging stub) with the real bodies: + +```csharp + /// + public void OnDragLift(UiItemList sourceList, UiItemSlot sourceCell, ItemDragPayload payload) + { + // Retail RecvNotice_ItemListBeginDrag → RemoveShortcut (0x004bd930/0x004bd450): the lifted + // shortcut leaves the bar (+ wire) the instant the drag starts; it's re-placed only on a + // drop onto a slot. Off-bar release leaves it removed. + _store.Remove(payload.SourceSlot); + _sendRemoveShortcut?.Invoke((uint)payload.SourceSlot); + Populate(); + } + + /// + public void HandleDropRelease(UiItemList targetList, UiItemSlot targetCell, ItemDragPayload payload) + { + // Retail gmToolbarUI::HandleDropRelease (0x004be7c0) within-bar reorder branch (deep-dive §5): + // evict the target's occupant, place the dragged item there, and bump the evicted item into + // the (now-vacated) source slot if it's free. + int target = targetCell.SlotIndex; + uint evicted = _store.Get(target); // RemoveShortcutInSlotNum(target) + if (evicted != 0) { _store.Remove(target); _sendRemoveShortcut?.Invoke((uint)target); } + _store.Set(target, payload.ObjId); // AddShortcut(dragged, target) + _sendAddShortcut?.Invoke((uint)target, payload.ObjId); + if (evicted != 0 && evicted != payload.ObjId && _store.IsEmpty(payload.SourceSlot)) + { // displaced → vacated source slot + _store.Set(payload.SourceSlot, evicted); + _sendAddShortcut?.Invoke((uint)payload.SourceSlot, evicted); + } + Populate(); + } +``` + +(Delete the old logging `Console.WriteLine` `HandleDropRelease` body and the `OnDragOver` stays as-is.) + +- [ ] **Step 5: Run to verify the controller tests pass** + +Run: `dotnet test tests/AcDream.App.Tests/AcDream.App.Tests.csproj --filter "FullyQualifiedName~ToolbarController"` +Expected: PASS (new B.2 tests + the existing B.1 controller tests — `Bind_registersDragHandler_andStampsSlots`, `Populate_bindsShortcutToCorrectSlot` etc. still hold; note `Populate_bindsShortcutToCorrectSlot` exercises the lazy-load path now and should still bind 0x5001 to slot 0). + +- [ ] **Step 6: Inject the session sends at the GameWindow toolbar Bind** + +In `src/AcDream.App/Rendering/GameWindow.cs`, at the `ToolbarController.Bind(...)` call (~line 2013), add the two send actions (mirror the `sendQueryHealth: g => _liveSession?.SendQueryHealth(g)` style used by `SelectedObjectController.Bind` just below it): + +```csharp + sendAddShortcut: (i, g) => _liveSession?.SendAddShortcut(i, g), + sendRemoveShortcut: i => _liveSession?.SendRemoveShortcut(i), +``` + +(Add them as the last arguments to the `ToolbarController.Bind(...)` call, after `emptyDigits:`.) + +- [ ] **Step 7: Update the divergence register** + +In `docs/architecture/retail-divergence-register.md`: +- **Reword AP-47** (the ghost row) — remove the reduced-alpha claim (the ghost is now full opacity, matching retail). New text for the Divergence cell: + `Cursor drag ghost reuses the full composited m_pIcon (incl. type-default underlay) instead of retail's dedicated underlay-less m_pDragIcon. Opacity now matches retail (full).` + and the Risk cell: `The ghost carries the opaque type-default underlay backing rather than retail's underlay-less copy — a subtle look difference while dragging, no functional effect.` (Where/oracle columns unchanged.) +- **Delete the TS-33 row** entirely (the logging stub is replaced by the real store + wire) and change the TS section header count `## 4. Temporary stopgap (TS) — 32 rows` → `— 31 rows`. + +- [ ] **Step 8: Full build + test (no regressions)** + +Run: `dotnet build AcDream.slnx -c Debug` then `dotnet test tests/AcDream.App.Tests/AcDream.App.Tests.csproj` and `dotnet test tests/AcDream.Core.Tests/AcDream.Core.Tests.csproj` +Expected: build green; all pass. + +- [ ] **Step 9: Commit** + +```bash +git add src/AcDream.App/UI/Layout/ToolbarController.cs src/AcDream.App/Rendering/GameWindow.cs tests/AcDream.App.Tests/UI/Layout/ToolbarControllerTests.cs docs/architecture/retail-divergence-register.md +git commit -m "feat(ui): D.5.3/B.2 — toolbar reorder/remove via ShortcutStore + wire + green-cross overlay" -m "Co-Authored-By: Claude Opus 4.8 (1M context) " +``` + +--- + +## Final verification + +- [ ] **Full solution:** `dotnet build AcDream.slnx -c Debug`; `dotnet test tests/AcDream.Core.Tests/...`, `tests/AcDream.Core.Net.Tests/...`, `tests/AcDream.App.Tests/...` — all green. +- [ ] **Spec acceptance §9 self-check:** UiRoot/UiItemSlot stay item-agnostic; AP-47 reworded; TS-33 deleted; wire builder renamed with no stale callers. +- [ ] **Hand to visual verification** (user, `ACDREAM_RETAIL_UI=1`, in-world): lift a hotbar item → source slot empties immediately; full-opacity icon follows the cursor; hovered slots show a green CROSS; drop on another slot → moves there (occupant bumps to source); drop off-bar → removed; single click still uses; changes persist after relog. +- [ ] **Update memory** (`project_d2b_retail_ui.md`) with the B.2 landing + the retail remove-on-lift model; refresh #141 / roadmap as needed. From 745a92bbaeaa11f7f1ea2ccb2cb3e86ad41356eb Mon Sep 17 00:00:00 2001 From: Erik Date: Sat, 20 Jun 2026 15:09:58 +0200 Subject: [PATCH 012/133] =?UTF-8?q?feat(core):=20D.5.3/B.2=20=E2=80=94=20S?= =?UTF-8?q?hortcutStore=20+=20AddShortcut/RemoveShortcut=20wire=20+=20send?= =?UTF-8?q?ers?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ShortcutStore lands in AcDream.Core.Net.Items (not AcDream.Core.Items) because it depends on PlayerDescriptionParser.ShortcutEntry; placing it in AcDream.Core would create a circular dependency (Core.Net already references Core). The test lives in AcDream.Core.Tests which gets Core.Net transitively via App. BuildAddShortcut signature corrected from the old (seq, slotIndex, objectType, targetId) 4×u32 layout to the retail ShortCutData wire format confirmed in the action-bar deep-dive: Index(u32), ObjectId(u32), SpellId(u16), Layer(u16). The old BuildAddShortcut_ThreeFields test is replaced by two new tests that verify both item and spell shortcut packing. WorldSession gains SendAddShortcut / SendRemoveShortcut following the SendChangeCombatMode sender pattern. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/AcDream.Core.Net/Items/ShortcutStore.cs | 32 ++++++++++++++ .../Messages/InventoryActions.cs | 13 +++--- src/AcDream.Core.Net/WorldSession.cs | 16 +++++++ .../Messages/InventoryActionsTests.cs | 27 +++++++----- .../Items/ShortcutStoreTests.cs | 42 +++++++++++++++++++ 5 files changed, 115 insertions(+), 15 deletions(-) create mode 100644 src/AcDream.Core.Net/Items/ShortcutStore.cs create mode 100644 tests/AcDream.Core.Tests/Items/ShortcutStoreTests.cs diff --git a/src/AcDream.Core.Net/Items/ShortcutStore.cs b/src/AcDream.Core.Net/Items/ShortcutStore.cs new file mode 100644 index 00000000..bff0b098 --- /dev/null +++ b/src/AcDream.Core.Net/Items/ShortcutStore.cs @@ -0,0 +1,32 @@ +using System.Collections.Generic; +using AcDream.Core.Net.Messages; + +namespace AcDream.Core.Net.Items; + +/// +/// Mutable client-side model of the 18 toolbar shortcut slots — port of retail +/// ShortCutManager::shortCuts_[18] (acclient.h:36492). Holds the bound object guid per +/// slot (0 = empty). Loaded from the login PlayerDescription, then mutated by drag-drop +/// (lift removes, drop places); the server is notified via AddShortcut/RemoveShortcut so the +/// store stays the client's source of truth within a session (retail: client owns the array). +/// Item shortcuts only — spell shortcuts (ObjectGuid 0) are skipped on Load. +/// +public sealed class ShortcutStore +{ + public const int SlotCount = 18; + private readonly uint[] _objIds = new uint[SlotCount]; + + /// Replace all slots from the login PlayerDescription shortcut list (item entries only). + public void Load(IReadOnlyList entries) + { + System.Array.Clear(_objIds); + foreach (var e in entries) + if (e.Index < SlotCount && e.ObjectGuid != 0) _objIds[(int)e.Index] = e.ObjectGuid; + } + + /// Bound object guid at , or 0 (empty / out of range). + public uint Get(int slot) => (uint)slot < SlotCount ? _objIds[slot] : 0u; + public bool IsEmpty(int slot) => Get(slot) == 0u; + public void Set(int slot, uint objId) { if ((uint)slot < SlotCount) _objIds[slot] = objId; } + public void Remove(int slot) { if ((uint)slot < SlotCount) _objIds[slot] = 0u; } +} diff --git a/src/AcDream.Core.Net/Messages/InventoryActions.cs b/src/AcDream.Core.Net/Messages/InventoryActions.cs index f46bd5b6..72fe566c 100644 --- a/src/AcDream.Core.Net/Messages/InventoryActions.cs +++ b/src/AcDream.Core.Net/Messages/InventoryActions.cs @@ -95,17 +95,20 @@ public static class InventoryActions return body; } - /// Pin an item / spell to a quickbar slot. + /// Pin an item/spell to a quickbar slot. ShortCutData = Index(u32), ObjectId(u32), + /// SpellId(u16), Layer(u16) — CONFIRMED across ACE/Chorizite/holtburger (action-bar deep-dive + /// §131-145). For an ITEM: objectGuid = item guid, spellId = layer = 0. public static byte[] BuildAddShortcut( - uint seq, uint slotIndex, uint objectType, uint targetId) + uint seq, uint index, uint objectGuid, ushort spellId, ushort layer) { byte[] body = new byte[24]; BinaryPrimitives.WriteUInt32LittleEndian(body, GameActionEnvelope); BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(4), seq); BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(8), AddShortcutOpcode); - BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(12), slotIndex); - BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(16), objectType); - BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(20), targetId); + BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(12), index); + BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(16), objectGuid); + BinaryPrimitives.WriteUInt16LittleEndian(body.AsSpan(20), spellId); + BinaryPrimitives.WriteUInt16LittleEndian(body.AsSpan(22), layer); return body; } diff --git a/src/AcDream.Core.Net/WorldSession.cs b/src/AcDream.Core.Net/WorldSession.cs index 6508084d..3f6dbbe2 100644 --- a/src/AcDream.Core.Net/WorldSession.cs +++ b/src/AcDream.Core.Net/WorldSession.cs @@ -1140,6 +1140,22 @@ public sealed class WorldSession : IDisposable SendGameAction(body); } + /// Send AddShortcut (0x019C) — pin an item to toolbar slot . + /// Retail: CM_Character::Event_AddShortCut. Mirrors the SendChangeCombatMode pattern. + public void SendAddShortcut(uint index, uint objectGuid, ushort spellId = 0, ushort layer = 0) + { + uint seq = NextGameActionSequence(); + SendGameAction(InventoryActions.BuildAddShortcut(seq, index, objectGuid, spellId, layer)); + } + + /// Send RemoveShortcut (0x019D) — clear toolbar slot . + /// Retail: CM_Character::Event_RemoveShortCut. + public void SendRemoveShortcut(uint index) + { + uint seq = NextGameActionSequence(); + SendGameAction(InventoryActions.BuildRemoveShortcut(seq, index)); + } + /// Send retail QueryHealth (0x01BF). Server replies UpdateHealth (0x01C0). /// /// Retail anchor: CM_Combat::Event_QueryHealth / gmToolbarUI::HandleSelectionChanged:198635 diff --git a/tests/AcDream.Core.Net.Tests/Messages/InventoryActionsTests.cs b/tests/AcDream.Core.Net.Tests/Messages/InventoryActionsTests.cs index 5bfec696..ee71c3a0 100644 --- a/tests/AcDream.Core.Net.Tests/Messages/InventoryActionsTests.cs +++ b/tests/AcDream.Core.Net.Tests/Messages/InventoryActionsTests.cs @@ -74,18 +74,25 @@ public sealed class InventoryActionsTests } [Fact] - public void BuildAddShortcut_ThreeFields() + public void BuildAddShortcut_ItemShortcut_FieldLayout() { - byte[] body = InventoryActions.BuildAddShortcut( - seq: 1, slotIndex: 0, objectType: 1, targetId: 0x3E1); + // ShortCutData = Index(u32), ObjectId(u32), SpellId(u16), Layer(u16). Item → spell/layer 0. + byte[] body = InventoryActions.BuildAddShortcut(seq: 1, index: 0, objectGuid: 0x3E1, spellId: 0, layer: 0); + Assert.Equal(24, body.Length); Assert.Equal(InventoryActions.AddShortcutOpcode, - BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(8))); - Assert.Equal(0u, - BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(12))); - Assert.Equal(1u, - BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(16))); - Assert.Equal(0x3E1u, - BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(20))); + 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))); } [Fact] diff --git a/tests/AcDream.Core.Tests/Items/ShortcutStoreTests.cs b/tests/AcDream.Core.Tests/Items/ShortcutStoreTests.cs new file mode 100644 index 00000000..2804f64e --- /dev/null +++ b/tests/AcDream.Core.Tests/Items/ShortcutStoreTests.cs @@ -0,0 +1,42 @@ +using System.Collections.Generic; +using AcDream.Core.Net.Items; +using AcDream.Core.Net.Messages; +using Xunit; + +namespace AcDream.Core.Tests.Items; + +public class ShortcutStoreTests +{ + [Fact] + public void Load_mapsIndexToObjId_skipsEmptyAndOutOfRange() + { + var store = new ShortcutStore(); + var entries = new List + { + new(Index: 0, ObjectGuid: 0x5001u, SpellId: 0, Layer: 0), + new(Index: 3, ObjectGuid: 0x5002u, SpellId: 0, Layer: 0), + new(Index: 5, ObjectGuid: 0u, SpellId: 7, Layer: 1), // spell-only → skipped (item store) + new(Index: 99, ObjectGuid: 0x5003u, SpellId: 0, Layer: 0), // out of range → skipped + }; + store.Load(entries); + Assert.Equal(0x5001u, store.Get(0)); + Assert.Equal(0x5002u, store.Get(3)); + Assert.Equal(0u, store.Get(5)); + Assert.True(store.IsEmpty(1)); + } + + [Fact] + public void SetRemoveGet_roundtrip_andBoundsSafe() + { + var store = new ShortcutStore(); + store.Set(4, 0xABCDu); + Assert.Equal(0xABCDu, store.Get(4)); + Assert.False(store.IsEmpty(4)); + store.Remove(4); + Assert.True(store.IsEmpty(4)); + 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 + } +} From a497cc631eee542878086ccf69ad05cf40f522a8 Mon Sep 17 00:00:00 2001 From: Erik Date: Sat, 20 Jun 2026 15:18:35 +0200 Subject: [PATCH 013/133] =?UTF-8?q?test(core):=20D.5.3/B.2=20=E2=80=94=20m?= =?UTF-8?q?ove=20ShortcutStoreTests=20to=20Core.Net.Tests=20(Rule=206)=20+?= =?UTF-8?q?=20cosmetic=20cleanups?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Move ShortcutStoreTests from AcDream.Core.Tests to AcDream.Core.Net.Tests (Rule 6: tests live in the project matching the layer under test; ShortcutStore is Core.Net) - Replace fully-qualified System.Buffers.Binary.BinaryPrimitives. with BinaryPrimitives. in the two new BuildAddShortcut tests (file already has `using System.Buffers.Binary`) - Add `using System;` to ShortcutStore.cs; change System.Array.Clear → Array.Clear (matches sibling file style, no behavior change) Co-Authored-By: Claude Opus 4.8 (1M context) --- src/AcDream.Core.Net/Items/ShortcutStore.cs | 3 ++- .../Items/ShortcutStoreTests.cs | 2 +- .../Messages/InventoryActionsTests.cs | 14 +++++++------- 3 files changed, 10 insertions(+), 9 deletions(-) rename tests/{AcDream.Core.Tests => AcDream.Core.Net.Tests}/Items/ShortcutStoreTests.cs (97%) diff --git a/src/AcDream.Core.Net/Items/ShortcutStore.cs b/src/AcDream.Core.Net/Items/ShortcutStore.cs index bff0b098..a927451d 100644 --- a/src/AcDream.Core.Net/Items/ShortcutStore.cs +++ b/src/AcDream.Core.Net/Items/ShortcutStore.cs @@ -1,3 +1,4 @@ +using System; using System.Collections.Generic; using AcDream.Core.Net.Messages; @@ -19,7 +20,7 @@ public sealed class ShortcutStore /// Replace all slots from the login PlayerDescription shortcut list (item entries only). public void Load(IReadOnlyList entries) { - System.Array.Clear(_objIds); + Array.Clear(_objIds); foreach (var e in entries) if (e.Index < SlotCount && e.ObjectGuid != 0) _objIds[(int)e.Index] = e.ObjectGuid; } diff --git a/tests/AcDream.Core.Tests/Items/ShortcutStoreTests.cs b/tests/AcDream.Core.Net.Tests/Items/ShortcutStoreTests.cs similarity index 97% rename from tests/AcDream.Core.Tests/Items/ShortcutStoreTests.cs rename to tests/AcDream.Core.Net.Tests/Items/ShortcutStoreTests.cs index 2804f64e..97a2b68e 100644 --- a/tests/AcDream.Core.Tests/Items/ShortcutStoreTests.cs +++ b/tests/AcDream.Core.Net.Tests/Items/ShortcutStoreTests.cs @@ -3,7 +3,7 @@ using AcDream.Core.Net.Items; using AcDream.Core.Net.Messages; using Xunit; -namespace AcDream.Core.Tests.Items; +namespace AcDream.Core.Net.Tests.Items; public class ShortcutStoreTests { diff --git a/tests/AcDream.Core.Net.Tests/Messages/InventoryActionsTests.cs b/tests/AcDream.Core.Net.Tests/Messages/InventoryActionsTests.cs index ee71c3a0..298448ae 100644 --- a/tests/AcDream.Core.Net.Tests/Messages/InventoryActionsTests.cs +++ b/tests/AcDream.Core.Net.Tests/Messages/InventoryActionsTests.cs @@ -80,19 +80,19 @@ public sealed class InventoryActionsTests 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 + BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(8))); + Assert.Equal(0u, BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(12))); // index + Assert.Equal(0x3E1u, BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(16))); // objectGuid + Assert.Equal((ushort)0, BinaryPrimitives.ReadUInt16LittleEndian(body.AsSpan(20))); // spellId + Assert.Equal((ushort)0, BinaryPrimitives.ReadUInt16LittleEndian(body.AsSpan(22))); // layer } [Fact] public void BuildAddShortcut_SpellShortcut_PacksSpellAndLayerAsU16s() { byte[] body = InventoryActions.BuildAddShortcut(seq: 1, index: 2, objectGuid: 0, spellId: 0x1234, layer: 3); - Assert.Equal(0x1234, System.Buffers.Binary.BinaryPrimitives.ReadUInt16LittleEndian(body.AsSpan(20))); - Assert.Equal(3, System.Buffers.Binary.BinaryPrimitives.ReadUInt16LittleEndian(body.AsSpan(22))); + Assert.Equal(0x1234, BinaryPrimitives.ReadUInt16LittleEndian(body.AsSpan(20))); + Assert.Equal(3, BinaryPrimitives.ReadUInt16LittleEndian(body.AsSpan(22))); } [Fact] From 41bb70c11a5729a28d4533b99a4db66a9acb4dcb Mon Sep 17 00:00:00 2001 From: Erik Date: Sat, 20 Jun 2026 15:22:27 +0200 Subject: [PATCH 014/133] =?UTF-8?q?feat(ui):=20D.5.3/B.2=20=E2=80=94=20spi?= =?UTF-8?q?ne:=20drag-lift=20hook=20+=20ghost=20snapshot=20(full=20opacity?= =?UTF-8?q?)=20+=20drop-on-hit-only?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 (1M context) --- src/AcDream.App/UI/IItemListDragHandler.cs | 6 +++ .../UI/Layout/ToolbarController.cs | 3 ++ src/AcDream.App/UI/UiItemSlot.cs | 10 ++-- src/AcDream.App/UI/UiRoot.cs | 39 +++++++------- .../UI/DragDropSpineTests.cs | 52 +++++++++++++++++-- 5 files changed, 84 insertions(+), 26 deletions(-) diff --git a/src/AcDream.App/UI/IItemListDragHandler.cs b/src/AcDream.App/UI/IItemListDragHandler.cs index 817effa0..2c8f1f96 100644 --- a/src/AcDream.App/UI/IItemListDragHandler.cs +++ b/src/AcDream.App/UI/IItemListDragHandler.cs @@ -11,6 +11,12 @@ namespace AcDream.App.UI; /// public interface IItemListDragHandler { + /// The drag STARTED from a cell in this list — retail's RecvNotice_ItemListBeginDrag + /// → RemoveShortcut (decomp 0x004bd930/0x004bd450): the handler removes the lifted item from its + /// model + wire so the source slot empties immediately. The item is "in hand" until + /// HandleDropRelease (place) or the drag ends off-target (stays removed). No restore on cancel. + void OnDragLift(UiItemList sourceList, UiItemSlot sourceCell, ItemDragPayload payload); + /// True ⇒ the target cell shows the accept (green) frame; false ⇒ reject (red). bool OnDragOver(UiItemList targetList, UiItemSlot targetCell, ItemDragPayload payload); diff --git a/src/AcDream.App/UI/Layout/ToolbarController.cs b/src/AcDream.App/UI/Layout/ToolbarController.cs index 9cb43471..ef84a558 100644 --- a/src/AcDream.App/UI/Layout/ToolbarController.cs +++ b/src/AcDream.App/UI/Layout/ToolbarController.cs @@ -301,6 +301,9 @@ public sealed class ToolbarController : IItemListDragHandler // The accept/reject gate (IsShortcutEligible) and the AddShortcut/RemoveShortcut // wire are Stream B.2; this stub accepts any real item and LOGS the drop (TS-33). + /// + public void OnDragLift(UiItemList sourceList, UiItemSlot sourceCell, ItemDragPayload payload) { /* Task 3: remove + wire */ } + /// public bool OnDragOver(UiItemList targetList, UiItemSlot targetCell, ItemDragPayload payload) => payload.ObjId != 0; diff --git a/src/AcDream.App/UI/UiItemSlot.cs b/src/AcDream.App/UI/UiItemSlot.cs index bae35fe4..35973c09 100644 --- a/src/AcDream.App/UI/UiItemSlot.cs +++ b/src/AcDream.App/UI/UiItemSlot.cs @@ -159,7 +159,11 @@ public sealed class UiItemSlot : UiElement return true; case UiEventType.DragBegin: - return true; // we're the source; payload already pulled by UiRoot + // Notify the source list's handler so it can lift (remove + wire) — retail + // RecvNotice_ItemListBeginDrag → RemoveShortcut. UiRoot snapshotted the ghost first. + if (FindList() is { DragHandler: { } lh } liftList && e.Payload is ItemDragPayload lp) + lh.OnDragLift(liftList, this, lp); + return true; case UiEventType.DragEnter: // pointer entered me mid-drag → ask the list's handler _dragAccept = (FindList() is { DragHandler: { } h } list @@ -173,9 +177,7 @@ public sealed class UiItemSlot : UiElement 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) + if (FindList() is { DragHandler: { } dh } dl && e.Payload is ItemDragPayload dp) dh.HandleDropRelease(dl, this, dp); return true; } diff --git a/src/AcDream.App/UI/UiRoot.cs b/src/AcDream.App/UI/UiRoot.cs index 267b5688..4bb20f5d 100644 --- a/src/AcDream.App/UI/UiRoot.cs +++ b/src/AcDream.App/UI/UiRoot.cs @@ -71,6 +71,9 @@ public sealed class UiRoot : UiElement /// Current drag source (set between drag-begin and drop/cancel). public UiElement? DragSource { get; private set; } public object? DragPayload { get; private set; } + private (uint tex, int w, int h)? _dragGhost; + /// Snapshotted drag-ghost (tex,w,h), exposed for tests. See BeginDrag. + internal (uint tex, int w, int h)? DragGhostForTest => _dragGhost; private UiElement? _lastDragHoverTarget; private int _pressX, _pressY; private bool _dragCandidate; @@ -145,17 +148,15 @@ public sealed class UiRoot : UiElement ctx.EndOverlayLayer(); } - /// Translucency of the cursor-following drag ghost. AP-47: we reuse the - /// item's full composited icon at this alpha rather than retail's dedicated - /// underlay-less m_pDragIcon. - private const float GhostAlpha = 0.6f; + private const float GhostAlpha = 1.0f; // retail m_dragIcon is the full icon, no fade - /// Paint the drag ghost at the cursor. The texture comes from the source - /// element's so UiRoot stays item-agnostic; the - /// ghost is NOT a tree element, so it never intercepts hit-tests. + /// Paint the drag ghost at the cursor. The texture comes from the snapshotted + /// ghost captured in so UiRoot stays item-agnostic and the ghost + /// survives the source cell emptying on lift; the ghost is NOT a tree element, so it + /// never intercepts hit-tests. private void DrawDragGhost(UiRenderContext ctx) { - if (DragSource?.GetDragGhost() is not { } g || g.tex == 0) return; + 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)); } @@ -476,13 +477,11 @@ public sealed class UiRoot : UiElement 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; + _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); } @@ -515,16 +514,18 @@ public sealed class UiRoot : UiElement private void FinishDrag(int x, int y) { var (t, lx, ly) = HitTestTopDown(x, y); - var target = t ?? DragSource!; - var accepted = t is not null && t != DragSource; - var e = new UiEvent(DragSource!.EventId, target, UiEventType.DropReleased, - Data0: accepted ? 1 : 0, - Data1: (int)lx, Data2: (int)ly, - Payload: DragPayload); - target.OnEvent(in e); - + if (t is not null) + { + // Dropped on a real element — deliver DropReleased; the hit cell's handler places. + // A non-item target's OnEvent ignores it, so an off-bar drop leaves the lift's removal. + var e = new UiEvent(DragSource!.EventId, t, UiEventType.DropReleased, + Data1: (int)lx, Data2: (int)ly, Payload: DragPayload); + t.OnEvent(in e); + } + // else: dropped on nothing — no drop fires; the lift (drag-begin removal) stands (retail). DragSource = null; DragPayload = null; + _dragGhost = null; _lastDragHoverTarget = null; } diff --git a/tests/AcDream.App.Tests/UI/DragDropSpineTests.cs b/tests/AcDream.App.Tests/UI/DragDropSpineTests.cs index 175c6e56..6448256e 100644 --- a/tests/AcDream.App.Tests/UI/DragDropSpineTests.cs +++ b/tests/AcDream.App.Tests/UI/DragDropSpineTests.cs @@ -11,6 +11,9 @@ public class DragDropSpineTests public bool AcceptResult = true; public (UiItemList list, UiItemSlot cell, ItemDragPayload payload)? LastOver; public (UiItemList list, UiItemSlot cell, ItemDragPayload payload)? LastDrop; + public (UiItemList list, UiItemSlot cell, ItemDragPayload payload)? LastLift; + public void OnDragLift(UiItemList list, UiItemSlot cell, ItemDragPayload p) + { LastLift = (list, cell, p); } public bool OnDragOver(UiItemList list, UiItemSlot cell, ItemDragPayload p) { LastOver = (list, cell, p); return AcceptResult; } @@ -125,11 +128,54 @@ public class DragDropSpineTests } [Fact] - public void DropReleased_notAccepted_skipsDispatch() + public void DropReleased_dispatchesToHandler_regardlessOfData0() { - var (_, cell, h) = ListWithHandler(); + // 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.Null(h.LastDrop); + Assert.NotNull(h.LastDrop); + } + + [Fact] + public void DragBegin_callsHandlerOnDragLift() + { + var (list, cell, h) = ListWithHandler(); + var p = SomePayload(); + cell.OnEvent(new UiEvent(0u, cell, UiEventType.DragBegin, Payload: p)); + Assert.NotNull(h.LastLift); + Assert.Same(list, h.LastLift!.Value.list); + Assert.Same(cell, h.LastLift.Value.cell); + Assert.Same(p, h.LastLift.Value.payload); + } + + [Fact] + public void Ghost_isSnapshottedAtBeginDrag_survivesSourceCellClearing() + { + var (root, _, cell) = RootWithBoundSlot(0x5001u); // icon tex 0x99 + root.OnMouseDown(UiMouseButton.Left, 10, 10); + root.OnMouseMove(20, 10); // BeginDrag → snapshot ghost + cell.Clear(); // simulate the lift emptying the source + Assert.Equal((0x99u, 32, 32), root.DragGhostForTest); + } + + [Fact] + public void FinishDrag_overNothing_deliversNoDrop_butLiftStands() + { + var root = new UiRoot { Width = 800, Height = 600 }; + var list = new UiItemList(_ => (1u, 1, 1)) { Left = 0, Top = 0, Width = 32, Height = 32 }; + list.Cell.Width = 32; list.Cell.Height = 32; + list.Cell.SetItem(0x5001u, 0x99u); + var h = new SpyHandler(); + list.RegisterDragHandler(h); + root.AddChild(list); + + root.OnMouseDown(UiMouseButton.Left, 10, 10); + root.OnMouseMove(20, 10); // BeginDrag → OnDragLift + root.OnMouseUp(UiMouseButton.Left, 600, 500); // release over empty space + Assert.NotNull(h.LastLift); // lift happened + Assert.Null(h.LastDrop); // no drop dispatched (off-bar) + Assert.Null(root.DragSource); // cleaned up } // ── Full UiRoot chain: arming + use-vs-drag ───────────────────────────── From 250f7827be5293e62f7a5dc17a2856c30f44bf04 Mon Sep 17 00:00:00 2001 From: Erik Date: Sat, 20 Jun 2026 15:33:23 +0200 Subject: [PATCH 015/133] =?UTF-8?q?feat(ui):=20D.5.3/B.2=20=E2=80=94=20too?= =?UTF-8?q?lbar=20reorder/remove=20via=20ShortcutStore=20+=20wire=20+=20gr?= =?UTF-8?q?een-cross=20overlay?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ToolbarController is now the LIVE drag handler: - OnDragLift: removes the source slot from ShortcutStore, sends RemoveShortcut (0x019D) wire immediately (retail remove-on-lift model). - HandleDropRelease: evicts occupant from target (RemoveShortcut), places dragged item (AddShortcut 0x019C), bumps evicted item into the vacated source slot if empty (swap path); off-bar release leaves the lift's removal standing. - Populate() now lazy-loads ShortcutStore from the PD shortcut list on first call; store is authoritative thereafter. - IsShortcutGuid() uses the store (O(18)) when loaded, falls back to scanning _shortcuts() in the pre-PD window. - All 18 slot cells now get DragAcceptSprite=0x060011FA (green cross, distinct from the inventory ring 0x060011F9). - GameWindow.Bind wired: sendAddShortcut + sendRemoveShortcut lambdas forwarded to _liveSession?.Send*(). - Divergence register: AP-47 Divergence+Risk cells updated (opacity corrected, underlay-backing framing improved). TS-33 row deleted (stopgap retired). Section header 32→31 rows. - Tests: HandleDropRelease_isInertStub removed; 5 new B.2 tests added (lift removes + sends, swap sequence, empty-target place, self-drop re-adds, green-cross sprite). 24/24 ToolbarController tests pass. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../retail-divergence-register.md | 5 +- src/AcDream.App/Rendering/GameWindow.cs | 4 +- .../UI/Layout/ToolbarController.cs | 89 +++++++++---- .../UI/Layout/ToolbarControllerTests.cs | 119 ++++++++++++++++-- 4 files changed, 179 insertions(+), 38 deletions(-) diff --git a/docs/architecture/retail-divergence-register.md b/docs/architecture/retail-divergence-register.md index 195f7046..c8e37e75 100644 --- a/docs/architecture/retail-divergence-register.md +++ b/docs/architecture/retail-divergence-register.md @@ -146,11 +146,11 @@ accepted-divergence entries (#96, #49, #50). | AP-42 | `UiMenu` item model is flat (label + opaque payload, single-level popup); retail `UIElement_Menu::MakePopup @0x46d310` supports hierarchical nested submenus via recursive popup chain | `src/AcDream.App/UI/UiMenu.cs` | The chat talk-focus menu is single-level (14 rows, 2 columns, no submenu); hierarchy is latent and unreachable through the chat window — no behavioral difference in the current usage | A future menu with nested submenus would render flat (only the top-level items drawn, no drill-down) | `UIElement_Menu::MakePopup` @0x46d310 | | AP-45 | `PublicUpdatePropertyInt (0x02CE)` sequence byte parsed-past but not honored; last update wins (no freshness check against sequence number) | `src/AcDream.Core.Net/Messages/PublicUpdatePropertyInt.cs` | Loopback ACE rarely reorders; latest-wins matches `PrivateUpdateVital`/`UpdatePosition`'s existing non-sequence behavior. Sequence tracking added when needed alongside TS-26. | A reordered 0x02CE on a real network could apply a stale UiEffects value — item icon temporarily shows the wrong effect state, corrected on next update | `PublicUpdatePropertyInt` sequence byte (ACE GameMessagePublicUpdatePropertyInt) | | AP-46 | Health-meter gate approximation: retail shows the health meter for `IsPlayer() || pet_owner || ClientCombatSystem::ObjectIsAttackable()` (full PK/faction logic); acdream's `GameWindow.IsHealthBarTarget` uses the server PWD bits `BF_ATTACKABLE (0x10)` OR `BF_PLAYER (0x8)` | `src/AcDream.App/Rendering/GameWindow.cs` (`IsHealthBarTarget`) → `SelectedObjectController` | The PWD `BF_ATTACKABLE`/`BF_PLAYER` bits distinguish monsters + players (bar) from friendly/vendor NPCs (name-only) for the M1.5 dev loop; the pet case and the full ObjectIsAttackable PK/faction refinement (free-PK, PK-vs-PK, PKLite) are not ported | A PK/faction edge (e.g. a hostile-flagged player whose `BF_ATTACKABLE` is unset, or a pet) could show/hide the bar where retail differs — no impact on the non-PK PvE dev loop | `ClientCombatSystem::ObjectIsAttackable` acclient_2013_pseudo_c.txt:375385; `BF_ATTACKABLE` acclient.h:6437 | -| AP-47 | Cursor drag ghost reuses the full composited `m_pIcon` 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 | +| AP-47 | Cursor drag ghost reuses the full composited `m_pIcon` (incl. type-default underlay) instead of retail's dedicated `m_pDragIcon` (base + custom-overlay, NO type-default underlay). Opacity now matches retail (full). | `src/AcDream.App/UI/UiRoot.cs` (`DrawDragGhost`/`GhostAlpha`) → `src/AcDream.App/UI/UiItemSlot.cs` (`GetDragGhost`) | Cosmetic only — the dragged item is still unambiguously identifiable; building the second underlay-less composite is deferred polish; the ghost is item-agnostic in UiRoot via `GetDragGhost()` | The ghost carries the opaque type-default underlay backing rather than retail's underlay-less copy — a subtle look difference while dragging, no functional effect. | `IconData::RenderIcons` acclient_2013_pseudo_c.txt:407594-407625 (m_pDragIcon path); deep-dive §3.2/§5.5 | --- -## 4. Temporary stopgap (TS) — 32 rows +## 4. Temporary stopgap (TS) — 31 rows | # | Divergence | Where (file:line) | Why it is safe / justified | Risk if assumption breaks | Retail oracle | |---|---|---|---|---|---| @@ -186,7 +186,6 @@ accepted-divergence entries (#96, #49, #50). | TS-30 | Numbered chat tabs (element ids `0x10000522`–`0x10000525`) render as clickable buttons but do not switch channel filter or affect the transcript — tab state is a no-op | `src/AcDream.App/UI/Layout/ChatWindowController.cs:210` | Retail's tab switching routes transcript lines by chat channel (`gmMainChatUI::gmScrollWindow` sub-windows per tab); the tab wiring is D.5 scope | Tab clicks produce no visible transcript change; retail would filter to the selected channel — all chat always shows in all tabs | `gmMainChatUI::PostInit` tab setup @0x4ce2a0; holtburger chat tab handling | | TS-31 | Squelch toggle absent (no `/squelch` slash command, no clickable name-tags to silence); retail's squelch list filters incoming chat lines | `src/AcDream.Core/Chat/ChatLog.cs` | Squelch is a social / moderation feature deferred to post-M1.5; the data structure (`ChatLog`) has no squelch set today | Any player can spam all clients; clickable-name-tag contextual menu (used in retail to squelch, tell, add-to-friends) is absent | `ChatFilter::IsSquelched`; retail right-click player name → Squelch menu | | TS-32 | `ClientObjectTable` has no pre-queue for a child `CreateObject` that arrives before its parent (out-of-order PARENTED create); such objects are ingested as root objects and their `ContainerId` links a not-yet-known container. Retail's `null_object_table` + `null_weenie_object_table` hold unresolvable objects until the parent arrives | `src/AcDream.Core/Items/ClientObjectTable.cs` (`Ingest`) | PD↔`CreateObject` ordering is handled (upsert semantics); out-of-order PARENTED creates are observed only at high packet loss or in vendor/corpse multi-object bursts on non-loopback links; deferred to D.5.5+ | A container's child object arriving before the container is ingested as a root item — it won't appear in `GetContents` until the next `RecordMembership` or a move event corrects the parent link | `CObjectMaint::null_object_table` / `null_weenie_object_table` (acclient.h / named-retail pc) | -| 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 | --- diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs index 95191d49..197bdd07 100644 --- a/src/AcDream.App/Rendering/GameWindow.cs +++ b/src/AcDream.App/Rendering/GameWindow.cs @@ -2018,7 +2018,9 @@ public sealed class GameWindow : IDisposable combatState: Combat, peaceDigits: toolbarPeaceDigits, warDigits: toolbarWarDigits, - emptyDigits: toolbarEmptyDigits); + emptyDigits: toolbarEmptyDigits, + sendAddShortcut: (i, g) => _liveSession?.SendAddShortcut(i, g), + sendRemoveShortcut: i => _liveSession?.SendRemoveShortcut(i)); // Phase D.5.3a — selected-object strip (name, overlay state, health meter). // Analogue of retail gmToolbarUI::HandleSelectionChanged diff --git a/src/AcDream.App/UI/Layout/ToolbarController.cs b/src/AcDream.App/UI/Layout/ToolbarController.cs index ef84a558..7143e1b1 100644 --- a/src/AcDream.App/UI/Layout/ToolbarController.cs +++ b/src/AcDream.App/UI/Layout/ToolbarController.cs @@ -60,6 +60,10 @@ public sealed class ToolbarController : IItemListDragHandler private readonly Func> _shortcuts; private readonly Func _iconIds; // (itemType, icon, underlay, overlay, effects) → GL tex private readonly Action _useItem; // guid → fire UseObject + private readonly AcDream.Core.Net.Items.ShortcutStore _store = new(); + private bool _storeLoaded; + private readonly Action? _sendAddShortcut; // (index, objectGuid) + private readonly Action? _sendRemoveShortcut; // (index) // Digit sprite DID arrays for slot labels (top row, numbers 1-9). // Read from LayoutDesc 0x21000037, element 0x1000034A under composite 0x10000346. @@ -82,7 +86,9 @@ public sealed class ToolbarController : IItemListDragHandler CombatState? combatState, uint[]? peaceDigits, uint[]? warDigits, - uint[]? emptyDigits) + uint[]? emptyDigits, + Action? sendAddShortcut = null, + Action? sendRemoveShortcut = null) { _repo = repo; _shortcuts = shortcuts; @@ -91,6 +97,8 @@ public sealed class ToolbarController : IItemListDragHandler _peaceDigits = peaceDigits; _warDigits = warDigits; _emptyDigits = emptyDigits; + _sendAddShortcut = sendAddShortcut; + _sendRemoveShortcut = sendRemoveShortcut; for (int i = 0; i < SlotIds.Length; i++) { @@ -104,6 +112,7 @@ public sealed class ToolbarController : IItemListDragHandler list.RegisterDragHandler(this); list.Cell.SlotIndex = i; list.Cell.SourceKind = ItemDragSource.ShortcutBar; + list.Cell.DragAcceptSprite = 0x060011FAu; // green cross (toolbar), not the ring 0x060011F9 (inventory) } } @@ -133,11 +142,20 @@ public sealed class ToolbarController : IItemListDragHandler } /// - /// Returns true if is one of the currently-active shortcut guids. + /// Returns true if is one of the currently-active shortcut guids + /// (i.e., present in the live ). /// Used to gate repo-event subscriptions so we don't re-populate on every creature spawn. + /// Falls back to scanning _shortcuts() before the store is loaded (pre-PD window). /// private bool IsShortcutGuid(uint guid) { + if (_storeLoaded) + { + for (int s = 0; s < AcDream.Core.Net.Items.ShortcutStore.SlotCount; s++) + if (_store.Get(s) == guid) return true; + return false; + } + // Store not yet loaded — fall back to the shortcuts provider (pre-PD window). foreach (var sc in _shortcuts()) if (sc.ObjectGuid == guid) return true; return false; @@ -182,10 +200,13 @@ public sealed class ToolbarController : IItemListDragHandler CombatState? combatState = null, uint[]? peaceDigits = null, uint[]? warDigits = null, - uint[]? emptyDigits = null) + uint[]? emptyDigits = null, + Action? sendAddShortcut = null, + Action? sendRemoveShortcut = null) { var c = new ToolbarController(layout, repo, shortcuts, iconIds, useItem, combatState, - peaceDigits, warDigits, emptyDigits); + peaceDigits, warDigits, emptyDigits, + sendAddShortcut, sendRemoveShortcut); c.Populate(); return c; } @@ -196,24 +217,27 @@ public sealed class ToolbarController : IItemListDragHandler /// Entries whose item is not yet in the repo are silently skipped here; the /// ObjectAdded event re-fires this method when the item arrives /// (matching retail's SetDelayedShortcutNum deferred-rebind path). + /// As of B.2: the is the + /// authoritative in-session slot map; _shortcuts() seeds it on first call. /// public void Populate() { - // Clear all slot cells first (flush). + // Lazy-load the store from the login shortcut list the first time it's present (PD arrives + // after Bind); thereafter the store is authoritative and drag ops mutate it directly. + if (!_storeLoaded && _shortcuts().Count > 0) { _store.Load(_shortcuts()); _storeLoaded = true; } + foreach (var list in _slots) list?.Cell.Clear(); - foreach (var sc in _shortcuts()) + for (int slot = 0; slot < _slots.Length; slot++) { - if (sc.ObjectGuid == 0) continue; // spell-only shortcut — inventory phase - if (sc.Index >= (uint)_slots.Length) continue; - var list = _slots[(int)sc.Index]; + uint guid = _store.Get(slot); + if (guid == 0) continue; + var list = _slots[slot]; if (list is null) continue; - - var item = _repo.Get(sc.ObjectGuid); - if (item is null) continue; // deferred: ObjectAdded will re-call Populate - + var item = _repo.Get(guid); + if (item is null) continue; // deferred: ObjectAdded re-calls Populate uint tex = _iconIds(item.Type, item.IconId, item.IconUnderlayId, item.IconOverlayId, item.Effects); - list.Cell.SetItem(sc.ObjectGuid, tex); + list.Cell.SetItem(guid, tex); } // Re-stamp slot number labels after any item change. @@ -296,13 +320,24 @@ public sealed class ToolbarController : IItemListDragHandler }; } - // ── IItemListDragHandler (B.1 spine) ───────────────────────────────────── + // ── IItemListDragHandler (B.2 live handler) ────────────────────────────── // 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). + // Retail model (remove-on-lift / place-on-drop / no-restore): + // lift → RemoveShortcut (0x019D) + store.Remove (slot empties immediately) + // drop → AddShortcut (0x019C) + optional swap of evicted item into source + // off-bar release → the lift's removal stands (no restore) + // Retail ref: gmToolbarUI::HandleDropRelease acclient_2013_pseudo_c.txt:197971 /// - public void OnDragLift(UiItemList sourceList, UiItemSlot sourceCell, ItemDragPayload payload) { /* Task 3: remove + wire */ } + public void OnDragLift(UiItemList sourceList, UiItemSlot sourceCell, ItemDragPayload payload) + { + // Retail RecvNotice_ItemListBeginDrag → RemoveShortcut (0x004bd930/0x004bd450): the lifted + // shortcut leaves the bar (+ wire) the instant the drag starts; it's re-placed only on a + // drop onto a slot. Off-bar release leaves it removed. + _store.Remove(payload.SourceSlot); + _sendRemoveShortcut?.Invoke((uint)payload.SourceSlot); + Populate(); + } /// public bool OnDragOver(UiItemList targetList, UiItemSlot targetCell, ItemDragPayload payload) @@ -311,9 +346,19 @@ public sealed class ToolbarController : IItemListDragHandler /// 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}"); + // Retail gmToolbarUI::HandleDropRelease (0x004be7c0) within-bar reorder branch (deep-dive §5): + // evict the target's occupant, place the dragged item there, and bump the evicted item into + // the (now-vacated) source slot if it's free. + int target = targetCell.SlotIndex; + uint evicted = _store.Get(target); // RemoveShortcutInSlotNum(target) + if (evicted != 0) { _store.Remove(target); _sendRemoveShortcut?.Invoke((uint)target); } + _store.Set(target, payload.ObjId); // AddShortcut(dragged, target) + _sendAddShortcut?.Invoke((uint)target, payload.ObjId); + if (evicted != 0 && evicted != payload.ObjId && _store.IsEmpty(payload.SourceSlot)) + { // displaced → vacated source slot + _store.Set(payload.SourceSlot, evicted); + _sendAddShortcut?.Invoke((uint)payload.SourceSlot, evicted); + } + Populate(); } } diff --git a/tests/AcDream.App.Tests/UI/Layout/ToolbarControllerTests.cs b/tests/AcDream.App.Tests/UI/Layout/ToolbarControllerTests.cs index 7401eb93..c3e84dd4 100644 --- a/tests/AcDream.App.Tests/UI/Layout/ToolbarControllerTests.cs +++ b/tests/AcDream.App.Tests/UI/Layout/ToolbarControllerTests.cs @@ -480,21 +480,116 @@ public class ToolbarControllerTests Assert.False(ctrl.OnDragOver(list, list.Cell, payload)); } - /// HandleDropRelease is a logging stub (TS-33): it must not throw and must not - /// mutate the target slot (no wire / no store yet). + // ── B.2: live drag handler (store + reorder/remove + wire) ─────────────── + private static (System.Collections.Generic.List<(uint i,uint g)> adds, + System.Collections.Generic.List removes) NewSpies(out System.Action add, out System.Action rem) + { + var adds = new System.Collections.Generic.List<(uint,uint)>(); + var removes = new System.Collections.Generic.List(); + add = (i, g) => adds.Add((i, g)); + rem = i => removes.Add(i); + return (adds, removes); + } + [Fact] - public void HandleDropRelease_isInertStub() + public void OnDragLift_removesSourceSlot_sendsRemove_emptiesCell() { var (layout, slots, _) = FakeToolbar(); - var ctrl = ToolbarController.Bind(layout, new ClientObjectTable(), - () => Array.Empty(), - iconIds: (_,_,_,_,_) => 0u, useItem: _ => { }); + var repo = new ClientObjectTable(); + repo.AddOrUpdate(new ClientObject { ObjectId = 0x5001u, WeenieClassId = 1u, IconId = 0x06001234u }); + var shortcuts = new System.Collections.Generic.List + { new(Index: 3, ObjectGuid: 0x5001u, SpellId: 0, Layer: 0) }; + var (adds, removes) = NewSpies(out var add, out var rem); - var list = slots[Row1[2]]; - list.Cell.SetItem(0x5001u, 0x77u); // populate so "unchanged" is a meaningful assertion - 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(0x5001u, list.Cell.ItemId); // unchanged — inert stub did not clear/mutate + var ctrl = ToolbarController.Bind(layout, repo, () => shortcuts, + iconIds: (_,_,_,_,_) => 0x77u, useItem: _ => { }, + sendAddShortcut: add, sendRemoveShortcut: rem); + Assert.Equal(0x5001u, slots[Row1[3]].Cell.ItemId); + + var payload = new ItemDragPayload(0x5001u, ItemDragSource.ShortcutBar, 3, slots[Row1[3]].Cell); + ctrl.OnDragLift(slots[Row1[3]], slots[Row1[3]].Cell, payload); + + Assert.Contains(3u, removes); + Assert.Equal(0u, slots[Row1[3]].Cell.ItemId); + } + + [Fact] + public void HandleDropRelease_ontoOccupied_swaps_andSendsRetailSequence() + { + var (layout, slots, _) = FakeToolbar(); + var repo = new ClientObjectTable(); + repo.AddOrUpdate(new ClientObject { ObjectId = 0x5001u, WeenieClassId = 1u, IconId = 0x06001234u }); // A + repo.AddOrUpdate(new ClientObject { ObjectId = 0x5002u, WeenieClassId = 1u, IconId = 0x06005678u }); // B + var shortcuts = new System.Collections.Generic.List + { new(Index: 3, ObjectGuid: 0x5001u, SpellId: 0, Layer: 0), + new(Index: 5, ObjectGuid: 0x5002u, SpellId: 0, Layer: 0) }; + var (adds, removes) = NewSpies(out var add, out var rem); + var ctrl = ToolbarController.Bind(layout, repo, () => shortcuts, + iconIds: (_,_,_,_,_) => 0x77u, useItem: _ => { }, + sendAddShortcut: add, sendRemoveShortcut: rem); + + var payload = new ItemDragPayload(0x5001u, ItemDragSource.ShortcutBar, 3, slots[Row1[3]].Cell); + ctrl.OnDragLift(slots[Row1[3]], slots[Row1[3]].Cell, payload); + ctrl.HandleDropRelease(slots[Row1[5]], slots[Row1[5]].Cell, payload); + + Assert.Equal(0x5001u, slots[Row1[5]].Cell.ItemId); + Assert.Equal(0x5002u, slots[Row1[3]].Cell.ItemId); + Assert.Equal(new[] { 3u, 5u }, removes.ToArray()); + Assert.Contains((5u, 0x5001u), adds); + Assert.Contains((3u, 0x5002u), adds); + } + + [Fact] + public void HandleDropRelease_ontoEmpty_placesOnly_sourceStaysEmpty() + { + var (layout, slots, _) = FakeToolbar(); + var repo = new ClientObjectTable(); + repo.AddOrUpdate(new ClientObject { ObjectId = 0x5001u, WeenieClassId = 1u, IconId = 0x06001234u }); + var shortcuts = new System.Collections.Generic.List + { new(Index: 3, ObjectGuid: 0x5001u, SpellId: 0, Layer: 0) }; + var (adds, removes) = NewSpies(out var add, out var rem); + var ctrl = ToolbarController.Bind(layout, repo, () => shortcuts, + iconIds: (_,_,_,_,_) => 0x77u, useItem: _ => { }, + sendAddShortcut: add, sendRemoveShortcut: rem); + + var payload = new ItemDragPayload(0x5001u, ItemDragSource.ShortcutBar, 3, slots[Row1[3]].Cell); + ctrl.OnDragLift(slots[Row1[3]], slots[Row1[3]].Cell, payload); + ctrl.HandleDropRelease(slots[Row1[7]], slots[Row1[7]].Cell, payload); + + Assert.Equal(0x5001u, slots[Row1[7]].Cell.ItemId); + Assert.Equal(0u, slots[Row1[3]].Cell.ItemId); + Assert.Contains((7u, 0x5001u), adds); + Assert.DoesNotContain(adds, a => a.i == 3u); + } + + [Fact] + public void HandleDropRelease_ontoSelf_reAddsToSource() + { + var (layout, slots, _) = FakeToolbar(); + var repo = new ClientObjectTable(); + repo.AddOrUpdate(new ClientObject { ObjectId = 0x5001u, WeenieClassId = 1u, IconId = 0x06001234u }); + var shortcuts = new System.Collections.Generic.List + { new(Index: 3, ObjectGuid: 0x5001u, SpellId: 0, Layer: 0) }; + var (adds, removes) = NewSpies(out var add, out var rem); + var ctrl = ToolbarController.Bind(layout, repo, () => shortcuts, + iconIds: (_,_,_,_,_) => 0x77u, useItem: _ => { }, + sendAddShortcut: add, sendRemoveShortcut: rem); + + var payload = new ItemDragPayload(0x5001u, ItemDragSource.ShortcutBar, 3, slots[Row1[3]].Cell); + ctrl.OnDragLift(slots[Row1[3]], slots[Row1[3]].Cell, payload); // slot 3 emptied + ctrl.HandleDropRelease(slots[Row1[3]], slots[Row1[3]].Cell, payload); // drop back on slot 3 + + Assert.Equal(0x5001u, slots[Row1[3]].Cell.ItemId); // re-added to source (net no-op) + Assert.Contains((3u, 0x5001u), adds); // AddShortcut(3, A) sent + } + + [Fact] + public void ToolbarSlots_useGreenCrossAcceptSprite() + { + var (layout, slots, _) = FakeToolbar(); + ToolbarController.Bind(layout, new ClientObjectTable(), + () => System.Array.Empty(), + iconIds: (_,_,_,_,_) => 0u, useItem: _ => { }); + Assert.Equal(0x060011FAu, slots[Row1[0]].Cell.DragAcceptSprite); // green cross, not the ring F9 } } From ff3592ec25a0452ac6cee6fc7cf2702bfecc690a Mon Sep 17 00:00:00 2001 From: Erik Date: Sat, 20 Jun 2026 15:50:33 +0200 Subject: [PATCH 016/133] =?UTF-8?q?refactor(core):=20D.5.3/B.2=20=E2=80=94?= =?UTF-8?q?=20move=20ShortcutStore=20to=20Core.Items=20(decouple=20Load=20?= =?UTF-8?q?from=20the=20wire=20type)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Matches the ClientObjectTable model-placement convention; Load now takes (slot,objGuid) pairs so the store has no Core.Net dependency. + self-drop wire-count assert + comment fix. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../UI/Layout/ToolbarController.cs | 16 ++++++++------ .../Items/ShortcutStore.cs | 21 ++++++++++--------- .../UI/Layout/ToolbarControllerTests.cs | 2 ++ .../Items/ShortcutStoreTests.cs | 21 ++++++++----------- 4 files changed, 32 insertions(+), 28 deletions(-) rename src/{AcDream.Core.Net => AcDream.Core}/Items/ShortcutStore.cs (50%) rename tests/{AcDream.Core.Net.Tests => AcDream.Core.Tests}/Items/ShortcutStoreTests.cs (53%) diff --git a/src/AcDream.App/UI/Layout/ToolbarController.cs b/src/AcDream.App/UI/Layout/ToolbarController.cs index 7143e1b1..e4fd00c4 100644 --- a/src/AcDream.App/UI/Layout/ToolbarController.cs +++ b/src/AcDream.App/UI/Layout/ToolbarController.cs @@ -60,7 +60,7 @@ public sealed class ToolbarController : IItemListDragHandler private readonly Func> _shortcuts; private readonly Func _iconIds; // (itemType, icon, underlay, overlay, effects) → GL tex private readonly Action _useItem; // guid → fire UseObject - private readonly AcDream.Core.Net.Items.ShortcutStore _store = new(); + private readonly AcDream.Core.Items.ShortcutStore _store = new(); private bool _storeLoaded; private readonly Action? _sendAddShortcut; // (index, objectGuid) private readonly Action? _sendRemoveShortcut; // (index) @@ -143,7 +143,7 @@ public sealed class ToolbarController : IItemListDragHandler /// /// Returns true if is one of the currently-active shortcut guids - /// (i.e., present in the live ). + /// (i.e., present in the live ). /// Used to gate repo-event subscriptions so we don't re-populate on every creature spawn. /// Falls back to scanning _shortcuts() before the store is loaded (pre-PD window). /// @@ -151,7 +151,7 @@ public sealed class ToolbarController : IItemListDragHandler { if (_storeLoaded) { - for (int s = 0; s < AcDream.Core.Net.Items.ShortcutStore.SlotCount; s++) + for (int s = 0; s < AcDream.Core.Items.ShortcutStore.SlotCount; s++) if (_store.Get(s) == guid) return true; return false; } @@ -217,14 +217,18 @@ public sealed class ToolbarController : IItemListDragHandler /// Entries whose item is not yet in the repo are silently skipped here; the /// ObjectAdded event re-fires this method when the item arrives /// (matching retail's SetDelayedShortcutNum deferred-rebind path). - /// As of B.2: the is the + /// As of B.2: the is the /// authoritative in-session slot map; _shortcuts() seeds it on first call. /// 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; } + if (!_storeLoaded && _shortcuts().Count > 0) + { + _store.Load(System.Linq.Enumerable.Select(_shortcuts(), e => ((int)e.Index, e.ObjectGuid))); + _storeLoaded = true; + } foreach (var list in _slots) list?.Cell.Clear(); @@ -350,7 +354,7 @@ public sealed class ToolbarController : IItemListDragHandler // 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) + 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); diff --git a/src/AcDream.Core.Net/Items/ShortcutStore.cs b/src/AcDream.Core/Items/ShortcutStore.cs similarity index 50% rename from src/AcDream.Core.Net/Items/ShortcutStore.cs rename to src/AcDream.Core/Items/ShortcutStore.cs index a927451d..0f581a2d 100644 --- a/src/AcDream.Core.Net/Items/ShortcutStore.cs +++ b/src/AcDream.Core/Items/ShortcutStore.cs @@ -1,28 +1,29 @@ using System; using System.Collections.Generic; -using AcDream.Core.Net.Messages; -namespace AcDream.Core.Net.Items; +namespace AcDream.Core.Items; /// /// Mutable client-side model of the 18 toolbar shortcut slots — port of retail /// ShortCutManager::shortCuts_[18] (acclient.h:36492). Holds the bound object guid per -/// slot (0 = empty). Loaded from the login PlayerDescription, then mutated by drag-drop -/// (lift removes, drop places); the server is notified via AddShortcut/RemoveShortcut so the -/// store stays the client's source of truth within a session (retail: client owns the array). -/// Item shortcuts only — spell shortcuts (ObjectGuid 0) are skipped on Load. +/// slot (0 = empty). Loaded from the login shortcut list, then mutated by drag-drop (lift +/// removes, drop places); the server is notified via AddShortcut/RemoveShortcut so the store +/// stays the client's source of truth within a session (retail: client owns the array). +/// Item shortcuts only — entries with ObjGuid 0 (spell-only) are skipped on Load. Pure model +/// (no Core.Net dependency): callers project their wire entries to (slot, objGuid) pairs. /// public sealed class ShortcutStore { public const int SlotCount = 18; private readonly uint[] _objIds = new uint[SlotCount]; - /// Replace all slots from the login PlayerDescription shortcut list (item entries only). - public void Load(IReadOnlyList entries) + /// Replace all slots from a (slot, objectGuid) sequence (item entries only; + /// ObjGuid 0 and out-of-range slots are skipped). + public void Load(IEnumerable<(int Slot, uint ObjGuid)> entries) { Array.Clear(_objIds); - foreach (var e in entries) - if (e.Index < SlotCount && e.ObjectGuid != 0) _objIds[(int)e.Index] = e.ObjectGuid; + foreach (var (slot, objGuid) in entries) + if ((uint)slot < SlotCount && objGuid != 0) _objIds[slot] = objGuid; } /// Bound object guid at , or 0 (empty / out of range). diff --git a/tests/AcDream.App.Tests/UI/Layout/ToolbarControllerTests.cs b/tests/AcDream.App.Tests/UI/Layout/ToolbarControllerTests.cs index c3e84dd4..6f590738 100644 --- a/tests/AcDream.App.Tests/UI/Layout/ToolbarControllerTests.cs +++ b/tests/AcDream.App.Tests/UI/Layout/ToolbarControllerTests.cs @@ -581,6 +581,8 @@ public class ToolbarControllerTests Assert.Equal(0x5001u, slots[Row1[3]].Cell.ItemId); // re-added to source (net no-op) Assert.Contains((3u, 0x5001u), adds); // AddShortcut(3, A) sent + Assert.Single(removes); // exactly RemoveShortcut(3) [from the lift] + Assert.Single(adds); // exactly AddShortcut(3, A) [the re-place] } [Fact] diff --git a/tests/AcDream.Core.Net.Tests/Items/ShortcutStoreTests.cs b/tests/AcDream.Core.Tests/Items/ShortcutStoreTests.cs similarity index 53% rename from tests/AcDream.Core.Net.Tests/Items/ShortcutStoreTests.cs rename to tests/AcDream.Core.Tests/Items/ShortcutStoreTests.cs index 97a2b68e..a58dabd9 100644 --- a/tests/AcDream.Core.Net.Tests/Items/ShortcutStoreTests.cs +++ b/tests/AcDream.Core.Tests/Items/ShortcutStoreTests.cs @@ -1,24 +1,21 @@ -using System.Collections.Generic; -using AcDream.Core.Net.Items; -using AcDream.Core.Net.Messages; +using AcDream.Core.Items; using Xunit; -namespace AcDream.Core.Net.Tests.Items; +namespace AcDream.Core.Tests.Items; public class ShortcutStoreTests { [Fact] - public void Load_mapsIndexToObjId_skipsEmptyAndOutOfRange() + public void Load_mapsSlotToObjId_skipsEmptyAndOutOfRange() { var store = new ShortcutStore(); - var entries = new List + store.Load(new (int, uint)[] { - 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); + (0, 0x5001u), + (3, 0x5002u), + (5, 0u), // empty/spell → skipped + (99, 0x5003u), // out of range → skipped + }); Assert.Equal(0x5001u, store.Get(0)); Assert.Equal(0x5002u, store.Get(3)); Assert.Equal(0u, store.Get(5)); From 049a099123f02d4e38fbd9a4301af3849532f790 Mon Sep 17 00:00:00 2001 From: Erik Date: Sat, 20 Jun 2026 15:59:14 +0200 Subject: [PATCH 017/133] =?UTF-8?q?docs(register):=20correct=20TS=20live-r?= =?UTF-8?q?ow=20count=20(31=20=E2=86=92=2032,=20pre-existing=20undercount)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The final B.2 review found the TS header undercounted: there are 32 contiguous rows (TS-1..TS-32) but the header read "31". The base already undercounted (said "32" with 33 rows TS-1..TS-33); deleting TS-33 with a literal 32→31 decrement preserved the off-by-one. Set the header to the verified actual count. Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/architecture/retail-divergence-register.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/architecture/retail-divergence-register.md b/docs/architecture/retail-divergence-register.md index c8e37e75..a78ef507 100644 --- a/docs/architecture/retail-divergence-register.md +++ b/docs/architecture/retail-divergence-register.md @@ -150,7 +150,7 @@ accepted-divergence entries (#96, #49, #50). --- -## 4. Temporary stopgap (TS) — 31 rows +## 4. Temporary stopgap (TS) — 32 rows | # | Divergence | Where (file:line) | Why it is safe / justified | Risk if assumption breaks | Retail oracle | |---|---|---|---|---|---| From e58be3f03052587efa8e134962c4ce03f647f3a8 Mon Sep 17 00:00:00 2001 From: Erik Date: Sat, 20 Jun 2026 18:25:46 +0200 Subject: [PATCH 018/133] docs(D.2b): toolbar collapse-to-one-row design spec MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit User request: the toolbar frame resizes vertically between one row (row 2 hidden, minimum) and two rows (shown), SNAPPING between the two stops; default expanded. Small toolkit feature: UiElement.MaxHeight + a ResizableEdges mask (bottom-edge-only) + a UiCollapsibleFrame (snaps height to the nearer stop and ties row-2 visibility to it in OnTick) + the GameWindow mount (compute the two heights from the layout, top-anchor the content so row 1 never reflows). Retail's real mechanism is keystone.dll (no decomp) + the dat stacks both rows always — so this is a toolkit UX from the user's retail observation; amends IA-17. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../2026-06-20-d2b-toolbar-collapse-design.md | 181 ++++++++++++++++++ 1 file changed, 181 insertions(+) create mode 100644 docs/superpowers/specs/2026-06-20-d2b-toolbar-collapse-design.md diff --git a/docs/superpowers/specs/2026-06-20-d2b-toolbar-collapse-design.md b/docs/superpowers/specs/2026-06-20-d2b-toolbar-collapse-design.md new file mode 100644 index 00000000..92cd7f25 --- /dev/null +++ b/docs/superpowers/specs/2026-06-20-d2b-toolbar-collapse-design.md @@ -0,0 +1,181 @@ +# D.2b toolbar collapse-to-one-row — design + +**Date:** 2026-06-20 +**Phase:** D.2b retail-UI, toolbar polish (follows D.5.3 B.1/B.2, both visually confirmed this session). +**Branch:** `claude/hopeful-maxwell-214a12`. +**Driver:** user request — the toolbar frame should resize vertically between **one row** (row 2 hidden, +the minimum) and **two rows** (row 2 shown), **snapping** between the two stops. + +--- + +## 1. Goal & non-goals + +**Goal.** The toolbar window can be collapsed to show only the top quickslot row (slots 1–9) or +expanded to show both rows (slots 1–18), by dragging its **bottom edge**. The drag **snaps** to the +nearer of two height stops — collapsed (row 2 hidden) or expanded (row 2 shown). Default = expanded +(today's look). Horizontal size stays fixed; the window still moves by grabbing empty cells / chrome +(IA-12). This is a toolkit UX defined from the user's retail observation — the real mechanism lives in +`keystone.dll` (no decomp); our research notes the dat just stacks two always-present rows, so the dat +encodes no collapse. Recorded as an amendment to **IA-17** (toolbar frame is toolkit-supplied). + +**Non-goals:** a collapse/expand BUTTON (it's a bottom-edge resize); horizontal resize; persisting the +collapsed state across sessions (it resets to expanded each launch — persistence is the deferred +window-manager Plan-2); animating the snap. + +--- + +## 2. Geometry (from the layout, not hardcoded) + +The toolbar `LayoutDesc 0x21000016` root is **300×122**; the two rows are top `0x100001A7..AF` and +bottom `0x100006B7..BF`, with the bottom row's slots at content-y ≈ 90 (deep-dive §2a table, slot 9 at +`6,90`). Heights are computed at mount time from the actual layout, so there is no magic constant: + +- `border` = `RetailChromeSprites.Border` (5 px). +- `ExpandedHeight` = `contentHeight + 2·border` (today's frame height; `contentHeight` = the imported + root's `Height`, 122). +- `CollapsedHeight` = `minRow2Top + 2·border`, where `minRow2Top` = the smallest `Top` among the nine + resolved row-2 slot elements (`0x100006B7..BF`). That cuts the frame just above row 2. +- `snapMidpoint` = `(CollapsedHeight + ExpandedHeight) / 2`. + +--- + +## 3. Components + +### 3.1 `UiElement` — `MaxHeight` + a `ResizableEdges` mask — `src/AcDream.App/UI/UiElement.cs` +Today resize clamps to a minimum only and (with `ResizeY`) treats BOTH vertical edges as grips. Add two +small generic members: +```csharp +/// Maximum height enforced while resizing (default unbounded). Pairs with MinHeight. +public float MaxHeight { get; set; } = float.MaxValue; + +/// Which edges may start a resize, beyond the ResizeX/ResizeY axis gates. Default: all. +/// Set to e.g. ResizeEdges.Bottom to allow only a bottom-edge drag (the collapse toolbar). +public ResizeEdges ResizableEdges { get; set; } = + ResizeEdges.Left | ResizeEdges.Right | ResizeEdges.Top | ResizeEdges.Bottom; +``` +(`MaxWidth` is YAGNI — only height needs it here.) + +- **`UiRoot.HitEdges`** applies the mask at the end: `e &= w.ResizableEdges;` (after the existing + `ResizeX`/`ResizeY` masking). So a toolbar with `ResizableEdges = Bottom` only grips its bottom edge; + a press near the top edge falls through to window-move, not resize. +- **`UiRoot.ResizeRect`** gains a `maxH` parameter and clamps the Bottom/Top height branches: + `h = Math.Clamp(startH + dy, minH, maxH)` (Bottom) and the Top branch likewise. `OnMouseMove`'s resize + call passes `_resizeTarget.MaxHeight` (and `float.MaxValue` for the width's maxW). **This changes + `ResizeRect`'s signature — update its existing callers + the `UiRootInputTests.ResizeRect_*` tests to + pass the new `maxW`/`maxH` args** (`float.MaxValue` where unbounded, preserving their current + assertions). + +### 3.2 `UiCollapsibleFrame : UiNineSlicePanel` (new) — `src/AcDream.App/UI/UiCollapsibleFrame.cs` +A toolbar-frame variant that snaps between two heights and toggles a set of "second-row" elements. +One clear responsibility: reconcile its height to a stop and the rows to that stop, every tick. +```csharp +public sealed class UiCollapsibleFrame : UiNineSlicePanel +{ + public UiCollapsibleFrame(Func resolveChrome) : base(resolveChrome) { } + + public float CollapsedHeight { get; set; } + public float ExpandedHeight { get; set; } + /// Elements shown only when expanded (the row-2 slot lists). Hidden when collapsed. + public IReadOnlyList SecondRow { get; set; } = System.Array.Empty(); + + /// True when the frame is currently at (or nearer) the expanded stop. + public bool IsExpanded => Height >= (CollapsedHeight + ExpandedHeight) * 0.5f; + + protected override void OnTick(double dt) + { + base.OnTick(dt); + if (ExpandedHeight <= CollapsedHeight) return; // not configured yet + // Snap to the nearer stop (the resize drag sets Height live; we resolve it to a stop so the + // frame always rests collapsed or expanded — never a half-row). + bool expanded = IsExpanded; + Height = expanded ? ExpandedHeight : CollapsedHeight; + // Row 2 is shown only when expanded. (No clipping needed — the dat content is top-anchored, + // so row-2 slots simply stop drawing when hidden; row 1 never moves.) + for (int i = 0; i < SecondRow.Count; i++) SecondRow[i].Visible = expanded; + } +} +``` +Notes: +- The snap runs in `OnTick` (after the frame's `MinHeight`/`MaxHeight`-clamped resize drag set `Height` + that frame), so the rendered height is always a stop. With only two stops one row apart, this reads + as: drag the bottom edge past the midpoint → it jumps to the other stop + row 2 appears/hides. +- `IsExpanded`/the snap use the midpoint; `MinHeight`/`MaxHeight` (set by the mount) keep the drag + within `[Collapsed, Expanded]` so the midpoint test is well-defined. + +### 3.3 GameWindow toolbar mount — `src/AcDream.App/Rendering/GameWindow.cs` (~line 2045) +- Build the frame as `UiCollapsibleFrame` instead of `UiNineSlicePanel` (same `ResolveChrome` ctor arg). +- After the content (`toolbarRoot`) is sized: compute `expandedH = toolbarContentH + 2·border`, + `collapsedH = minRow2Top + 2·border` where `minRow2Top` = `min` of the nine row-2 lists' `Top` + (resolve each via `toolbarLayout.FindElement(0x100006B7..BF)`; reuse `ToolbarController`'s row-2 id + list or inline the nine ids). +- Set on the frame: `Resizable = true; ResizableEdges = ResizeEdges.Bottom` (bottom-edge only — top + edge stays a move grip); `MinHeight = collapsedH; MaxHeight = expandedH; Height = expandedH` (default + expanded); `CollapsedHeight = collapsedH; ExpandedHeight = expandedH; SecondRow = `. (`ResizeX`/`ResizeY` keep defaults; the `ResizableEdges = Bottom` mask is the operative + restriction.) +- Change `toolbarRoot.Anchors` from all-four-edges to **`Left | Top | Right`** (drop `Bottom`) so the + dat content keeps its full height and row 1 never reflows when the frame collapses; row 2 hides via + `Visible`. (Width is fixed — `ResizeX=false` — so the horizontal anchors are inert but harmless.) + +--- + +## 4. Behavior walk-through + +- **Launch:** frame at `ExpandedHeight`, both rows visible (unchanged from today). +- **Collapse:** grab the bottom edge, drag up past the midpoint → `OnTick` snaps `Height` to + `CollapsedHeight` and hides the nine row-2 slots. The frame is now a single-row bar; row 1 unchanged. +- **Expand:** drag the bottom edge down past the midpoint → snaps to `ExpandedHeight`, row 2 reappears. +- **Move:** unchanged — drag an empty cell / chrome to reposition (IA-12); occupied cells drag items + (B.1/B.2). +- **Edge cases:** `MinHeight`/`MaxHeight` clamp the drag to `[Collapsed, Expanded]`; the snap is + idempotent when not dragging (Height already at a stop). The collapsed state is per-session (resets + to expanded on relaunch). + +--- + +## 5. Divergence register + +**Amend IA-17** (toolbar window FRAME is toolkit-supplied): add that the frame also supports a +toolkit-defined **collapse-to-one-row** (bottom-edge resize snapping between a row-1-only and a +two-row height, row-2 visibility tied to the stop). Retail's real collapse mechanism is keystone.dll +(no decomp) and the dat encodes no collapse (both rows always present) — so this is our toolkit UX from +the user's retail observation, same justification class as the rest of IA-17. No new row; extend IA-17's +text + cite this spec. + +--- + +## 6. Testing + +`tests/AcDream.App.Tests/UI/`: +1. `UiCollapsibleFrame.OnTick` snap: set `CollapsedHeight=96`, `ExpandedHeight=128`; set `Height` just + below the midpoint (e.g. 100) + tick → `Height == 96` and every `SecondRow` element `Visible==false`; + set `Height` just above (e.g. 120) + tick → `Height == 128` and `SecondRow` `Visible==true`. +2. `UiCollapsibleFrame` not-configured guard: `ExpandedHeight==CollapsedHeight==0` → `OnTick` is a + no-op (no divide/no forced height). +3. `UiRoot.ResizeRect` MaxHeight clamp: a Bottom-edge resize with `dy` huge clamps `h` to `maxH`; + a Top-edge resize likewise; min still honored. (Drive via the existing `ResizeRect` static test + pattern in `UiRootInputTests`; also update the two pre-existing `ResizeRect_*` tests to the new + `maxW`/`maxH` signature — pass `float.MaxValue`, assertions unchanged.) +4. `UiRoot.HitEdges` honors `ResizableEdges`: a panel with `ResizableEdges = ResizeEdges.Bottom` returns + only `Bottom` when pressed near its bottom edge, and `None` near its top edge (which would otherwise + be a grip with `ResizeY` true). + +A `UiCollapsibleFrame` needs a chrome resolver in tests — pass `_ => (1u,1,1)` (the existing +`UiNineSlicePanel` test pattern); `OnTick` doesn't draw, so no GL. + +--- + +## 7. Acceptance + +- [ ] `dotnet build` + `dotnet test` green. +- [ ] IA-17 amended. +- [ ] **Visual (user):** default shows both rows; dragging the toolbar's **bottom edge up** snaps it to + a single row (row 2 gone); dragging **down** snaps back to two rows; row 1 never moves/squishes; + the window still moves by dragging empty cells/chrome; item drag (B.1/B.2) still works. + +--- + +## 8. Plan size + +One small task (TDD): `UiElement.MaxHeight` + `ResizeRect` clamp → `UiCollapsibleFrame` + its tests → +the GameWindow mount swap + IA-17 amend. ~3 files + 1 test file. Suitable for a single implementer pass. From f74e01750950954fdb309a7468734f5bfac0f73c Mon Sep 17 00:00:00 2001 From: Erik Date: Sat, 20 Jun 2026 18:33:35 +0200 Subject: [PATCH 019/133] =?UTF-8?q?feat(ui):=20D.2b=20=E2=80=94=20toolbar?= =?UTF-8?q?=20collapse-to-one-row=20(bottom-edge=20snap=20resize=20hides/s?= =?UTF-8?q?hows=20row=202)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit UiElement.MaxHeight + ResizableEdges mask; UiCollapsibleFrame snaps height to the nearer of {collapsed,expanded} and toggles row-2 visibility; GameWindow computes the two heights from the layout + top-anchors the content. Amends IA-17. UiNineSlicePanel unsealed to allow subclassing. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../retail-divergence-register.md | 2 +- src/AcDream.App/Rendering/GameWindow.cs | 40 +++++++++++- src/AcDream.App/UI/UiCollapsibleFrame.cs | 36 +++++++++++ src/AcDream.App/UI/UiElement.cs | 8 +++ src/AcDream.App/UI/UiNineSlicePanel.cs | 2 +- src/AcDream.App/UI/UiRoot.cs | 19 +++--- .../UI/UiCollapsibleFrameTests.cs | 63 +++++++++++++++++++ .../AcDream.App.Tests/UI/UiRootInputTests.cs | 4 +- 8 files changed, 159 insertions(+), 15 deletions(-) create mode 100644 src/AcDream.App/UI/UiCollapsibleFrame.cs create mode 100644 tests/AcDream.App.Tests/UI/UiCollapsibleFrameTests.cs diff --git a/docs/architecture/retail-divergence-register.md b/docs/architecture/retail-divergence-register.md index a78ef507..48d38e34 100644 --- a/docs/architecture/retail-divergence-register.md +++ b/docs/architecture/retail-divergence-register.md @@ -56,7 +56,7 @@ accepted-divergence entries (#96, #49, #50). | IA-13 | GameEventType registry deliberately omits event types retail ignores; unknown events fall through unhandled | `src/AcDream.Core.Net/Messages/GameEventType.cs:11` | Retail also ignores them — dropping matches retail by construction | If the "retail ignores X" judgment is wrong for any opcode (or a server mod uses one), the event is silently dropped with no diagnostic pointing at the omission | retail GameEvent dispatch (ignored-event set) | | IA-14 | Rendering + dat-handling base is WorldBuilder's tested port, not a fresh retail-decomp port (Phase N.4/O design stance) | `docs/architecture/worldbuilder-inventory.md` (code at `src/AcDream.{Core,App}/Rendering/Wb/`) | WB visually verified on the AC world, MIT, same stack; known WB↔retail deltas resolved case-by-case — terrain split kept retail `FSplitNESW` (**#51**, pinned by `SplitFormulaDivergenceTest`), scenery drift accepted (AP-31) | A WB-upstream divergence not yet caught ships silently as "our" behavior; guard = the inventory doc's 🟢/🔴 split + per-formula divergence tests | retail decomp per algorithm; `tests/.../SplitFormulaDivergenceTest.cs` | | IA-15 | D.2b retail UI is our own UiHost/UiElement retained-mode tree drawing dat-sprite window frames, not a byte-port of keystone.dll's LayoutDesc binary tree. Both the vitals window (`LayoutDesc 0x2100006C`) and the chat window (`LayoutDesc 0x21000006`) are rendered by the LayoutDesc importer; `UiNineSlicePanel`/`RetailChromeSprites` now back only plugin panels | `src/AcDream.App/UI/Layout/LayoutImporter.cs` (vitals + chat) + `src/AcDream.App/UI/Layout/ChatWindowController.cs` | keystone.dll has no PDB/decomp so a byte-port is impossible by definition; we mirror retail's ElementDesc field model + controls.ini tokens, and the chrome sprites ARE the real dat RenderSurfaces (Step-0 prove-out 2026-06-14 confirmed 0x06004CC2 center + 0x060074BF..C6 bevel). The 8-piece edge/corner→position mapping is DATA-DRIVEN from the dat: the `LayoutImporter` reads `LayoutDesc 0x2100006C`/`0x21000006` and resolves chrome element positions + sprite ids directly from parsed dat fields; vitals locked by the conformance fixture `tests/AcDream.App.Tests/UI/Layout/fixtures/vitals_2100006C.json` | Remaining residual risk: anchor resolution at non-800×600 and the controls.ini cascade still lack an oracle — layout scaling at non-reference resolution and stylesheet token inheritance differ silently | `LayoutDesc 0x2100006C`/`0x21000006` (SHIPPED); `docs/research/2026-06-15-layoutdesc-format.md`; controls.ini tokens; keystone.dll layout eval (no PDB) | -| IA-17 | Toolbar window FRAME is toolkit-supplied (per-window UiNineSlicePanel 8-piece bevel, drawn over content via UiElement.OnDrawAfterChildren) rather than the window-manager-owned chrome retail paints uniformly around every window | `src/AcDream.App/Rendering/GameWindow.cs` (toolbar mount) + `src/AcDream.App/UI/UiNineSlicePanel.cs` | LayoutDesc 0x21000016 has NO baked frame; retail's toolbar frame is window-manager chrome (keystone.dll). We draw the same reusable 8-piece bevel chat/vitals use; border drawn over content so the toolbar's 2px-wide row-2 right cap (W=8) can't poke through. Same pattern as the chat window. | Until a central window manager owns chrome uniformly, per-window wraps can drift (size/offset/z-order) from each other and from retail; the border-over-content rule is the toolkit's, not the WM's | gmToolbarUI WM chrome (keystone.dll, no PDB); no bevel ids in LayoutDesc 0x21000016 (toolbar dump) | +| IA-17 | Toolbar window FRAME is toolkit-supplied (per-window UiNineSlicePanel 8-piece bevel, drawn over content via UiElement.OnDrawAfterChildren) rather than the window-manager-owned chrome retail paints uniformly around every window. It also supports a toolkit-defined collapse-to-one-row (bottom-edge resize snapping between a row-1-only and a two-row height, row-2 visibility tied to the stop) — retail's real collapse is keystone.dll (no decomp) and the dat stacks both rows always. | `src/AcDream.App/Rendering/GameWindow.cs` (toolbar mount) + `src/AcDream.App/UI/UiNineSlicePanel.cs` + `src/AcDream.App/UI/UiCollapsibleFrame.cs`; spec: `docs/superpowers/specs/2026-06-20-d2b-toolbar-collapse-design.md` | LayoutDesc 0x21000016 has NO baked frame; retail's toolbar frame is window-manager chrome (keystone.dll). We draw the same reusable 8-piece bevel chat/vitals use; border drawn over content so the toolbar's 2px-wide row-2 right cap (W=8) can't poke through. Same pattern as the chat window. | Until a central window manager owns chrome uniformly, per-window wraps can drift (size/offset/z-order) from each other and from retail; the border-over-content rule is the toolkit's, not the WM's | gmToolbarUI WM chrome (keystone.dll, no PDB); no bevel ids in LayoutDesc 0x21000016 (toolbar dump) | | IA-18 | Effect overlay tile (enum 0x10000005) is a `ReplaceColor` SURFACE SOURCE — pure-white pixels in the composited drag icon are replaced PER-PIXEL with the same (x,y) pixel of the effect tile (the SURFACE overload `SurfaceWindow::ReplaceColor` 0x004415b0), preserving the tile's texture/gradient; the tile itself is NOT blitted as an additional layer. This IS faithful retail behavior. **Anti-regression: do NOT re-implement this as a blit layer NOR as a flat-color replace (it is a per-pixel surface copy).** | `src/AcDream.App/UI/IconComposer.cs` (`ReplaceWhiteFromSurface`) | Faithful port of `IconData::RenderIcons` @407614 → the SURFACE overload `ReplaceColor` 0x004415b0 (`dst[x,y]=src[x,y]` where `dst==white`); confirmed via clean Ghidra decompile + named decomp + visual (the Energy Crystal's blue is a gradient, 2026-06-17). | A blit-layer or flat-color re-implementation would show the wrong effect look (no gradient) — the visual-verification regression that retired the mean-color approximation | `IconData::RenderIcons` acclient_2013_pseudo_c.txt:407524; `ReplaceColor` SURFACE overload 0x004415b0:71656; `docs/research/2026-06-17-stateful-icon-RESOLVED.md` | --- diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs index 197bdd07..1eaf9497 100644 --- a/src/AcDream.App/Rendering/GameWindow.cs +++ b/src/AcDream.App/Rendering/GameWindow.cs @@ -2044,7 +2044,7 @@ public sealed class GameWindow : IDisposable // thickness on every side, giving an outer window of 310×132. const int toolbarBorder = AcDream.App.UI.RetailChromeSprites.Border; float toolbarContentW = 300f, toolbarContentH = toolbarRoot.Height; - var toolbarFrame = new AcDream.App.UI.UiNineSlicePanel(ResolveChrome) + var toolbarFrame = new AcDream.App.UI.UiCollapsibleFrame(ResolveChrome) { Left = 10, Top = 300, Width = toolbarContentW + 2 * toolbarBorder, @@ -2057,14 +2057,48 @@ public sealed class GameWindow : IDisposable toolbarRoot.Top = toolbarBorder; toolbarRoot.Width = toolbarContentW; toolbarRoot.Height = toolbarContentH; - // Anchor content to all four edges so it reflows if the frame is resized. + // Anchor content to Left|Top|Right only — drop Bottom so the dat content + // keeps its full height when the frame collapses; row 2 hides via Visible, + // not reflow. (Width is fixed so the horizontal anchors are inert but harmless.) toolbarRoot.Anchors = AcDream.App.UI.AnchorEdges.Left | AcDream.App.UI.AnchorEdges.Top - | AcDream.App.UI.AnchorEdges.Right | AcDream.App.UI.AnchorEdges.Bottom; + | AcDream.App.UI.AnchorEdges.Right; // The frame is the draggable window; the content itself is not. toolbarRoot.ClickThrough = false; toolbarRoot.Draggable = false; toolbarRoot.Resizable = false; toolbarFrame.AddChild(toolbarRoot); + + // Collapse-to-one-row: the frame's bottom edge snaps between a one-row (row 2 hidden) + // and two-row height. CollapsedHeight is computed from the layout (just above row 2), + // so there's no magic constant. Bottom-edge only; default expanded. + uint[] row2Ids = + { + 0x100006B7u, 0x100006B8u, 0x100006B9u, 0x100006BAu, 0x100006BBu, + 0x100006BCu, 0x100006BDu, 0x100006BEu, 0x100006BFu, + }; + var toolbarRow2 = new System.Collections.Generic.List(); + float minRow2Top = float.MaxValue; + foreach (var id in row2Ids) + if (toolbarLayout.FindElement(id) is { } e2) + { + toolbarRow2.Add(e2); + if (e2.Top < minRow2Top) minRow2Top = e2.Top; + } + if (toolbarRow2.Count > 0) + { + float expandedH = toolbarContentH + 2 * toolbarBorder; // today's full height + float collapsedH = minRow2Top + 2 * toolbarBorder; // just above row 2 + toolbarFrame.CollapsedHeight = collapsedH; + toolbarFrame.ExpandedHeight = expandedH; + toolbarFrame.SecondRow = toolbarRow2; + toolbarFrame.Resizable = true; + toolbarFrame.ResizableEdges = AcDream.App.UI.ResizeEdges.Bottom; // bottom edge only + toolbarFrame.MinHeight = collapsedH; + toolbarFrame.MaxHeight = expandedH; + // Height stays expandedH (already set at construction) = default expanded. + Console.WriteLine($"[D.2b] toolbar collapse: collapsed={collapsedH:0} expanded={expandedH:0} (row2Top={minRow2Top:0})."); + } + _uiHost.Root.AddChild(toolbarFrame); Console.WriteLine("[D.5.1] retail toolbar window from LayoutDesc importer (0x21000016)."); diff --git a/src/AcDream.App/UI/UiCollapsibleFrame.cs b/src/AcDream.App/UI/UiCollapsibleFrame.cs new file mode 100644 index 00000000..7f1fc340 --- /dev/null +++ b/src/AcDream.App/UI/UiCollapsibleFrame.cs @@ -0,0 +1,36 @@ +using System; +using System.Collections.Generic; + +namespace AcDream.App.UI; + +/// +/// A toolbar-frame variant that snaps its height between two stops — collapsed (row 2 hidden) and +/// expanded (row 2 shown) — and toggles a set of "second-row" elements to match. Resized via the +/// bottom edge (the mount sets = Bottom); each tick it resolves +/// the dragged height to the nearer stop so the frame always rests collapsed or expanded — never a +/// half-row. Toolkit UX (keystone.dll has no decomp; the dat stacks both rows always) — see IA-17. +/// +public sealed class UiCollapsibleFrame : UiNineSlicePanel +{ + public UiCollapsibleFrame(Func resolve) : base(resolve) { } + + public float CollapsedHeight { get; set; } + public float ExpandedHeight { get; set; } + /// Elements shown only when expanded (the row-2 slot lists). Hidden when collapsed. + public IReadOnlyList SecondRow { get; set; } = Array.Empty(); + + /// True when the frame is at (or nearer) the expanded stop. + public bool IsExpanded => Height >= (CollapsedHeight + ExpandedHeight) * 0.5f; + + protected override void OnTick(double deltaSeconds) + { + base.OnTick(deltaSeconds); + if (ExpandedHeight <= CollapsedHeight) return; // not configured yet — no snap + bool expanded = IsExpanded; + Height = expanded ? ExpandedHeight : CollapsedHeight; // snap the dragged height to a stop + for (int i = 0; i < SecondRow.Count; i++) SecondRow[i].Visible = expanded; + } + + /// Test hook — OnTick is protected. Drives one snap+visibility reconcile. + internal void TickForTest(double dt) => OnTick(dt); +} diff --git a/src/AcDream.App/UI/UiElement.cs b/src/AcDream.App/UI/UiElement.cs index 84e5f8bd..72a49d63 100644 --- a/src/AcDream.App/UI/UiElement.cs +++ b/src/AcDream.App/UI/UiElement.cs @@ -125,11 +125,19 @@ public abstract class UiElement public float MinWidth { get; set; } = 40f; public float MinHeight { get; set; } = 40f; + /// Maximum height enforced while resizing (default unbounded). Pairs with MinHeight. + public float MaxHeight { get; set; } = float.MaxValue; + /// Allow horizontal (width) resize. Ignored unless . public bool ResizeX { get; set; } = true; /// Allow vertical (height) resize. Ignored unless . public bool ResizeY { get; set; } = true; + /// Which edges may start a resize, beyond the ResizeX/ResizeY axis gates. Default: all. + /// Set to e.g. to allow only a bottom-edge drag (the collapse toolbar). + public ResizeEdges ResizableEdges { get; set; } = + ResizeEdges.Left | ResizeEdges.Right | ResizeEdges.Top | ResizeEdges.Bottom; + /// Edges this element anchors to in its parent. Default Left|Top /// (pinned top-left, fixed size — no reflow). Left|Right stretches width. public AnchorEdges Anchors { get; set; } = AnchorEdges.Left | AnchorEdges.Top; diff --git a/src/AcDream.App/UI/UiNineSlicePanel.cs b/src/AcDream.App/UI/UiNineSlicePanel.cs index f407f07b..1c4c207b 100644 --- a/src/AcDream.App/UI/UiNineSlicePanel.cs +++ b/src/AcDream.App/UI/UiNineSlicePanel.cs @@ -10,7 +10,7 @@ namespace AcDream.App.UI; /// the widget is testable without GL. In production: /// id => { var t = cache.GetOrUploadRenderSurface(id, out var w, out var h); return (t, w, h); }. /// -public sealed class UiNineSlicePanel : UiPanel +public class UiNineSlicePanel : UiPanel { /// A placed chrome piece: destination rect in local pixel space. public readonly record struct Rect(float X, float Y, float W, float H); diff --git a/src/AcDream.App/UI/UiRoot.cs b/src/AcDream.App/UI/UiRoot.cs index 4bb20f5d..b43201dc 100644 --- a/src/AcDream.App/UI/UiRoot.cs +++ b/src/AcDream.App/UI/UiRoot.cs @@ -176,7 +176,8 @@ public sealed class UiRoot : UiElement var (nx, ny, nw, nh) = ResizeRect( _resizeStartX, _resizeStartY, _resizeStartW, _resizeStartH, _resizeEdges, x - _resizeMouseX, y - _resizeMouseY, - _resizeTarget.MinWidth, _resizeTarget.MinHeight); + _resizeTarget.MinWidth, _resizeTarget.MinHeight, + float.MaxValue, _resizeTarget.MaxHeight); _resizeTarget.Left = nx; _resizeTarget.Top = ny; _resizeTarget.Width = nw; _resizeTarget.Height = nh; return; @@ -626,21 +627,23 @@ public sealed class UiRoot : UiElement if (System.Math.Abs(y - b) <= grip) e |= ResizeEdges.Bottom; if (!w.ResizeX) e &= ~(ResizeEdges.Left | ResizeEdges.Right); if (!w.ResizeY) e &= ~(ResizeEdges.Top | ResizeEdges.Bottom); + e &= w.ResizableEdges; return e; } /// Compute a resized rect from a start rect + drag delta + which edges, - /// clamping to (,). Left/Top edges - /// move the origin so the opposite edge stays put. + /// clamping to (,) and + /// (,). Left/Top edges move the + /// origin so the opposite edge stays put. public static (float x, float y, float w, float h) ResizeRect( float startX, float startY, float startW, float startH, - ResizeEdges edges, float dx, float dy, float minW, float minH) + ResizeEdges edges, float dx, float dy, float minW, float minH, float maxW, float maxH) { float x = startX, y = startY, w = startW, h = startH; - if ((edges & ResizeEdges.Right) != 0) w = System.Math.Max(minW, startW + dx); - if ((edges & ResizeEdges.Bottom) != 0) h = System.Math.Max(minH, startH + dy); - if ((edges & ResizeEdges.Left) != 0) { float nw = System.Math.Max(minW, startW - dx); x = startX + (startW - nw); w = nw; } - if ((edges & ResizeEdges.Top) != 0) { float nh = System.Math.Max(minH, startH - dy); y = startY + (startH - nh); h = nh; } + if ((edges & ResizeEdges.Right) != 0) w = System.Math.Clamp(startW + dx, minW, maxW); + if ((edges & ResizeEdges.Bottom) != 0) h = System.Math.Clamp(startH + dy, minH, maxH); + if ((edges & ResizeEdges.Left) != 0) { float nw = System.Math.Clamp(startW - dx, minW, maxW); x = startX + (startW - nw); w = nw; } + if ((edges & ResizeEdges.Top) != 0) { float nh = System.Math.Clamp(startH - dy, minH, maxH); y = startY + (startH - nh); h = nh; } return (x, y, w, h); } diff --git a/tests/AcDream.App.Tests/UI/UiCollapsibleFrameTests.cs b/tests/AcDream.App.Tests/UI/UiCollapsibleFrameTests.cs new file mode 100644 index 00000000..bc7abf5b --- /dev/null +++ b/tests/AcDream.App.Tests/UI/UiCollapsibleFrameTests.cs @@ -0,0 +1,63 @@ +using AcDream.App.UI; +using Xunit; + +namespace AcDream.App.Tests.UI; + +public class UiCollapsibleFrameTests +{ + private static UiCollapsibleFrame MakeFrame(out UiPanel row2a, out UiPanel row2b) + { + var f = new UiCollapsibleFrame(_ => (1u, 1, 1)) + { + CollapsedHeight = 96f, + ExpandedHeight = 128f, + }; + row2a = new UiPanel(); row2b = new UiPanel(); + f.SecondRow = new UiElement[] { row2a, row2b }; + return f; + } + + [Fact] + public void Tick_belowMidpoint_snapsCollapsed_hidesSecondRow() + { + var f = MakeFrame(out var a, out var b); + f.Height = 100f; // nearer the collapsed stop (midpoint 112) + f.TickForTest(0.016); + Assert.Equal(96f, f.Height); + Assert.False(a.Visible); + Assert.False(b.Visible); + Assert.False(f.IsExpanded); + } + + [Fact] + public void Tick_aboveMidpoint_snapsExpanded_showsSecondRow() + { + var f = MakeFrame(out var a, out var b); + f.Height = 120f; // nearer the expanded stop + f.TickForTest(0.016); + Assert.Equal(128f, f.Height); + Assert.True(a.Visible); + Assert.True(b.Visible); + Assert.True(f.IsExpanded); + } + + [Fact] + public void Tick_notConfigured_isNoOp() + { + var f = new UiCollapsibleFrame(_ => (1u, 1, 1)); // Collapsed==Expanded==0 + f.Height = 50f; + f.TickForTest(0.016); + Assert.Equal(50f, f.Height); // unchanged, no divide/no forced height + } + + [Fact] + public void HitEdges_respectsResizableEdgesMask_bottomOnly() + { + var panel = new UiPanel { Left = 100, Top = 100, Width = 200, Height = 100, + Resizable = true, ResizableEdges = ResizeEdges.Bottom }; + // bottom edge (y=200) → Bottom only + Assert.Equal(ResizeEdges.Bottom, UiRoot.HitEdges(panel, 200, 200, 5)); + // top edge (y=100) → masked out → None + Assert.Equal(ResizeEdges.None, UiRoot.HitEdges(panel, 200, 100, 5)); + } +} diff --git a/tests/AcDream.App.Tests/UI/UiRootInputTests.cs b/tests/AcDream.App.Tests/UI/UiRootInputTests.cs index c3160e66..89123682 100644 --- a/tests/AcDream.App.Tests/UI/UiRootInputTests.cs +++ b/tests/AcDream.App.Tests/UI/UiRootInputTests.cs @@ -157,7 +157,7 @@ public class UiRootInputTests public void ResizeRect_RightBottom_GrowsSizeOnly() { var (x, y, w, h) = UiRoot.ResizeRect(10, 20, 100, 50, - ResizeEdges.Right | ResizeEdges.Bottom, dx: 30, dy: 15, minW: 40, minH: 40); + ResizeEdges.Right | ResizeEdges.Bottom, dx: 30, dy: 15, minW: 40, minH: 40, maxW: float.MaxValue, maxH: float.MaxValue); Assert.Equal(10f, x); Assert.Equal(20f, y); Assert.Equal(130f, w); Assert.Equal(65f, h); } @@ -168,7 +168,7 @@ public class UiRootInputTests // Drag left edge right by 80 on a 100-wide / min-40 window: width clamps to 40, // origin shifts so the RIGHT edge (110) stays put → x = 70. var (x, _, w, _) = UiRoot.ResizeRect(10, 20, 100, 50, - ResizeEdges.Left, dx: 80, dy: 0, minW: 40, minH: 40); + ResizeEdges.Left, dx: 80, dy: 0, minW: 40, minH: 40, maxW: float.MaxValue, maxH: float.MaxValue); Assert.Equal(40f, w); Assert.Equal(70f, x); } From 89a2a2857a4e0dd5dae4fb643039b4ee88f9d6c9 Mon Sep 17 00:00:00 2001 From: Erik Date: Sat, 20 Jun 2026 18:37:33 +0200 Subject: [PATCH 020/133] =?UTF-8?q?test(ui):=20D.2b=20=E2=80=94=20cover=20?= =?UTF-8?q?ResizeRect=20maxH=20clamp=20(Bottom=20+=20Top=20edges)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Spec §6 test 3 — the maxH clamp is the load-bearing path for the toolbar collapse; the implementer updated the existing ResizeRect tests' signatures but didn't add the clamp-verification cases. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../AcDream.App.Tests/UI/UiRootInputTests.cs | 21 +++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/tests/AcDream.App.Tests/UI/UiRootInputTests.cs b/tests/AcDream.App.Tests/UI/UiRootInputTests.cs index 89123682..9f326919 100644 --- a/tests/AcDream.App.Tests/UI/UiRootInputTests.cs +++ b/tests/AcDream.App.Tests/UI/UiRootInputTests.cs @@ -173,6 +173,27 @@ public class UiRootInputTests Assert.Equal(70f, x); } + [Fact] + public void ResizeRect_Bottom_ClampsToMaxH() + { + // dy=1000 on a 50-tall window with maxH=128 → height clamps to 128, origin unchanged. + var (_, y, _, h) = UiRoot.ResizeRect(10, 20, 100, 50, + ResizeEdges.Bottom, dx: 0, dy: 1000, minW: 40, minH: 40, maxW: float.MaxValue, maxH: 128f); + Assert.Equal(128f, h); + Assert.Equal(20f, y); + } + + [Fact] + public void ResizeRect_Top_ClampsToMaxH() + { + // Drag the top edge UP by 1000 on a 50-tall window with maxH=128 → height clamps to 128, + // origin shifts so the bottom edge (70) stays put → y = 20 + 50 - 128. + var (_, y, _, h) = UiRoot.ResizeRect(10, 20, 100, 50, + ResizeEdges.Top, dx: 0, dy: -1000, minW: 40, minH: 40, maxW: float.MaxValue, maxH: 128f); + Assert.Equal(128f, h); + Assert.Equal(20f + 50f - 128f, y); + } + [Fact] public void HitEdges_DetectsCornerAndInteriorNone() { From 14443e5c27e7a00392ff65174fce295a85b5d6d5 Mon Sep 17 00:00:00 2001 From: Erik Date: Sat, 20 Jun 2026 18:52:49 +0200 Subject: [PATCH 021/133] =?UTF-8?q?fix(ui):=20D.2b=20=E2=80=94=20collapsed?= =?UTF-8?q?=20toolbar=20left=20black=20pillars=20+=20draggable=20below=20t?= =?UTF-8?q?he=20bar?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two bugs from the visual gate, same dump-confirmed cause: row 2 is an 11-element band (left edge-piece 0x100006B6 + 9 slots 0x100006B7..BF + right edge-piece 0x100006C0, all at content-y 90), but SecondRow hid only the 9 slots — so the two edge-pieces kept drawing as black pillars below the collapsed bar. And toolbarRoot (ClickThrough=false, full height) still caught clicks below the collapsed frame → walked up to the Draggable frame → phantom window-move. Fix: SecondRow now hides the full band (B6..C0); toolbarRoot.ClickThrough=true so its children (slots/indicators) still get hits children-first but its empty/below regions fall through (no phantom drag) — same pattern as the chat content panel. The slots are edge-flag 0 = Left|Top fixed, so nothing reflows. Build green. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/AcDream.App/Rendering/GameWindow.cs | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs index 1eaf9497..bba630e7 100644 --- a/src/AcDream.App/Rendering/GameWindow.cs +++ b/src/AcDream.App/Rendering/GameWindow.cs @@ -2062,8 +2062,13 @@ public sealed class GameWindow : IDisposable // not reflow. (Width is fixed so the horizontal anchors are inert but harmless.) toolbarRoot.Anchors = AcDream.App.UI.AnchorEdges.Left | AcDream.App.UI.AnchorEdges.Top | AcDream.App.UI.AnchorEdges.Right; - // The frame is the draggable window; the content itself is not. - toolbarRoot.ClickThrough = false; + // The frame is the draggable window; the content itself is not. ClickThrough so the + // content panel never CLAIMS a hit — its behavioral children (slots, indicators) are + // hit children-first, and its empty areas fall through to the frame (move). Critically, + // when collapsed the row-2 band is hidden, so below the collapsed frame the content has + // no visible/hit children and ClickThrough lets clicks fall through (no phantom + // window-drag from where row 2 used to be). Same pattern as the chat content panel. + toolbarRoot.ClickThrough = true; toolbarRoot.Draggable = false; toolbarRoot.Resizable = false; toolbarFrame.AddChild(toolbarRoot); @@ -2071,10 +2076,16 @@ public sealed class GameWindow : IDisposable // Collapse-to-one-row: the frame's bottom edge snaps between a one-row (row 2 hidden) // and two-row height. CollapsedHeight is computed from the layout (just above row 2), // so there's no magic constant. Bottom-edge only; default expanded. + // The full row-2 band (all at content-y 90, toolbar dump 0x21000016): a 6px left + // edge-piece (0x100006B6) + the 9 slots (0x100006B7..BF) + an 8px right edge-piece + // (0x100006C0). Hide ALL 11 when collapsed — hiding only the 9 slots leaves the two + // edge-pieces drawing as black pillars below the bar. uint[] row2Ids = { + 0x100006B6u, 0x100006B7u, 0x100006B8u, 0x100006B9u, 0x100006BAu, 0x100006BBu, 0x100006BCu, 0x100006BDu, 0x100006BEu, 0x100006BFu, + 0x100006C0u, }; var toolbarRow2 = new System.Collections.Generic.List(); float minRow2Top = float.MaxValue; From a391b86a2e905d0f20ddbe9b3d52c7c5a1fad252 Mon Sep 17 00:00:00 2001 From: Erik Date: Sat, 20 Jun 2026 20:38:41 +0200 Subject: [PATCH 022/133] =?UTF-8?q?docs(handoff):=20window=20manager=20?= =?UTF-8?q?=E2=86=92=20inventory=20window=20=E2=86=92=20paperdoll=20(next?= =?UTF-8?q?=20D.2b=20arc)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Frames the next core-panels work for a fresh session, after B.1 (drag spine) + B.2 (toolbar reorder/remove + wire) + toolbar collapse merged this session. Three sub-phases in order: (A) minimal window manager (open/close + I-key toggle; UiHost has no open/close API today, windows are always-on); (B) inventory window (Stream C, synthesis §4 — UiItemList grid + sub-window mount + wire gaps + InventoryController; the drag SOURCE that closes B.2's drag-from-inventory via a SourceKind==Inventory branch in ToolbarController.HandleDropRelease); (C) paperdoll UiViewport (Type 0xD) + Core→App IUiViewportRenderer seam, heaviest, last. Spell bar deferred. Reuse the shipped spine/ShortcutStore/wire. Paste-able new-session prompt + MEMORY index line inside. Co-Authored-By: Claude Opus 4.8 (1M context) --- ...-06-20-window-manager-inventory-handoff.md | 200 ++++++++++++++++++ 1 file changed, 200 insertions(+) create mode 100644 docs/research/2026-06-20-window-manager-inventory-handoff.md diff --git a/docs/research/2026-06-20-window-manager-inventory-handoff.md b/docs/research/2026-06-20-window-manager-inventory-handoff.md new file mode 100644 index 00000000..d2b7b237 --- /dev/null +++ b/docs/research/2026-06-20-window-manager-inventory-handoff.md @@ -0,0 +1,200 @@ +# Handoff — window manager → inventory window → paperdoll (the next D.2b core-panels arc) + +**Date:** 2026-06-20 +**From:** the D.5.3 session that shipped the drag-drop spine (B.1), toolbar shortcut reorder/remove + +wire (B.2), and toolbar collapse-to-one-row — all **merged to `main`** (merge commit `abbd97b`; +branch `claude/hopeful-maxwell-214a12`, tip `14443e5`). Full suite green (2752) at merge. +**Line numbers below WILL drift — grep the symbol, don't trust the line.** + +--- + +## 0. What just shipped (the foundation the next work builds on) + +This is now on `main` and visually confirmed: + +- **Drag-drop spine (B.1)** — the shared widget-level drag machine. `UiRoot` holds the device-level + drag (BeginDrag/UpdateDragHover/FinishDrag, ghost snapshotted at begin, drop delivered only on a real + hit). `UiElement` has `GetDragPayload()`/`GetDragGhost()`/`IsDragSource` virtuals (UiRoot stays + item-agnostic). `UiItemSlot` is drag source + drop target + accept/reject overlay; `UiItemList` owns + a registered `IItemListDragHandler { OnDragLift; OnDragOver; HandleDropRelease }`. Payload = + `ItemDragPayload(ObjId, SourceKind, SourceSlot, SourceCell)`. Spec/plan: + `docs/superpowers/{specs,plans}/2026-06-20-d2b-drag-drop-spine*.md`. +- **Toolbar shortcut drag (B.2)** — retail **remove-on-lift / place-on-drop / no-restore**. + `ToolbarController : IItemListDragHandler` drives a `ShortcutStore` (`AcDream.Core.Items`, 18 slots) + + the wire: `InventoryActions.BuildAddShortcut(seq,index,objectGuid,spellId,layer)` / + `BuildRemoveShortcut`, sent via `WorldSession.SendAddShortcut`/`SendRemoveShortcut`. Green-cross + accept sprite `0x060011FA`. Spec/plan: `docs/superpowers/{specs,plans}/2026-06-20-d2b-toolbar-shortcut-drag*.md`. +- **Toolbar collapse** — `UiCollapsibleFrame` bottom-edge snap resize (1-row ↔ 2-row). Spec: + `docs/superpowers/specs/2026-06-20-d2b-toolbar-collapse-design.md`. + +**The one B-stream piece still owed:** *drag FROM inventory onto the bar.* B.2 did the within-bar +reorder/remove (`SourceKind == ShortcutBar`). The fresh-from-inventory branch (retail `flags & 0xE == 0` +→ `CreateShortcutToItem`) needs the inventory window as a drag SOURCE — so it lands WITH Stream C +below, not before it. + +--- + +## 1. Read first + +- This doc. +- `docs/research/2026-06-16-ui-panels-synthesis.md` §4 — **the build plan** for the core panels + (build order, widget list, cross-panel wire table). Stream C follows it. +- `docs/research/2026-06-16-inventory-deep-dive.md` — `gmInventoryUI 0x21000023` nests paperdoll + (`0x21000024`) + backpack (`0x21000022`) + 3D-items (`0x21000021`); backpack burden Meter + (`SetLoadLevel`→fill `0x69`); full inventory wire catalog + acdream parse-status. +- `docs/research/2026-06-16-equipment-paperdoll-deep-dive.md` — the doll = `UIElement_Viewport` + (Type `0xD`) hosting a re-dressed player clone; ~25 equip slots; wield = `GetAndWieldItem 0x001A` + (builder MISSING); needs a Core→App `IUiViewportRenderer` seam. +- `docs/research/2026-06-18-d53-bar-finish-and-inventory-handoff.md` §C — the predecessor's + current-code readiness for the inventory window (most still accurate; deltas noted below). +- `claude-memory/project_d2b_retail_ui.md` (the toolkit, now incl. B.1/B.2/collapse) + + `claude-memory/project_object_item_model.md` (the D.5.4 `ClientObjectTable`). + +**Mandatory workflow** (CLAUDE.md): grep `docs/research/named-retail/acclient_2013_pseudo_c.txt` by +`class::method` → cross-ref ACE/holtburger → pseudocode → port; conformance tests throughout. Each +sub-phase gets its own brainstorm → spec → plan → subagent-driven (the flow this arc has been using). + +--- + +## 2. Scope — three sub-phases, in order + +| # | Sub-phase | One-liner | +|---|---|---| +| **A** | **Window manager (open/close + `I`-key)** | The small shared infra to open/close a window + a keybind to toggle the inventory window. Prerequisite for everything below. | +| **B** | **Inventory window (Stream C / D.5.5)** | `gmInventoryUI` nesting paperdoll + backpack + 3D-items. The drag SOURCE that completes "drag-from-inventory." | +| **C** | **Paperdoll** (`UiViewport`) | The 3D dressed-doll viewport — the heaviest piece, its own Core→App seam. Last. | + +**Out of scope:** the spell bar (a separate spell-casting feature, NOT the action-bar spell shortcuts); +the faithful dragbar/resizebar window resize (IA-12 whole-window-drag + the new bottom-edge collapse +are the accepted approximations). + +--- + +## 3. Sub-phase A — window manager (open/close + `I`-key) — DO THIS FIRST + +**Current state:** every retail-UI window is **always-on at a hardcoded position** — the vitals, +chat, and toolbar are added to `_uiHost.Root` unconditionally at startup under `ACDREAM_RETAIL_UI=1` +(grep `_uiHost.Root.AddChild` in `GameWindow.cs`; the mounts are in the `_options.RetailUi` block). +`UiHost` has **no open/close API** (`src/AcDream.App/UI/UiHost.cs` — it exposes `Root`, `Tick`, +`Draw`, `WireMouse/Keyboard` only). Input flows through `InputDispatcher` (Phase K) — +`keybinds.json` + `KeyBindings`; the `I` key needs an action + binding. + +**Minimum deliverable:** open/close a top-level window + toggle the inventory window with `I`. +Concretely: +- A small open/close concept on `UiHost`/`UiRoot` (e.g. register a named window + Show/Hide/Toggle that + flips `Visible` and brings it to front via `ZOrder`). Today windows are always `Visible=true`; the + manager makes a window default-hidden and toggleable. (z-order/persist can be minimal — `Visible` + + a top-most `ZOrder` bump on open.) +- An `InputAction` (e.g. `ToggleInventory`) + the retail default `I` keybind (cross-check + `docs/research/named-retail/retail-default.keymap.txt`), wired through the existing + `InputDispatcher` (see `claude-memory/project_input_pipeline.md`), gated by `WantsKeyboard` so it + doesn't fire while a chat input is focused. + +**Brainstorm questions (A):** Is the window manager a property on `UiRoot` (a registry of named +top-level windows + Show/Hide/Toggle), or a thin `UiHost` API? How is z-order handled on open (bring +to front)? Does `I` toggle (open↔close) or only open (Esc/close-button to close)? Persist open-state +across the session (in-memory) only — no disk persistence yet (that's the deferred Plan-2)? + +Faithful retail note: retail windows are opened by the radar/menu buttons + hotkeys and managed by +keystone.dll (no decomp) — so this is a toolkit-defined manager (IA-12/IA-15 umbrella), kept minimal. + +--- + +## 4. Sub-phase B — inventory window (Stream C / D.5.5) — follow the synthesis §4 + +**The design is already written — follow `2026-06-16-ui-panels-synthesis.md` §4.** This section is the +**current-code readiness** + what's missing. Don't re-derive the design. + +**READY (and stronger now, post-B.1/B.2):** +- `UiItemSlot` + `UiItemList` + `IconComposer` (`src/AcDream.App/UI/`) — the shared item-cell spine, + now with the full drag-drop machine (B.1). An inventory grid is `UiItemList` cells + an + `IItemListDragHandler` on the controller. +- `DatWidgetFactory` registers `0x10000031 → UiItemList` (grep `0x10000031` in + `src/AcDream.App/UI/Layout/DatWidgetFactory.cs`). +- The data path: `ClientObjectTable.GetContents(containerGuid)` → ordered guids → `Get(guid)` → full + icon fields (`src/AcDream.Core/Items/ClientObjectTable.cs`). The object/container model shipped in + D.5.4 (`project_object_item_model.md`). +- The shortcut wire + `ShortcutStore` (B.2) — so **drag-from-inventory-onto-the-bar** is now a small + addition: the inventory cell is a drag SOURCE (its handler's `OnDragLift` does NOT remove — an + inventory→bar drag creates a shortcut REFERENCE; the item stays in the pack), and `ToolbarController. + HandleDropRelease` gains the `SourceKind == Inventory` branch (place a new shortcut at the target via + `SendAddShortcut`, no source-bump) — retail's `flags & 0xE == 0` `CreateShortcutToItem` path. + +**MISSING (the build, in synthesis order — see the 2026-06-18 handoff §C.1-6 for the detailed list):** +1. **Window manager** — §3 above (do first). +2. **`UiItemList` N-cell grid mode** — currently single-cell (`UiItemList.cs`; `Flush`/`AddItem` + skeleton exists, no column-count/pitch/wrap). LIKELY ~6 cols; confirm from + `UIElement_ItemList::ItemList_AddItem`. +3. **Sub-window mount in `LayoutImporter`** — `gmInventoryUI 0x21000023` nests `gm*UI` children with + their own `BaseLayoutId`; the importer only does TEMPLATE inheritance today + (`src/AcDream.App/UI/Layout/LayoutImporter.cs`) — instantiating a nested `gm*UI` window is new. +4. **Wire gaps** (inventory deep-dive §4.3): builders `DropItem 0x001B`, `GetAndWieldItem 0x001A`, + `NoLongerViewingContents 0x0195`; parsers `ViewContents 0x0196`, `SetStackSize 0x0197`, + `InventoryRemoveObject`; fix `ParsePutObjInContainer` (drops the 4th `containerType`) + + `ParseInventoryServerSaveFailed` (drops `weenieError`); register `ViewContents`/`0x019A`/`0x0052`/ + `0x00A0` in `GameEventWiring`. (Grep these symbols — none landed this session.) +5. **`InventoryController`** (`gm*UI::PostInit` find-by-id pattern): backpack burden Meter + (`SetLoadLevel`→fill `0x69`), own-pack list + side-pack list, `ObjDescEvent 0xF625` → re-dress. + +**Brainstorm questions (B):** Sub-window mount — recursive `Import()` in `LayoutImporter`, or an +external stitch by the controller? Grid column count (confirm 6 from decomp)? Open by `I`-key only, or +also a toolbar inventory button? The inventory cell's drag semantics (lift does NOT remove from the +pack — it's a copy-to-shortcut for the bar, but a real MOVE between packs DOES relocate via +`PutItemInContainer 0x0019`) — pin the SourceKind→action matrix (deep-dive §5.7 opcode table). + +--- + +## 5. Sub-phase C — paperdoll (`UiViewport`, Type 0xD) — heaviest, last + +**The single biggest new piece.** No widget, no factory registration, no renderer today. Needs an +`IUiViewportRenderer` **Core→App seam** (structure Rule 2) for a scissored single-entity GL pass — the +doll is the local player's ObjDesc-dressed entity in a fixed viewport. ~25 equip slots; the +element-id→`EquipMask` map; wield = `GetAndWieldItem 0x001A` (builder still MISSING). Brainstorm +separately (it's substantial). Follow `2026-06-16-equipment-paperdoll-deep-dive.md`. + +**Brainstorm questions (C):** Does the doll clone the player `WorldEntity` or build a fresh +ObjDesc-dressed `AnimatedEntityState` (the player is the camera, so there's no player-as-renderable +today)? `IUiViewportRenderer` timing (post-world pass vs pre-pass)? Scissor infra (the toolkit has no +GL scissor yet — see the collapse spec's clipping discussion). + +--- + +## 6. Build order + dependency graph + +``` +A. window manager (open/close + I-key) ← do first; unblocks B + │ + ▼ +B. inventory window (grid + sub-window mount + wire gaps + InventoryController) + │ └─ also completes B.2's drag-from-inventory (inventory cell = drag source; + │ ToolbarController.HandleDropRelease gains the SourceKind==Inventory branch) + ▼ +C. paperdoll (UiViewport + IUiViewportRenderer seam + PaperDollController) ← heaviest, last +``` + +Critical-path note: A is small and unblocks B; the inventory window (B) is the big one and the drag +*source* that closes the B-stream; the paperdoll (C) is the heaviest and independent enough to come +last. + +--- + +## 7. ⚠ State notes for the fresh session + +- **Start from `main`** (merge `abbd97b`) — it has all of D.5.2 + D.5.4 + this session's D.5.3 B.1/B.2 + + the collapse, plus the indoor-lighting handoff (`f7f3e08`, issues `#142`/`#143` are LIGHTING, not + UI). Create a new worktree off `main`. +- **ISSUES:** `#144` is the (LOW, latent) empty-item-slot click note from B.1; `#141` is the toolbar + selected-object display (D.5.3a done; mana/stack deferred). `#142`/`#143` are the lighting issues. +- The drag spine's `IItemListDragHandler` + `ItemDragPayload` + `ShortcutStore` + the shortcut wire all + exist now — reuse them; don't rebuild. +- Don't re-port what WorldBuilder/the toolkit already gives (read `worldbuilder-inventory.md` first for + any dat/render work). + +--- + +## 8. New-session prompt (paste into a fresh session) + +> Continue acdream's D.2b retail-UI track. **Read `docs/research/2026-06-20-window-manager-inventory-handoff.md` first**, then the 2026-06-16 UI deep-dives + synthesis §4 it references. The drag-drop spine (B.1), toolbar shortcut reorder/remove + wire (B.2), and toolbar collapse all shipped + merged to `main` (`abbd97b`). Next, in order: **(A)** a minimal **window manager** — open/close + an `I`-key toggle for the inventory window (today every retail-UI window is always-on at a hardcoded position; `UiHost` has no open/close API); **(B)** the **inventory window** (`gmInventoryUI` nesting paperdoll/backpack/3D-items — `UiItemList` N-cell grid + sub-window mount in `LayoutImporter` + the inventory wire gaps + `InventoryController`; it's the drag SOURCE that completes B.2's drag-from-inventory via a `SourceKind==Inventory` branch in `ToolbarController.HandleDropRelease`); **(C)** the **paperdoll** `UiViewport` (Type 0xD) 3D doll, with a Core→App `IUiViewportRenderer` seam — heaviest, last. Spell bar DEFERRED. Use the full brainstorm → spec → plan → subagent-driven flow per sub-phase; mandatory grep-named→cross-ref→pseudocode→port for any wire format; conformance tests throughout. Reuse the shipped spine (`IItemListDragHandler`/`ItemDragPayload`/`ShortcutStore`/the AddShortcut/RemoveShortcut wire) — don't rebuild it. Resolve objects via `ClientObjectTable.Get(guid)` / `GetContents(containerGuid)`. Start a new worktree off `main`. + +**MEMORY.md index line:** +- [Handoff: window manager → inventory → paperdoll (2026-06-20)](research/2026-06-20-window-manager-inventory-handoff.md) — next D.2b-UI arc after B.1/B.2/collapse (all merged to main `abbd97b`). 3 sub-phases: (A) window manager (open/close + I-key — UiHost has no open/close API today; windows are always-on); (B) inventory window (Stream C, synthesis §4 — UiItemList grid + sub-window mount + wire gaps DropItem 0x1B/GetAndWieldItem 0x1A/ViewContents 0x196 + InventoryController; the drag SOURCE that closes B.2's drag-from-inventory via a SourceKind==Inventory branch); (C) paperdoll UiViewport Type 0xD + Core→App IUiViewportRenderer seam (heaviest). Reuse the shipped drag spine + ShortcutStore + shortcut wire. Spell bar DEFERRED. From 8f30585cb031e60ec6bbf9133caf54aa04a6fa9f Mon Sep 17 00:00:00 2001 From: Erik Date: Sat, 20 Jun 2026 21:21:29 +0200 Subject: [PATCH 023/133] docs(D.2b-A): window manager + F12 inventory toggle design spec MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Sub-phase A of the window-manager → inventory → paperdoll arc. A named- window registry on UiRoot (Show/Hide/Toggle/BringToFront) + raise-on-click + wiring the EXISTING InputAction.ToggleInventoryPanel (already F12-bound, retail-faithful) through OnInputAction, toggling a throwaway placeholder inventory window that Sub-phase B replaces with the real gmInventoryUI. Cross-check correction vs the handoff: retail's inventory key is F12, not I (I = Laugh emote per retail-default.keymap.txt:246). Reuses the existing action, so no new keybind — rebindable via the Settings panel. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../2026-06-20-d2b-window-manager-design.md | 245 ++++++++++++++++++ 1 file changed, 245 insertions(+) create mode 100644 docs/superpowers/specs/2026-06-20-d2b-window-manager-design.md diff --git a/docs/superpowers/specs/2026-06-20-d2b-window-manager-design.md b/docs/superpowers/specs/2026-06-20-d2b-window-manager-design.md new file mode 100644 index 00000000..201739a0 --- /dev/null +++ b/docs/superpowers/specs/2026-06-20-d2b-window-manager-design.md @@ -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 _windows` plus: + +```csharp +/// 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. +public void RegisterWindow(string name, UiElement window); + +/// Make the named window visible and raise it to the front. +/// No-op if unknown. Returns true if a window was shown. +public bool ShowWindow(string name); + +/// Hide the named window (Visible = false). No-op if unknown. +public bool HideWindow(string name); + +/// Flip the named window's visibility; Show (raise) if it was hidden, +/// Hide if it was visible. Returns the new IsVisible state. +public bool ToggleWindow(string name); + +/// Set element.ZOrder to one above the current max among top-level +/// children, so it paints + hit-tests above its peers. +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`. From 8457bf0006418d67ef429405ce479d02b0400cb6 Mon Sep 17 00:00:00 2001 From: Erik Date: Sat, 20 Jun 2026 21:28:50 +0200 Subject: [PATCH 024/133] docs(D.2b-A): window manager implementation plan 4-task TDD plan for Sub-phase A: UiRoot named-window registry (Show/Hide/Toggle/BringToFront) + raise-on-click + F12 wiring of the existing ToggleInventoryPanel action to a throwaway placeholder inventory window that Sub-phase B replaces. Divergence handled by extending IA-12. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../plans/2026-06-20-d2b-window-manager.md | 472 ++++++++++++++++++ 1 file changed, 472 insertions(+) create mode 100644 docs/superpowers/plans/2026-06-20-d2b-window-manager.md diff --git a/docs/superpowers/plans/2026-06-20-d2b-window-manager.md b/docs/superpowers/plans/2026-06-20-d2b-window-manager.md new file mode 100644 index 00000000..0bd23d3a --- /dev/null +++ b/docs/superpowers/plans/2026-06-20-d2b-window-manager.md @@ -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; + +/// Canonical registry names for top-level retail-UI windows, so the +/// mount, the window registry, and the toggle keybind all agree on one literal. +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 _windows = new(); + + /// 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). + public void RegisterWindow(string name, UiElement window) => _windows[name] = window; + + /// Make the named window visible. No-op (returns false) if unknown. + public bool ShowWindow(string name) + { + if (!_windows.TryGetValue(name, out var w)) return false; + w.Visible = true; + return true; + } + + /// Hide the named window. No-op (returns false) if unknown. + public bool HideWindow(string name) + { + if (!_windows.TryGetValue(name, out var w)) return false; + w.Visible = false; + return true; + } + + /// Flip the named window's visibility (Show if hidden, Hide if shown). + /// Returns the new IsVisible state (false for an unknown name). + 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) " +``` + +--- + +## 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 + + /// 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. + 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) " +``` + +--- + +## 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) ───────────────── + + /// Register a top-level window for Show/Hide/Toggle. See . + public void RegisterWindow(string name, UiElement window) => Root.RegisterWindow(name, window); + + /// Toggle a registered window's visibility; returns the new IsVisible. + 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) " +``` + +--- + +## 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) " +``` + +- [ ] **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. ✓ From 640903857627ee4314705d17182dd1d5f925a042 Mon Sep 17 00:00:00 2001 From: Erik Date: Sat, 20 Jun 2026 21:30:22 +0200 Subject: [PATCH 025/133] =?UTF-8?q?feat(ui):=20D.2b-A=20=E2=80=94=20UiRoot?= =?UTF-8?q?=20named-window=20registry=20(Show/Hide/Toggle)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A Dictionary registry on UiRoot with RegisterWindow + Show/Hide/Toggle. Show/Hide flip UiElement.Visible (already gates Draw/Tick/HitTest); Toggle returns the new visibility; unknown names are no-ops. WindowNames.Inventory const shared by mount/registry/toggle. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/AcDream.App/UI/UiRoot.cs | 36 +++++++++++++++++++ src/AcDream.App/UI/WindowNames.cs | 8 +++++ .../AcDream.App.Tests/UI/UiRootInputTests.cs | 32 +++++++++++++++++ 3 files changed, 76 insertions(+) create mode 100644 src/AcDream.App/UI/WindowNames.cs diff --git a/src/AcDream.App/UI/UiRoot.cs b/src/AcDream.App/UI/UiRoot.cs index b43201dc..381ceea5 100644 --- a/src/AcDream.App/UI/UiRoot.cs +++ b/src/AcDream.App/UI/UiRoot.cs @@ -474,6 +474,42 @@ public sealed class UiRoot : UiElement public void SetCapture(UiElement e) => Captured = e; public void ReleaseCapture() => Captured = null; + // ── Window manager (named top-level windows: Show / Hide / Toggle) ─── + + private readonly Dictionary _windows = new(); + + /// 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). + public void RegisterWindow(string name, UiElement window) => _windows[name] = window; + + /// Make the named window visible. No-op (returns false) if unknown. + public bool ShowWindow(string name) + { + if (!_windows.TryGetValue(name, out var w)) return false; + w.Visible = true; + return true; + } + + /// Hide the named window. No-op (returns false) if unknown. + public bool HideWindow(string name) + { + if (!_windows.TryGetValue(name, out var w)) return false; + w.Visible = false; + return true; + } + + /// Flip the named window's visibility (Show if hidden, Hide if shown). + /// Returns the new IsVisible state (false for an unknown name). + 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; + } + // ── Drag-drop (retail event chain 0x15 → 0x21 → 0x1C → 0x3E) ──────── private void BeginDrag(UiElement source) diff --git a/src/AcDream.App/UI/WindowNames.cs b/src/AcDream.App/UI/WindowNames.cs new file mode 100644 index 00000000..34cf5c43 --- /dev/null +++ b/src/AcDream.App/UI/WindowNames.cs @@ -0,0 +1,8 @@ +namespace AcDream.App.UI; + +/// Canonical registry names for top-level retail-UI windows, so the +/// mount, the window registry, and the toggle keybind all agree on one literal. +public static class WindowNames +{ + public const string Inventory = "inventory"; +} diff --git a/tests/AcDream.App.Tests/UI/UiRootInputTests.cs b/tests/AcDream.App.Tests/UI/UiRootInputTests.cs index 9f326919..55048e6a 100644 --- a/tests/AcDream.App.Tests/UI/UiRootInputTests.cs +++ b/tests/AcDream.App.Tests/UI/UiRootInputTests.cs @@ -257,4 +257,36 @@ public class UiRootInputTests Assert.Equal(8f, x); Assert.Equal(24f, y); Assert.Equal(200f, w); Assert.Equal(14f, h); } + + [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")); + } } From 036db8b8ab1eb7062a44fffa79bd8de0b40be183 Mon Sep 17 00:00:00 2001 From: Erik Date: Sat, 20 Jun 2026 21:31:29 +0200 Subject: [PATCH 026/133] =?UTF-8?q?feat(ui):=20D.2b-A=20=E2=80=94=20BringT?= =?UTF-8?q?oFront=20+=20raise=20on=20show/click?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit BringToFront sets a window's ZOrder one past the max among its peers. ShowWindow now raises on open; OnMouseDown raises any pressed top-level window (retail-faithful stacking). Existing drag/resize tests unaffected (raise only touches ZOrder, not geometry). Co-Authored-By: Claude Opus 4.8 (1M context) --- src/AcDream.App/UI/UiRoot.cs | 15 +++++++ .../AcDream.App.Tests/UI/UiRootInputTests.cs | 41 +++++++++++++++++++ 2 files changed, 56 insertions(+) diff --git a/src/AcDream.App/UI/UiRoot.cs b/src/AcDream.App/UI/UiRoot.cs index 381ceea5..11d35b55 100644 --- a/src/AcDream.App/UI/UiRoot.cs +++ b/src/AcDream.App/UI/UiRoot.cs @@ -248,6 +248,8 @@ public sealed class UiRoot : UiElement // A left-drag starting near an edge resizes; interior drag repositions; // otherwise it's a normal drag-drop candidate. 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) { var edges = window.Resizable ? HitEdges(window, x, y, ResizeGrip) : ResizeEdges.None; @@ -489,6 +491,7 @@ public sealed class UiRoot : UiElement { if (!_windows.TryGetValue(name, out var w)) return false; w.Visible = true; + BringToFront(w); return true; } @@ -510,6 +513,18 @@ public sealed class UiRoot : UiElement return true; } + /// 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. + 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; + } + // ── Drag-drop (retail event chain 0x15 → 0x21 → 0x1C → 0x3E) ──────── private void BeginDrag(UiElement source) diff --git a/tests/AcDream.App.Tests/UI/UiRootInputTests.cs b/tests/AcDream.App.Tests/UI/UiRootInputTests.cs index 55048e6a..e31da6c7 100644 --- a/tests/AcDream.App.Tests/UI/UiRootInputTests.cs +++ b/tests/AcDream.App.Tests/UI/UiRootInputTests.cs @@ -289,4 +289,45 @@ public class UiRootInputTests Assert.False(root.HideWindow("nope")); Assert.False(root.ToggleWindow("nope")); } + + [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); + } } From 882b4dd5d36e98c860c16e706063ccb65427081a Mon Sep 17 00:00:00 2001 From: Erik Date: Sat, 20 Jun 2026 21:32:17 +0200 Subject: [PATCH 027/133] =?UTF-8?q?feat(ui):=20D.2b-A=20=E2=80=94=20F12=20?= =?UTF-8?q?toggles=20placeholder=20inventory=20window?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit UiHost RegisterWindow/ToggleWindow forwarders to UiRoot. A default-hidden placeholder inventory window mounts in the RetailUi block + registers as WindowNames.Inventory. OnInputAction handles the existing F12-bound ToggleInventoryPanel action -> _uiHost.ToggleWindow (no-op when retail UI off; gated by WantsKeyboard so it can't fire while typing in chat). Placeholder is throwaway scaffolding; Sub-phase B swaps in the real gmInventoryUI (0x21000023) under the same registry name. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/AcDream.App/Rendering/GameWindow.cs | 20 ++++++++++++++++++++ src/AcDream.App/UI/UiHost.cs | 8 ++++++++ 2 files changed, 28 insertions(+) diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs index bba630e7..7c6bfd22 100644 --- a/src/AcDream.App/Rendering/GameWindow.cs +++ b/src/AcDream.App/Rendering/GameWindow.cs @@ -2137,6 +2137,19 @@ public sealed class GameWindow : IDisposable } } } + + // 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)."); } // Phase N.4+N.5 — WB rendering pipeline foundation. The modern path is @@ -11420,6 +11433,13 @@ public sealed class GameWindow : IDisposable switch (action) { + 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; + case AcDream.UI.Abstractions.Input.InputAction.AcdreamToggleDebugPanel: foreach (var panel in EnumerateDebugPanel()) { diff --git a/src/AcDream.App/UI/UiHost.cs b/src/AcDream.App/UI/UiHost.cs index 718d5cbd..8e4c66f3 100644 --- a/src/AcDream.App/UI/UiHost.cs +++ b/src/AcDream.App/UI/UiHost.cs @@ -103,6 +103,14 @@ public sealed class UiHost : System.IDisposable _ => UiMouseButton.Left, }; + // ── Window manager forwarders (delegate to UiRoot) ───────────────── + + /// Register a top-level window for Show/Hide/Toggle. See . + public void RegisterWindow(string name, UiElement window) => Root.RegisterWindow(name, window); + + /// Toggle a registered window's visibility; returns the new IsVisible. + public bool ToggleWindow(string name) => Root.ToggleWindow(name); + public void Dispose() { TextRenderer.Dispose(); From e3152ade9ab63c0021a66e96c2af28fdcf0a6185 Mon Sep 17 00:00:00 2001 From: Erik Date: Sat, 20 Jun 2026 21:33:04 +0200 Subject: [PATCH 028/133] =?UTF-8?q?docs(register):=20D.2b-A=20=E2=80=94=20?= =?UTF-8?q?extend=20IA-12=20to=20cite=20the=20window=20manager?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The UiRoot window manager (RegisterWindow/Show/Hide/Toggle/BringToFront) is more of the same IA-12 toolkit-reimplementation of keystone semantics, so per the dedup convention it joins IA-12's Where as a secondary site rather than getting a redundant row. F12 inventory toggle is retail- faithful (no divergence). Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/architecture/retail-divergence-register.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/architecture/retail-divergence-register.md b/docs/architecture/retail-divergence-register.md index 48d38e34..db59e18a 100644 --- a/docs/architecture/retail-divergence-register.md +++ b/docs/architecture/retail-divergence-register.md @@ -52,7 +52,7 @@ accepted-divergence entries (#96, #49, #50). | IA-9 | One unified camera matrix for terrain — retail's separate `LScape::update_viewpoint` landscape viewpoint does not exist | `src/AcDream.App/Rendering/TerrainModernRenderer.cs:266` | Phase W T4.2: with one matrix everywhere, viewpoint-desync bugs are unrepresentable — the unification IS the correctness argument | Anything retail derives from the landcell-relative viewpoint (float precision at extreme coords, viewpoint-keyed state) has no analogue; a future port expecting it silently reads the camera | `LScape::update_viewpoint`; `LScape::draw` 0x00506330 | | IA-10 | Transparent groups sorted back-to-front per GROUP by first-instance position (no within-group sort) vs retail per-poly BSP-order draw | `src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs:1364` (comparer :1662) | One MDI call per pass requires group-granularity ordering; per-poly sorting is incompatible with instanced multi-draw; works when group instances are spatially coherent | Spatially spread or interleaved transparent groups composite in the wrong order — popping / wrong see-through layering as the camera moves | retail per-poly BSP-order transparent draw (D3DPolyRender / PView::DrawCells) | | IA-11 | Tier-1 cross-frame batch-classification cache for static entities (retail re-walks part arrays every frame) | `src/AcDream.App/Rendering/Wb/EntityClassificationCache.cs:12` | Issue #53 perf tier; invariants documented (keys = EntityId + OWNING-landblock hint post-**#119** fix `2163308`; invalidation at despawn/LB-unload; mutation audit 2026-05-10) | Key collision or missed invalidation serves one entity another's batches — session-sticky wrong meshes (the #119 broken-stairs/water-barrel symptom) | retail per-frame part-array classification (no cache) | -| 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 | +| 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 | | IA-13 | GameEventType registry deliberately omits event types retail ignores; unknown events fall through unhandled | `src/AcDream.Core.Net/Messages/GameEventType.cs:11` | Retail also ignores them — dropping matches retail by construction | If the "retail ignores X" judgment is wrong for any opcode (or a server mod uses one), the event is silently dropped with no diagnostic pointing at the omission | retail GameEvent dispatch (ignored-event set) | | IA-14 | Rendering + dat-handling base is WorldBuilder's tested port, not a fresh retail-decomp port (Phase N.4/O design stance) | `docs/architecture/worldbuilder-inventory.md` (code at `src/AcDream.{Core,App}/Rendering/Wb/`) | WB visually verified on the AC world, MIT, same stack; known WB↔retail deltas resolved case-by-case — terrain split kept retail `FSplitNESW` (**#51**, pinned by `SplitFormulaDivergenceTest`), scenery drift accepted (AP-31) | A WB-upstream divergence not yet caught ships silently as "our" behavior; guard = the inventory doc's 🟢/🔴 split + per-formula divergence tests | retail decomp per algorithm; `tests/.../SplitFormulaDivergenceTest.cs` | | IA-15 | D.2b retail UI is our own UiHost/UiElement retained-mode tree drawing dat-sprite window frames, not a byte-port of keystone.dll's LayoutDesc binary tree. Both the vitals window (`LayoutDesc 0x2100006C`) and the chat window (`LayoutDesc 0x21000006`) are rendered by the LayoutDesc importer; `UiNineSlicePanel`/`RetailChromeSprites` now back only plugin panels | `src/AcDream.App/UI/Layout/LayoutImporter.cs` (vitals + chat) + `src/AcDream.App/UI/Layout/ChatWindowController.cs` | keystone.dll has no PDB/decomp so a byte-port is impossible by definition; we mirror retail's ElementDesc field model + controls.ini tokens, and the chrome sprites ARE the real dat RenderSurfaces (Step-0 prove-out 2026-06-14 confirmed 0x06004CC2 center + 0x060074BF..C6 bevel). The 8-piece edge/corner→position mapping is DATA-DRIVEN from the dat: the `LayoutImporter` reads `LayoutDesc 0x2100006C`/`0x21000006` and resolves chrome element positions + sprite ids directly from parsed dat fields; vitals locked by the conformance fixture `tests/AcDream.App.Tests/UI/Layout/fixtures/vitals_2100006C.json` | Remaining residual risk: anchor resolution at non-800×600 and the controls.ini cascade still lack an oracle — layout scaling at non-reference resolution and stylesheet token inheritance differ silently | `LayoutDesc 0x2100006C`/`0x21000006` (SHIPPED); `docs/research/2026-06-15-layoutdesc-format.md`; controls.ini tokens; keystone.dll layout eval (no PDB) | From aa6061591264f52c66e5fa142d97d8a8627d937f Mon Sep 17 00:00:00 2001 From: Erik Date: Sat, 20 Jun 2026 22:25:50 +0200 Subject: [PATCH 029/133] =?UTF-8?q?docs(D.2b-B):=20B-Grid=20design=20spec?= =?UTF-8?q?=20=E2=80=94=20inventory=20sub-window=20mount=20+=20grid=20mode?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit First step of Sub-phase B (inventory window). Dumped LayoutDesc 0x21000023: the three nested panels (paperdoll/backpack/3D-items) are Type-0 pure- container leaves that nest via the EXISTING BaseElement+BaseLayoutId inheritance path (BaseLayoutId → 0x21000024/22/21), NOT game-class Types as the research agent claimed. ElementReader.Merge drops base children, so they import empty today. Mount = a surgical ~4-line LayoutImporter.Resolve change: a childless, media-less inheritor attaches its base's resolved subtree (predicate ShouldMountBaseChildren, unit-testable; inert for media-bearing inheritors + childless style prototypes, so vitals/chat/toolbar are unaffected). Plus UiItemList N-cell grid mode (single-cell toolbar preserved). Co-Authored-By: Claude Opus 4.8 (1M context) --- ...6-06-20-d2b-inventory-grid-mount-design.md | 235 ++++++++++++++++++ 1 file changed, 235 insertions(+) create mode 100644 docs/superpowers/specs/2026-06-20-d2b-inventory-grid-mount-design.md diff --git a/docs/superpowers/specs/2026-06-20-d2b-inventory-grid-mount-design.md b/docs/superpowers/specs/2026-06-20-d2b-inventory-grid-mount-design.md new file mode 100644 index 00000000..2aa129cc --- /dev/null +++ b/docs/superpowers/specs/2026-06-20-d2b-inventory-grid-mount-design.md @@ -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? baseChildren = null; + + if (d.BaseElement != 0 && d.BaseLayoutId != 0 + && baseChain.Add((d.BaseLayoutId, d.BaseElement))) + { + var baseLd = dats.Get(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; +} + +/// 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. +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 "" 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). From 132bf36daa7b775e04c8544a808e1923e2cfc33c Mon Sep 17 00:00:00 2001 From: Erik Date: Sat, 20 Jun 2026 22:32:37 +0200 Subject: [PATCH 030/133] docs(D.2b-B): B-Grid implementation plan 4-task TDD plan: UiItemList grid mode (CellOffset/Columns/cell pitch), the LayoutImporter.Resolve sub-window mount (ShouldMountBaseChildren + attach base subtree), GameWindow swap of the placeholder for the real Import(0x21000023), then full-suite regression guard + visual. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../2026-06-20-d2b-inventory-grid-mount.md | 390 ++++++++++++++++++ 1 file changed, 390 insertions(+) create mode 100644 docs/superpowers/plans/2026-06-20-d2b-inventory-grid-mount.md diff --git a/docs/superpowers/plans/2026-06-20-d2b-inventory-grid-mount.md b/docs/superpowers/plans/2026-06-20-d2b-inventory-grid-mount.md new file mode 100644 index 00000000..69fbefb9 --- /dev/null +++ b/docs/superpowers/plans/2026-06-20-d2b-inventory-grid-mount.md @@ -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 + /// Grid columns (row-major). 1 = single column. Ignored in fill mode. + public int Columns { get; set; } = 1; + + /// 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. + public float CellWidth { get; set; } + /// Fixed cell height in grid mode (pairs with CellWidth). + public float CellHeight { get; set; } + + /// Row-major pixel offset of cell in a grid of + /// columns at the given cell pitch. + 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); + } + + /// 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. + 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) " +``` + +--- + +## 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 + /// 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). + 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? 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(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) " +``` + +--- + +## 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) " +``` + +--- + +## 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). ✓ From 4fd4b09f3f5416d78e3ba62a1f40be2725d112ff Mon Sep 17 00:00:00 2001 From: Erik Date: Sat, 20 Jun 2026 22:34:30 +0200 Subject: [PATCH 031/133] =?UTF-8?q?feat(ui):=20D.2b-B=20=E2=80=94=20UiItem?= =?UTF-8?q?List=20N-cell=20grid=20mode?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Columns + CellWidth/CellHeight + a row-major CellOffset/LayoutCells pass. CellWidth<=0 keeps the single-cell fill mode (toolbar slot sizes to the list — unchanged); CellWidth>0 tiles cells in a grid. Layout runs on AddItem and per-frame in OnDraw so a list resize reflows. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/AcDream.App/UI/UiItemList.cs | 53 +++++++++++++++---- .../UI/UiItemListGridTests.cs | 43 +++++++++++++++ 2 files changed, 86 insertions(+), 10 deletions(-) create mode 100644 tests/AcDream.App.Tests/UI/UiItemListGridTests.cs diff --git a/src/AcDream.App/UI/UiItemList.cs b/src/AcDream.App/UI/UiItemList.cs index 6c4ce9bf..f550e53c 100644 --- a/src/AcDream.App/UI/UiItemList.cs +++ b/src/AcDream.App/UI/UiItemList.cs @@ -49,9 +49,48 @@ public sealed class UiItemList : UiElement public void AddItem(UiItemSlot cell) { cell.SpriteResolve ??= SpriteResolve; - cell.Left = 0; cell.Top = 0; cell.Width = Width; cell.Height = Height; _cells.Add(cell); AddChild(cell); + LayoutCells(); + } + + /// Grid columns (row-major). 1 = single column. Ignored in fill mode. + public int Columns { get; set; } = 1; + + /// 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. + public float CellWidth { get; set; } + /// Fixed cell height in grid mode (pairs with CellWidth). + public float CellHeight { get; set; } + + /// Row-major pixel offset of cell in a grid of + /// columns at the given cell pitch. + 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); + } + + /// 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. + 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; + } } public void Flush() @@ -62,14 +101,8 @@ public sealed class UiItemList : UiElement protected override void OnDraw(UiRenderContext ctx) { - // The factory sets THIS list's Width/Height AFTER construction, so the cell - // (added in the ctor) starts 0x0. For the single-cell toolbar slot, keep the - // cell sized to the list each frame; the cell paints itself in the children - // pass that follows. (N-cell grid layout is the inventory phase.) - if (_cells.Count > 0) - { - var cell = _cells[0]; - cell.Left = 0; cell.Top = 0; cell.Width = Width; cell.Height = Height; - } + // 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(); } } diff --git a/tests/AcDream.App.Tests/UI/UiItemListGridTests.cs b/tests/AcDream.App.Tests/UI/UiItemListGridTests.cs new file mode 100644 index 00000000..4053eac1 --- /dev/null +++ b/tests/AcDream.App.Tests/UI/UiItemListGridTests.cs @@ -0,0 +1,43 @@ +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); + } +} From 85098f535de957af8506105a0bbf6fb51dc60cd5 Mon Sep 17 00:00:00 2001 From: Erik Date: Sat, 20 Jun 2026 22:36:20 +0200 Subject: [PATCH 032/133] =?UTF-8?q?feat(ui):=20D.2b-B=20=E2=80=94=20sub-wi?= =?UTF-8?q?ndow=20mount=20(inheritor=20attaches=20base=20subtree)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit LayoutImporter.Resolve now captures the resolved base element's children and, for a pure-container leaf (no own children + no own media) inheriting from a base WITH content, attaches that subtree. This pulls the nested gmInventoryUI panels' content (paperdoll/backpack/3D-items) in through the existing BaseElement+BaseLayoutId path. ShouldMountBaseChildren is a pure, unit-tested predicate; it's inert for media-bearing inheritors and childless style prototypes, so vitals/chat/toolbar are unaffected (existing importer tests still green). Co-Authored-By: Claude Opus 4.8 (1M context) --- src/AcDream.App/UI/Layout/LayoutImporter.cs | 22 +++++++++++++++++-- .../UI/LayoutImporterMountTests.cs | 22 +++++++++++++++++++ 2 files changed, 42 insertions(+), 2 deletions(-) create mode 100644 tests/AcDream.App.Tests/UI/LayoutImporterMountTests.cs diff --git a/src/AcDream.App/UI/Layout/LayoutImporter.cs b/src/AcDream.App/UI/Layout/LayoutImporter.cs index 6a3cdd1e..aa6264c9 100644 --- a/src/AcDream.App/UI/Layout/LayoutImporter.cs +++ b/src/AcDream.App/UI/Layout/LayoutImporter.cs @@ -191,6 +191,14 @@ public static class LayoutImporter // ── Inheritance resolution ──────────────────────────────────────────────── + /// 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 — the base prototype has no children). + internal static bool ShouldMountBaseChildren(int derivedChildCount, int derivedMediaCount, int baseChildCount) + => derivedChildCount == 0 && derivedMediaCount == 0 && baseChildCount > 0; + /// /// Converts an to a resolved : /// reads own fields + media, applies the BaseElement / BaseLayoutId chain @@ -204,6 +212,7 @@ public static class LayoutImporter // Read this element's own fields + media (no inheritance, no children yet). var self = ToInfo(d); var result = self; + List? baseChildren = null; // Apply BaseElement / BaseLayoutId inheritance if present. if (d.BaseElement != 0 && d.BaseLayoutId != 0 @@ -215,9 +224,9 @@ public static class LayoutImporter { // Recurse the base chain (already guarded by the HashSet add above). var baseInfo = Resolve(dats, baseDesc, baseChain); - // Derived fields override the base; result.Children is still empty here - // — children are attached below from the DERIVED element's own tree. + // Derived fields override the base; children are attached below. result = ElementReader.Merge(baseInfo, self); + baseChildren = baseInfo.Children; // capture for the sub-window mount } } @@ -226,6 +235,15 @@ public static class LayoutImporter 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; } diff --git a/tests/AcDream.App.Tests/UI/LayoutImporterMountTests.cs b/tests/AcDream.App.Tests/UI/LayoutImporterMountTests.cs new file mode 100644 index 00000000..eca936e9 --- /dev/null +++ b/tests/AcDream.App.Tests/UI/LayoutImporterMountTests.cs @@ -0,0 +1,22 @@ +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)); +} From 7d971a2b3ea012d8074300b7db8758812f95603a Mon Sep 17 00:00:00 2001 From: Erik Date: Sat, 20 Jun 2026 22:37:01 +0200 Subject: [PATCH 033/133] =?UTF-8?q?feat(ui):=20D.2b-B=20=E2=80=94=20F12=20?= =?UTF-8?q?shows=20the=20real=20gmInventoryUI=20(0x21000023)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Swap the Sub-phase A placeholder UiNineSlicePanel for LayoutImporter.Import (0x21000023). The sub-window mount nests the paperdoll/backpack/3D-items panels; the window starts hidden, registered under WindowNames.Inventory, toggled by F12 via the window manager (unchanged). Co-Authored-By: Claude Opus 4.8 (1M context) --- src/AcDream.App/Rendering/GameWindow.cs | 29 +++++++++++++++---------- 1 file changed, 18 insertions(+), 11 deletions(-) diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs index 7c6bfd22..e04a1622 100644 --- a/src/AcDream.App/Rendering/GameWindow.cs +++ b/src/AcDream.App/Rendering/GameWindow.cs @@ -2138,18 +2138,25 @@ public sealed class GameWindow : IDisposable } } - // 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) + // 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) { - 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)."); + 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."); } // Phase N.4+N.5 — WB rendering pipeline foundation. The modern path is From d81ea11a3104d2032fcef0d386189a3c0d1d7a18 Mon Sep 17 00:00:00 2001 From: Erik Date: Sat, 20 Jun 2026 22:58:11 +0200 Subject: [PATCH 034/133] =?UTF-8?q?docs(issues):=20#145=20=E2=80=94=20inve?= =?UTF-8?q?ntory=20panels=20occluded=20by=20full-window=20backdrop?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit B-Grid sub-window mount works (paperdoll base 0x100001D4 has 25+ equip-slot children, attached), but the gmInventoryUI panels render blank: the importer maps ZOrder from ReadOrder only, so the full-window backdrop (0x100001D0, ReadOrder 4) paints over the panels (ReadOrder 1-3). Retail keeps it behind via ZLevel (backdrop 100 vs panels 0), which the importer ignores (vitals were all ZLevel 0). Fix scoped in the issue. Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/ISSUES.md | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/docs/ISSUES.md b/docs/ISSUES.md index 035041d4..d86ee6b0 100644 --- a/docs/ISSUES.md +++ b/docs/ISSUES.md @@ -46,6 +46,25 @@ Copy this block when adding a new issue: --- +## #145 — Inventory window panels occluded by the full-window backdrop (importer ignores ZLevel) + +**Status:** OPEN +**Severity:** MEDIUM (blocks B-Grid visual acceptance — the gmInventoryUI nested panels render blank) +**Filed:** 2026-06-20 +**Component:** ui — LayoutImporter / DatWidgetFactory z-order + +**Description:** With `ACDREAM_RETAIL_UI=1`, F12 shows the `gmInventoryUI` (`0x21000023`) frame + title + the dark full-window backdrop sprite, but the three nested panels (paperdoll / backpack / 3D-items) render **blank**. The B-Grid sub-window mount IS attaching the panels' content — confirmed: the paperdoll base element `0x100001D4` (root of `0x21000024`) has 25+ equip-slot children — but the content is **occluded**. + +**Root cause / status:** `DatWidgetFactory.Create` maps `ZOrder ← ReadOrder` only (`~:81`). In `0x21000023` the full-window backdrop element `0x100001D0` has `ReadOrder=4` (drawn AFTER the panels at `ReadOrder 1/2/3`), so it paints OVER them. Retail keeps the backdrop behind via **`ZLevel`** (backdrop `ZLevel=100` vs panels `ZLevel=0`); the importer ignores `ZLevel` because every vitals element was `ZLevel=0`, so it never mattered until now. Fix: factory `ZOrder` should honor `ZLevel` (higher `ZLevel` = further back) with `ReadOrder` as the tiebreaker — e.g. `ZOrder = ReadOrder − ZLevel·K`. `ElementInfo` has no `ZLevel` field yet (add it + read in `ElementReader.ToInfo` + carry in `Merge`). **Regression risk:** touches z-order for every window — must verify chat (`0x21000006`) + toolbar (`0x21000016`) `ZLevel`s (vitals are all 0) and visually regression-test. + +**Files:** `src/AcDream.App/UI/Layout/DatWidgetFactory.cs` (`e.ZOrder = (int)info.ReadOrder`, ~:81); `src/AcDream.App/UI/Layout/ElementReader.cs` (`ElementInfo` — add `ZLevel`; `ToInfo`; `Merge`); `src/AcDream.App/UI/Layout/LayoutImporter.cs`. + +**Research:** `docs/superpowers/specs/2026-06-20-d2b-inventory-grid-mount-design.md` (B-Grid); dat dump of `0x21000023` (panels ReadOrder 1-3 / ZLevel 0; backdrop `0x100001D0` ReadOrder 4 / ZLevel 100). + +**Acceptance:** F12 shows the nested paperdoll/backpack/3D-items panels over the backdrop (backpack burden meter + slot borders visible); vitals/chat/toolbar unchanged; full suite green. + +--- + ## #142 — Windowed-building interiors read "like outdoors" (indoor lighting regime is per-frame, not per-stage) **Status:** OPEN From 45a5cc5bb67aba3fee3a9e9cb07f04cfbacf5e23 Mon Sep 17 00:00:00 2001 From: Erik Date: Sun, 21 Jun 2026 07:22:26 +0200 Subject: [PATCH 035/133] =?UTF-8?q?fix(ui):=20#145=20=E2=80=94=20importer?= =?UTF-8?q?=20honors=20ZLevel=20so=20the=20backdrop=20sits=20behind=20pane?= =?UTF-8?q?ls?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The factory mapped ZOrder from ReadOrder only, so the gmInventoryUI full-window backdrop (0x100001D0, ReadOrder 4) painted over the nested panels (ReadOrder 1-3). Wire ElementDesc.ZLevel through ElementInfo / ToInfo / Merge and fold it into ZOrder = ReadOrder - ZLevel*10000 (higher ZLevel = further back, ReadOrder the within-layer tiebreaker). Vitals (all ZLevel 0) are unchanged; chat (ZLevel 900) + toolbar (1,2) shift to their dat layering — verify visually. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/AcDream.App/UI/Layout/DatWidgetFactory.cs | 7 ++- src/AcDream.App/UI/Layout/ElementReader.cs | 7 +++ src/AcDream.App/UI/Layout/LayoutImporter.cs | 1 + .../UI/DatWidgetFactoryZOrderTests.cs | 43 +++++++++++++++++++ 4 files changed, 56 insertions(+), 2 deletions(-) create mode 100644 tests/AcDream.App.Tests/UI/DatWidgetFactoryZOrderTests.cs diff --git a/src/AcDream.App/UI/Layout/DatWidgetFactory.cs b/src/AcDream.App/UI/Layout/DatWidgetFactory.cs index 287971fe..2fb75977 100644 --- a/src/AcDream.App/UI/Layout/DatWidgetFactory.cs +++ b/src/AcDream.App/UI/Layout/DatWidgetFactory.cs @@ -77,8 +77,11 @@ public static class DatWidgetFactory e.Width = info.Width; e.Height = info.Height; - // Honor the dat's draw order so overlapping pieces (grip overlay over bevel chrome) layer correctly. - e.ZOrder = (int)info.ReadOrder; + // Honor the dat's draw order. ZLevel is the primary layer (higher = further BACK — e.g. the + // gmInventoryUI full-window backdrop at ZLevel 100 sits behind the ZLevel-0 panels, #145); + // ReadOrder is the within-layer tiebreaker (higher = on top). K=10000 exceeds any window's + // element count so ZLevel always dominates. Vitals (all ZLevel 0) keep ZOrder == ReadOrder. + e.ZOrder = (int)info.ReadOrder - (int)info.ZLevel * 10000; // Map the four raw edge-anchor values to the AnchorEdges bit-flag that the // UI layout engine uses for reflow. diff --git a/src/AcDream.App/UI/Layout/ElementReader.cs b/src/AcDream.App/UI/Layout/ElementReader.cs index 93a4eb30..bbafcd05 100644 --- a/src/AcDream.App/UI/Layout/ElementReader.cs +++ b/src/AcDream.App/UI/Layout/ElementReader.cs @@ -39,6 +39,12 @@ public sealed class ElementInfo /// Draw order within the parent (lower = drawn first / behind). public uint ReadOrder; + /// Layer level from the dat (ElementDesc.ZLevel). Higher = drawn further + /// BACK (a full-window backdrop at ZLevel 100 sits behind ZLevel-0 panels). The factory + /// folds it into so ZLevel dominates and ReadOrder is the + /// within-layer tiebreaker. Issue #145 (vitals are all ZLevel 0, so they're unaffected). + public uint ZLevel; + /// /// Font dat object id inherited from the base element's Properties[0x1A] /// (ArrayBaseProperty → DataIdBaseProperty). 0 = none / not inherited. @@ -152,6 +158,7 @@ public static class ElementReader Right = derived.Right, Bottom = derived.Bottom, ReadOrder = derived.ReadOrder, + ZLevel = derived.ZLevel != 0 ? derived.ZLevel : base_.ZLevel, FontDid = derived.FontDid != 0 ? derived.FontDid : base_.FontDid, // DefaultStateName: derived wins if set; otherwise inherit the base's default. DefaultStateName = !string.IsNullOrEmpty(derived.DefaultStateName) ? derived.DefaultStateName : base_.DefaultStateName, diff --git a/src/AcDream.App/UI/Layout/LayoutImporter.cs b/src/AcDream.App/UI/Layout/LayoutImporter.cs index aa6264c9..26c63465 100644 --- a/src/AcDream.App/UI/Layout/LayoutImporter.cs +++ b/src/AcDream.App/UI/Layout/LayoutImporter.cs @@ -270,6 +270,7 @@ public static class LayoutImporter Right = d.RightEdge, Bottom = d.BottomEdge, ReadOrder = d.ReadOrder, + ZLevel = d.ZLevel, DefaultStateName = (defState is "Undef" or "Undefined" or "0") ? "" : defState, }; diff --git a/tests/AcDream.App.Tests/UI/DatWidgetFactoryZOrderTests.cs b/tests/AcDream.App.Tests/UI/DatWidgetFactoryZOrderTests.cs new file mode 100644 index 00000000..52ba5332 --- /dev/null +++ b/tests/AcDream.App.Tests/UI/DatWidgetFactoryZOrderTests.cs @@ -0,0 +1,43 @@ +using AcDream.App.UI.Layout; + +namespace AcDream.App.Tests.UI; + +public class DatWidgetFactoryZOrderTests +{ + private static (uint, int, int) NoChrome(uint id) => (0u, 0, 0); + + [Fact] + public void ZOrder_HigherZLevel_DrawsBehind() + { + // The gmInventoryUI backdrop (ZLevel 100, ReadOrder 4) must draw BEHIND the panels + // (ZLevel 0, ReadOrder 1): lower ZOrder = drawn first = behind. Before the fix, ZOrder + // came from ReadOrder only, so the backdrop (4) painted over the panels (1). + var backdrop = new ElementInfo { Type = 3, ReadOrder = 4, ZLevel = 100 }; + var panel = new ElementInfo { Type = 3, ReadOrder = 1, ZLevel = 0 }; + + var b = DatWidgetFactory.Create(backdrop, NoChrome, null); + var p = DatWidgetFactory.Create(panel, NoChrome, null); + + Assert.True(b!.ZOrder < p!.ZOrder, $"backdrop ZOrder {b.ZOrder} should be < panel ZOrder {p.ZOrder}"); + } + + [Fact] + public void ZOrder_AllZeroZLevel_EqualsReadOrder() + { + // Regression guard: a vitals-style window (every element ZLevel 0) keeps ZOrder == ReadOrder. + var e = new ElementInfo { Type = 3, ReadOrder = 5, ZLevel = 0 }; + var w = DatWidgetFactory.Create(e, NoChrome, null); + Assert.Equal(5, w!.ZOrder); + } + + [Fact] + public void ZOrder_SameZLevel_OrderedByReadOrder() + { + // Within one ZLevel, ReadOrder is the tiebreaker (higher ReadOrder draws on top). + var lo = new ElementInfo { Type = 3, ReadOrder = 2, ZLevel = 2 }; + var hi = new ElementInfo { Type = 3, ReadOrder = 7, ZLevel = 2 }; + var l = DatWidgetFactory.Create(lo, NoChrome, null); + var h = DatWidgetFactory.Create(hi, NoChrome, null); + Assert.True(l!.ZOrder < h!.ZOrder); + } +} From 4904ff4e21014231770cab4a579eb81f4469094a Mon Sep 17 00:00:00 2001 From: Erik Date: Sun, 21 Jun 2026 07:36:55 +0200 Subject: [PATCH 036/133] =?UTF-8?q?docs(issues):=20#145=20DONE=20=E2=80=94?= =?UTF-8?q?=20ZLevel=20fix=20renders=20the=20inventory=20panels?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Occlusion fixed (45a5cc5); paperdoll equip-slot positions visually confirmed. Remaining gaps are next-sub-step content/art, not occlusion: backpack/3D-items population → B-Controller; per-slot paperdoll silhouettes → Sub-phase C. Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/ISSUES.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/ISSUES.md b/docs/ISSUES.md index d86ee6b0..739c1619 100644 --- a/docs/ISSUES.md +++ b/docs/ISSUES.md @@ -48,7 +48,7 @@ Copy this block when adding a new issue: ## #145 — Inventory window panels occluded by the full-window backdrop (importer ignores ZLevel) -**Status:** OPEN +**Status:** DONE (2026-06-21 · `45a5cc5`) — occlusion fixed; the importer now folds `ZLevel` into `ZOrder`, so the backdrop sits behind the panels and they render (paperdoll equip-slot positions visually confirmed). The remaining gaps are NOT occlusion: empty backpack/3D-items content → B-Controller; per-slot paperdoll silhouettes (equip slots fall back to a generic `UiDatElement` sprite — they need `0x10000032` UiItemSlot + per-slot art) → Sub-phase C. Verify chat/toolbar (ZLevel 900 / 1,2) showed no regression. **Severity:** MEDIUM (blocks B-Grid visual acceptance — the gmInventoryUI nested panels render blank) **Filed:** 2026-06-20 **Component:** ui — LayoutImporter / DatWidgetFactory z-order From 4e23a7b9bc391ed9ad12b89579f1b98c61a0ffcf Mon Sep 17 00:00:00 2001 From: Erik Date: Sun, 21 Jun 2026 07:52:40 +0200 Subject: [PATCH 037/133] docs(handoff): D.2b A+B-Grid shipped, #145 fixed, B-Controller next Session handoff: window manager (F12) + inventory sub-window mount (inheritance-children) + UiItemList grid all shipped; #145 ZLevel z-order fixed (backdrop behind panels). B-Controller next (populate grids + burden meter from ClientObjectTable). Key discoveries, B-Controller readiness, and the new-session prompt captured. Co-Authored-By: Claude Opus 4.8 (1M context) --- ...-bgrid-shipped-bcontroller-next-handoff.md | 173 ++++++++++++++++++ 1 file changed, 173 insertions(+) create mode 100644 docs/research/2026-06-21-d2b-bgrid-shipped-bcontroller-next-handoff.md diff --git a/docs/research/2026-06-21-d2b-bgrid-shipped-bcontroller-next-handoff.md b/docs/research/2026-06-21-d2b-bgrid-shipped-bcontroller-next-handoff.md new file mode 100644 index 00000000..785859ba --- /dev/null +++ b/docs/research/2026-06-21-d2b-bgrid-shipped-bcontroller-next-handoff.md @@ -0,0 +1,173 @@ +# Handoff — D.2b inventory: A + B-Grid shipped, #145 fixed, B-Controller next + +**Date:** 2026-06-21 +**From:** the session that shipped the **window manager (Sub-phase A)**, **B-Grid** (inventory +sub-window mount + grid), and fixed **#145** (ZLevel z-order). All committed on branch +`claude/hopeful-maxwell-214a12`, tip `4904ff4`. Full suite green (2767). +**Predecessor handoff:** `docs/research/2026-06-20-window-manager-inventory-handoff.md` (the +A→B→C arc). This doc supersedes it for "what's next." +**Line numbers drift — grep the symbol.** + +--- + +## 0. What shipped this session + +- **Sub-phase A — window manager.** `UiRoot` named-window registry (`RegisterWindow` / + `ShowWindow` / `HideWindow` / `ToggleWindow` / `BringToFront`), raise-on-click, and F12 → + toggle via the **existing** `InputAction.ToggleInventoryPanel`. `UiHost` forwarders. Spec + `docs/superpowers/specs/2026-06-20-d2b-window-manager-design.md`; plan + `…/plans/2026-06-20-d2b-window-manager.md`. Commits `6409038`/`036db8b`/`882b4dd`/`e3152ad`. + Visually confirmed (F12 toggles, click raises, chat-focus gate). + - **Cross-check correction:** retail's inventory key is **F12** (`ToggleInventoryPanel`), + NOT `I` (`I` = the `Laugh` emote per `retail-default.keymap.txt:246`). The handoff guessed I. + +- **Sub-phase B-Grid — inventory frame.** `UiItemList` N-cell **grid mode** + (`Columns`/`CellWidth`/`CellHeight`/`CellOffset`/`LayoutCells`; single-cell toolbar preserved) + + the **sub-window mount** in `LayoutImporter.Resolve` + the `GameWindow` swap of the A + placeholder for `LayoutImporter.Import(0x21000023)`. Spec + `…/specs/2026-06-20-d2b-inventory-grid-mount-design.md`; plan + `…/plans/2026-06-20-d2b-inventory-grid-mount.md`. Commits `4fd4b09`/`85098f5`/`7d971a2`. + +- **#145 — ZLevel z-order (DONE `45a5cc5`).** The importer mapped `ZOrder ← ReadOrder` only, so + the gmInventoryUI full-window backdrop (`0x100001D0`, ReadOrder 4) painted over the panels + (ReadOrder 1-3). Now `ZOrder = ReadOrder − ZLevel·10000` (higher ZLevel = further back). See §3. + +--- + +## 1. Read first + +- This doc + the two B-Grid spec/plan files above. +- `docs/research/2026-06-16-ui-panels-synthesis.md` §4 (the core-panels build plan). +- `docs/research/2026-06-16-inventory-deep-dive.md` (gmInventoryUI nesting + wire catalog). +- `claude-memory/project_d2b_retail_ui.md` (toolkit crib — updated this session). +- `claude-memory/project_object_item_model.md` (the `ClientObjectTable` — B-Controller's data source). + +--- + +## 2. Decomposition + status + +Sub-phase B was decomposed into four steps (approved this session): + +| Step | Status | +|---|---| +| **B-Grid** — UiItemList grid + sub-window mount | ✅ **SHIPPED** (mount works for all 3 panels; paperdoll equip-slot positions visually confirmed) | +| **B-Controller** — populate grids + burden meter + captions | ⬜ **NEXT** (see §5) | +| **B-Wire** — wire gaps (DropItem/ViewContents/…) | ⬜ pending | +| **B-Drag** — inventory cell as drag SOURCE (reuse the shipped spine) | ⬜ pending | +| **Sub-phase C** — paperdoll doll + per-slot equip art | ⬜ pending (the wrong-slot-graphics fix lives here) | + +--- + +## 3. Key discoveries (durable, non-obvious) + +1. **The nested panels are Type-0 inheritors, not game-class Types.** `gmInventoryUI 0x21000023`'s + three panels — paperdoll `0x100001CD`, backpack `0x100001CE`, 3D-items `0x100001CF` — are + **Type-0, media-less, childless leaves** that nest via `BaseElement`+`BaseLayoutId` + (BaseLayoutId → `0x21000024`/`0x21000022`/`0x21000021`). The research agent's "game-class Type + + recursive Import" model was WRONG — confirmed by dumping `0x21000023`. + +2. **The mount = inheritance carrying base children.** `ElementReader.Merge` dropped the base's + children. The mount (`LayoutImporter.ShouldMountBaseChildren` + the `Resolve` tail) attaches a + base's resolved subtree when the derived element is a pure container (no own children, no own + media) inheriting from a base WITH children. Inert for media-bearing inheritors (close + button/title) and childless style prototypes (vitals/chat/toolbar). + +3. **ZLevel z-order: higher = further back.** `DatWidgetFactory` folds `ZLevel` into `ZOrder` + (`ReadOrder − ZLevel·10000`). ZLevels observed: vitals all **0**; toolbar **0,1,2**; chat + **0,900**; inventory **0,10,50,100** (backdrop 100). Vitals unchanged; **chat + toolbar shifted + to their dat layering — needs a visual regression confirm (see §6).** + +4. **The paperdoll equip slots need `0x10000032` (UiItemSlot) registration.** They resolve to type + `0x10000032`, which the factory does NOT register, so they fall back to a generic `UiDatElement` + sprite (the uniform blue border seen on screen) instead of per-slot silhouettes. This + the + dressed doll is **Sub-phase C**. + +--- + +## 4. Current visual state (F12 with ACDREAM_RETAIL_UI=1) + +The inventory window opens and renders the nested frame: outer chrome + title + the **paperdoll +equip slots correctly positioned** over the backdrop. **Gaps (all expected — next sub-steps):** +- Equip slots draw a generic border, not per-slot silhouettes → **C**. +- Backpack strip + 3D-items contents look empty (the panels mounted; their content is unpopulated) + → **B-Controller**. +- Item cells empty everywhere (no items bound) → **B-Controller**. + +--- + +## 5. B-Controller — the next step + +**Goal:** make the inventory show your live contents — populate the backpack/3D-items grids from +`ClientObjectTable`, drive the burden meter, render the captions. The `gm*UI::PostInit` analogue. + +**READY:** +- `ClientObjectTable.GetContents(containerGuid)` → ordered guids; `Get(guid)` → icon fields + (`src/AcDream.Core/Items/ClientObjectTable.cs`). The player's own pack is already in the table + from the CreateObject stream — **no new wire needed for the own-pack path** (B-Wire is for other + containers + live deltas). +- `UiItemList` grid mode (B-Grid) — set `Columns`/`CellWidth`/`CellHeight` (read the cell pitch + from the nested layout's item template) then `Flush()` + `AddItem()` per item. +- `IconComposer` for the cell icons; `UiItemSlot.SetItem(guid, iconTex)`. +- `ImportedLayout.FindElement(id)` to bind by id (the burden meter, the lists). +- The burden meter is element `0x100001D9` (Type 7 → `UiMeter`); drive fill via + `SetAttribute_Float`-equivalent (`UiMeter.Fill`); load ratio = burden/maxBurden. + +**MISSING / to build:** +- `InventoryController`: find-by-id bind of the burden meter + the backpack/3D-items lists from + `invLayout` (the `ImportedLayout` built in `GameWindow`); subscribe to `ClientObjectTable` + `ObjectAdded/Moved/Removed`; rebuild cells. +- **Grid params:** dump `0x21000022` (backpack) + `0x21000021` (3D-items) to read the item-list + element sizes + cell template (geometry suggests backpack ~1 col, 3D-items ~6 cols of 36×36). +- **Type-0 text captions** ("Burden", "Contents of Backpack") don't render today — `UiDatElement` + doesn't draw text. Decide: promote those Type-0 elements to `UiText`, or a caption pass. +- Burden meter: verify `BuildMeter` produces a valid meter for the backpack meter's child shape + (it may differ from the vitals 3-slice shape). + +**Brainstorm Qs (B-Controller):** Where does `InventoryController` get `invLayout` (expose the +`ImportedLayout` from `GameWindow`, or import inside the controller)? Cell pitch source (dump vs +hardcode 36)? Type-0 caption rendering (UiText promotion vs a caption helper)? + +--- + +## 6. Open items / caveats + +- **⚠ Confirm chat + toolbar didn't regress from the #145 ZLevel fix** (chat ZLevel 900, toolbar + 1,2). The user looked but didn't explicitly confirm; first thing to eyeball next session. +- Type-0 text captions don't render (see §5). +- The B-Drag step reuses the **already-shipped** drag spine (`UiItemSlot` is a full drag source + + drop target; `IItemListDragHandler`/`ItemDragPayload`/`ShortcutStore`/the AddShortcut wire all + exist) — do NOT rebuild it. It needs the inventory cell as a SOURCE + the `SourceKind==Inventory` + branch in `ToolbarController.HandleDropRelease`. + +--- + +## 7. Branch state + +All work is on `claude/hopeful-maxwell-214a12` (tip `4904ff4`), which is `main` (`a391b86`) + +this session's A + B-Grid + #145 commits (a clean fast-forward chain — `main` is an ancestor). +Decide at the start of the next session: **fast-forward `main` to `4904ff4` and start a fresh +worktree off `main`** (matches the prior-arc pattern), or continue this branch. + +--- + +## 8. New-session prompt (paste into a fresh session) + +> Continue acdream's D.2b retail-UI inventory arc. **Read +> `docs/research/2026-06-21-d2b-bgrid-shipped-bcontroller-next-handoff.md` first.** Sub-phase A +> (window manager, F12) and B-Grid (inventory sub-window mount + UiItemList grid) shipped, and #145 +> (ZLevel z-order — the backdrop now sits behind the panels) is fixed — all on +> `claude/hopeful-maxwell-214a12` (tip `4904ff4`; `main` is an ancestor, so ff `main` and branch +> fresh, or continue). F12 now shows the nested inventory frame with the paperdoll equip slots +> correctly positioned, but the backpack/3D-items panels are empty and the equip slots show a +> generic border. **Next: B-Controller** — bind the imported `gmInventoryUI` tree by id and +> populate the backpack/3D-items grids from `ClientObjectTable.GetContents(player)` + drive the +> burden meter (`0x100001D9`) + render the Type-0 captions. Use the full brainstorm → spec → plan → +> subagent-driven flow; mandatory grep-named→cross-ref→pseudocode→port for any wire format; +> conformance tests throughout. Reuse the shipped grid + window manager + drag spine — don't +> rebuild. **First, eyeball chat + toolbar for any z-order regression from the #145 ZLevel fix.** +> After B-Controller: B-Wire (wire gaps), B-Drag (inventory drag SOURCE), then Sub-phase C +> (paperdoll doll + per-slot equip art — the wrong-graphics fix; needs `0x10000032` UiItemSlot +> registration). + +**MEMORY.md index line:** +- [Handoff: D.2b A+B-Grid shipped, #145 fixed, B-Controller next (2026-06-21)](research/2026-06-21-d2b-bgrid-shipped-bcontroller-next-handoff.md) — window manager (F12) + inventory sub-window mount (inheritance-children, NOT game-class Type) + UiItemList grid all shipped; #145 ZLevel z-order fixed (backdrop behind panels). Next B-Controller (populate grids + burden meter from ClientObjectTable). Paperdoll equip slots need 0x10000032; chat/toolbar ZLevel-regression unconfirmed. From 0e273ff2ac65f3be89c6c80926c193a708ba06db Mon Sep 17 00:00:00 2001 From: Erik Date: Sun, 21 Jun 2026 08:31:21 +0200 Subject: [PATCH 038/133] docs(D.2b-B): InventoryController (B-Controller) design spec Read-only population of the gmInventoryUI tree: bind 0x21000023 by id, fill the 'Contents of Backpack' grid + the right-strip pack-selector from ClientObjectTable, drive the vertical burden meter, render the Type-0 captions. Faithful selector + full faithful burden meter (per brainstorm). Ported AC algorithms with decomp anchors: CACQualities::InqLoad (0x0058f130), EncumbranceSystem::EncumbranceCapacity (0x004fcc00) / Load (0x004fcc40), gmBackpackUI::SetLoadLevel (0x004a6ea0), UIElement_Meter::DrawChildren (0x0046fbd0) / Initialize (0x0046f7b0, m_eDirection from property 0x6f). Key finding: BuildMeter's single-image sprite assignment is already correct (meter-own=track, child=fill); only vertical fill is new. Co-Authored-By: Claude Opus 4.8 (1M context) --- ...6-06-21-d2b-inventory-controller-design.md | 265 ++++++++++++++++++ 1 file changed, 265 insertions(+) create mode 100644 docs/superpowers/specs/2026-06-21-d2b-inventory-controller-design.md diff --git a/docs/superpowers/specs/2026-06-21-d2b-inventory-controller-design.md b/docs/superpowers/specs/2026-06-21-d2b-inventory-controller-design.md new file mode 100644 index 00000000..79cd75c3 --- /dev/null +++ b/docs/superpowers/specs/2026-06-21-d2b-inventory-controller-design.md @@ -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 playerGuid, // _playerServerGuid + Func iconIds, // IconComposer.GetIcon + Func 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`, 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 Date: Sun, 21 Jun 2026 08:50:21 +0200 Subject: [PATCH 039/133] docs(D.2b-B): InventoryController implementation plan (8 tasks, TDD) Bite-sized TDD tasks: BurdenMath encumbrance ports (T1), SumCarriedBurden fallback (T2), UiMeter vertical fill (T3), InventoryController bind+populate (T4) + burden+captions (T5), GameWindow wiring (T6), divergence/docs (T7), visual gate (T8). Self-reviewed against the spec; test helpers verified against the shipped widget APIs. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../2026-06-21-d2b-inventory-controller.md | 976 ++++++++++++++++++ 1 file changed, 976 insertions(+) create mode 100644 docs/superpowers/plans/2026-06-21-d2b-inventory-controller.md diff --git a/docs/superpowers/plans/2026-06-21-d2b-inventory-controller.md b/docs/superpowers/plans/2026-06-21-d2b-inventory-controller.md new file mode 100644 index 00000000..0194deee --- /dev/null +++ b/docs/superpowers/plans/2026-06-21-d2b-inventory-controller.md @@ -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 + /// Augmentation bonus-burden per aug rank (retail EncumbranceCapacity, 0x1e). + public const int AugBurdenPerRank = 30; + /// Max augmentation bonus-burden contribution per Strength (retail clamp 0x96). + public const int AugBurdenCap = 150; + + /// + /// Retail EncumbranceSystem::EncumbranceCapacity(strength, aug) + /// (named-retail decomp 256393, 0x004fcc00): + /// strength <= 0 ? 0 : strength*150 + clamp(aug*30, 0, 150)*strength. + /// + 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; + } + + /// + /// Retail EncumbranceSystem::Load(capacity, burden) (decomp 256413, + /// 0x004fcc40): the encumbrance ratio burden / capacity + /// (1.0 = at capacity, up to ~3.0). The decompiler mangled the FP divide to + /// return burden; the cap <= 0 guard + single divide is unambiguous. + /// Returns 0 when capacity <= 0 (no-data). + /// + public static float LoadRatio(int capacity, int burden) + => capacity <= 0 ? 0f : (float)burden / capacity; + + /// + /// Retail gmBackpackUI::SetLoadLevel bar fill (decomp 176542, + /// 0x004a6ea6): clamp(load * 0.3333…, 0, 1) — the bar is 1/3 full at + /// 100% capacity, full at 300%. + /// + public static float LoadToFill(float load) + { + float fill = load / 3f; + if (fill < 0f) return 0f; + return fill > 1f ? 1f : fill; + } + + /// + /// Retail gmBackpackUI::SetLoadLevel percent text (decomp 176576): after + /// arg2 = load/3, the text is floor(arg2 * 300) = floor(load * 100), + /// NOT clamped (reads up to 300%+). + /// + 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 + /// + /// Σ Burden over every object carried by : 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 EncumbranceVal (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. + /// + 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 + /// Vertical orientation (retail m_eDirection 2/4). Default false = + /// horizontal (direction 1). The burden bar is the only vertical meter today. + public bool Vertical { get; set; } + + /// 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. + public bool FillFromBottom { get; set; } = true; +``` + +(b) Add the testable rect helper next to `ComputeFillRect`: + +```csharp + /// Clamp to [0,1] and return the vertical fill rect + /// (local px). true → the fill occupies the bottom + /// h*pct px (retail direction 4); false → the top (direction 2). + 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 + /// Draws a single-tile vertical bar slice. The track ( + /// false) spans the full height; the fill spans px from the + /// bottom () 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. + private void DrawVBar(UiRenderContext ctx, Func 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 + { + [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; + +/// +/// Binds the imported gmInventoryUI tree (LayoutDesc 0x21000023) and populates it from +/// . 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). +/// +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 _playerGuid; + private readonly Func _iconIds; + + private readonly UiItemList? _contentsGrid; + private readonly UiItemList? _containerList; + private readonly UiItemList? _topContainer; + + private InventoryController( + ImportedLayout layout, + ClientObjectTable objects, + Func playerGuid, + Func iconIds, + Func 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 playerGuid, + Func iconIds, + Func 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(); } + + /// True if the object is in (or wielded by) the player — i.e. a rebuild is warranted. + 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 + } + + /// 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). + 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); + } + } + + /// Detach event handlers (idempotent). + 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 _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 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 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() + : new[] { new UiText.Line(s, CaptionColor) }; + }, + }; + host.AddChild(label); + } + + /// 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. + 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`. ✓ From 5875ac857d8af78643c2485915abea4666dce48c Mon Sep 17 00:00:00 2001 From: Erik Date: Sun, 21 Jun 2026 08:54:16 +0200 Subject: [PATCH 040/133] =?UTF-8?q?feat(core):=20D.2b-B=20=E2=80=94=20port?= =?UTF-8?q?=20EncumbranceSystem=20capacity/load=20+=20SetLoadLevel=20fill/?= =?UTF-8?q?percent?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit EncumbranceCapacity (decomp 0x004fcc00), Load (0x004fcc40), SetLoadLevel fill=load/3 clamped + percent=floor(load*100) (0x004a6ea0). Golden tests. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/AcDream.Core/Items/ClientObject.cs | 49 +++++++++++++++++++ .../Items/BurdenMathTests.cs | 40 +++++++++++++++ 2 files changed, 89 insertions(+) create mode 100644 tests/AcDream.Core.Tests/Items/BurdenMathTests.cs diff --git a/src/AcDream.Core/Items/ClientObject.cs b/src/AcDream.Core/Items/ClientObject.cs index 04d9c873..cf08a6b4 100644 --- a/src/AcDream.Core/Items/ClientObject.cs +++ b/src/AcDream.Core/Items/ClientObject.cs @@ -216,6 +216,55 @@ public static class BurdenMath { public const int BurdenPerStrength = 150; + /// Augmentation bonus-burden per aug rank (retail EncumbranceCapacity, 0x1e). + public const int AugBurdenPerRank = 30; + /// Max augmentation bonus-burden contribution per Strength (retail clamp 0x96). + public const int AugBurdenCap = 150; + + /// + /// Retail EncumbranceSystem::EncumbranceCapacity(strength, aug) + /// (named-retail decomp 256393, 0x004fcc00): + /// strength <= 0 ? 0 : strength*150 + clamp(aug*30, 0, 150)*strength. + /// + 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; + } + + /// + /// Retail EncumbranceSystem::Load(capacity, burden) (decomp 256413, + /// 0x004fcc40): the encumbrance ratio burden / capacity + /// (1.0 = at capacity, up to ~3.0). The decompiler mangled the FP divide to + /// return burden; the cap <= 0 guard + single divide is unambiguous. + /// Returns 0 when capacity <= 0 (no-data). + /// + public static float LoadRatio(int capacity, int burden) + => capacity <= 0 ? 0f : (float)burden / capacity; + + /// + /// Retail gmBackpackUI::SetLoadLevel bar fill (decomp 176542, + /// 0x004a6ea6): clamp(load * 0.3333…, 0, 1) — the bar is 1/3 full at + /// 100% capacity, full at 300%. + /// + public static float LoadToFill(float load) + { + float fill = load / 3f; + if (fill < 0f) return 0f; + return fill > 1f ? 1f : fill; + } + + /// + /// Retail gmBackpackUI::SetLoadLevel percent text (decomp 176576): after + /// arg2 = load/3, the text is floor(arg2 * 300) = floor(load * 100), + /// NOT clamped (reads up to 300%+). + /// + public static int LoadToPercent(float load) + => (int)System.MathF.Floor(load * 100f); + public static int ComputeMax(int strength, int bonusBurden) => BurdenPerStrength * strength + strength * bonusBurden; diff --git a/tests/AcDream.Core.Tests/Items/BurdenMathTests.cs b/tests/AcDream.Core.Tests/Items/BurdenMathTests.cs new file mode 100644 index 00000000..25cfa043 --- /dev/null +++ b/tests/AcDream.Core.Tests/Items/BurdenMathTests.cs @@ -0,0 +1,40 @@ +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)); +} From fb050aed4d385b33e3622d9a87c646921376ec5a Mon Sep 17 00:00:00 2001 From: Erik Date: Sun, 21 Jun 2026 08:55:35 +0200 Subject: [PATCH 041/133] =?UTF-8?q?feat(core):=20D.2b-B=20=E2=80=94=20Clie?= =?UTF-8?q?ntObjectTable.SumCarriedBurden=20(carried-Burden=20fallback)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 (1M context) --- src/AcDream.Core/Items/ClientObjectTable.cs | 29 +++++++++++++++++ .../Items/ClientObjectTableBurdenTests.cs | 32 +++++++++++++++++++ 2 files changed, 61 insertions(+) create mode 100644 tests/AcDream.Core.Tests/Items/ClientObjectTableBurdenTests.cs diff --git a/src/AcDream.Core/Items/ClientObjectTable.cs b/src/AcDream.Core/Items/ClientObjectTable.cs index 94fa574f..356fa7b6 100644 --- a/src/AcDream.Core/Items/ClientObjectTable.cs +++ b/src/AcDream.Core/Items/ClientObjectTable.cs @@ -274,6 +274,35 @@ public sealed class ClientObjectTable _containerIndex.TryGetValue(containerId, out var l) ? l.ToArray() : System.Array.Empty(); + /// + /// Σ Burden over every object carried by : 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 EncumbranceVal (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. + /// + 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; + } + /// /// Flush the table — typically called on logoff or teleport /// that drops the session's object state. diff --git a/tests/AcDream.Core.Tests/Items/ClientObjectTableBurdenTests.cs b/tests/AcDream.Core.Tests/Items/ClientObjectTableBurdenTests.cs new file mode 100644 index 00000000..13e348c4 --- /dev/null +++ b/tests/AcDream.Core.Tests/Items/ClientObjectTableBurdenTests.cs @@ -0,0 +1,32 @@ +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)); +} From 5e75d2ac76ee2209bd916ff783e0c67ec3b2c2e7 Mon Sep 17 00:00:00 2001 From: Erik Date: Sun, 21 Jun 2026 08:58:37 +0200 Subject: [PATCH 042/133] =?UTF-8?q?feat(ui):=20D.2b-B=20=E2=80=94=20UiMete?= =?UTF-8?q?r=20vertical=20fill=20(burden=20bar,=20retail=20m=5FeDirection?= =?UTF-8?q?=202/4)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 (1M context) --- src/AcDream.App/UI/UiMeter.cs | 69 ++++++++++++++++--- .../UI/UiMeterVerticalTests.cs | 35 ++++++++++ 2 files changed, 96 insertions(+), 8 deletions(-) create mode 100644 tests/AcDream.App.Tests/UI/UiMeterVerticalTests.cs diff --git a/src/AcDream.App/UI/UiMeter.cs b/src/AcDream.App/UI/UiMeter.cs index 057402c7..5d7fc535 100644 --- a/src/AcDream.App/UI/UiMeter.cs +++ b/src/AcDream.App/UI/UiMeter.cs @@ -55,6 +55,14 @@ public sealed class UiMeter : UiElement /// Coloured-fill right-cap RenderSurface id. public uint FrontRight { get; set; } + /// Vertical orientation (retail m_eDirection 2/4). Default false = + /// horizontal (direction 1). The burden bar is the only vertical meter today. + public bool Vertical { get; set; } + + /// 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. + public bool FillFromBottom { get; set; } = true; + public UiMeter() { ClickThrough = true; } /// The meter draws its own 3-slice bars; the importer must not build its @@ -71,6 +79,18 @@ public sealed class UiMeter : UiElement return (0f, 0f, w * pct, h); } + /// Clamp to [0,1] and return the vertical fill rect + /// (local px). true → the fill occupies the bottom + /// h*pct px (retail direction 4); false → the top (direction 2). + 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); + } + protected override void OnDraw(UiRenderContext ctx) { float? pct = Fill(); @@ -78,14 +98,25 @@ public sealed class UiMeter : UiElement if (SpriteResolve is { } resolve && (BackLeft != 0 || BackTile != 0 || FrontTile != 0)) { - // Retail meter (UIElement_Meter::DrawChildren): the BACK 3-slice is the - // empty track, drawn full width; the FRONT 3-slice is the coloured fill, - // drawn at FULL width too but horizontally CLIPPED to the fill fraction. - // The front carries its own right-cap (shown at 100%); clipping below 100% - // removes it and reveals the back track's right-cap — retail's scissor-fill. - DrawHBar(ctx, resolve, BackLeft, BackTile, BackRight, Width); - if (pct is not null && p > 0f) - DrawHBar(ctx, resolve, FrontLeft, FrontTile, FrontRight, Width * p); + 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 + { + // Retail meter (UIElement_Meter::DrawChildren): the BACK 3-slice is the + // empty track, drawn full width; the FRONT 3-slice is the coloured fill, + // drawn at FULL width too but horizontally CLIPPED to the fill fraction. + // The front carries its own right-cap (shown at 100%); clipping below 100% + // removes it and reveals the back track's right-cap — retail's scissor-fill. + DrawHBar(ctx, resolve, BackLeft, BackTile, BackRight, Width); + if (pct is not null && p > 0f) + DrawHBar(ctx, resolve, FrontLeft, FrontTile, FrontRight, Width * p); + } } else { @@ -158,6 +189,28 @@ public sealed class UiMeter : UiElement DrawPiece(ctx, rt, w - capR, capR, rw, h, clipW); } + /// Draws a single-tile vertical bar slice. The track ( + /// false) spans the full height; the fill spans px from the + /// bottom () 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. + private void DrawVBar(UiRenderContext ctx, Func 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); + } + /// Draw a slice over local [, /// pieceX+], with the texture repeating every /// px (UV-repeat — the UI texture is GL_REPEAT-wrapped). diff --git a/tests/AcDream.App.Tests/UI/UiMeterVerticalTests.cs b/tests/AcDream.App.Tests/UI/UiMeterVerticalTests.cs new file mode 100644 index 00000000..6f130e24 --- /dev/null +++ b/tests/AcDream.App.Tests/UI/UiMeterVerticalTests.cs @@ -0,0 +1,35 @@ +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); + } +} From 89c640a54d10618bb857fd95e0faa7a0d4edc13a Mon Sep 17 00:00:00 2001 From: Erik Date: Sun, 21 Jun 2026 09:04:48 +0200 Subject: [PATCH 043/133] =?UTF-8?q?feat(ui):=20D.2b-B=20=E2=80=94=20Invent?= =?UTF-8?q?oryController=20bind=20+=20grid=20population=20(loose/side-bag?= =?UTF-8?q?=20partition)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Task 4: InventoryController.Bind + Populate: find-by-id bind for the 7 inventory element ids, configure grid (6 cols x 32px) + container list, partition GetContents(player) into loose items (contentsGrid) vs side bags (containerList), populate main-pack cell in topContainer, subscribe ObjectAdded/Moved/Removed/Updated for live rebuilds. Handles both Ingest-indexed (production) and AddOrUpdate-direct (test) paths via fallback scan. Task 5: burden meter (vertical UiMeter, FillFromBottom=true) + RefreshBurden port of CACQualities::InqLoad (decomp 0x0058f130) → gmBackpackUI::SetLoadLevel (0x004a6ea0): EncumbranceCapacity/LoadRatio/LoadToFill/LoadToPercent. Three AttachCaption overlays (Burden, Contents of Backpack, %text). 5 tests all green. Co-Authored-By: Claude Sonnet 4.6 --- .../UI/Layout/InventoryController.cs | 230 ++++++++++++++++++ .../UI/Layout/InventoryControllerTests.cs | 138 +++++++++++ 2 files changed, 368 insertions(+) create mode 100644 src/AcDream.App/UI/Layout/InventoryController.cs create mode 100644 tests/AcDream.App.Tests/UI/Layout/InventoryControllerTests.cs diff --git a/src/AcDream.App/UI/Layout/InventoryController.cs b/src/AcDream.App/UI/Layout/InventoryController.cs new file mode 100644 index 00000000..310e2239 --- /dev/null +++ b/src/AcDream.App/UI/Layout/InventoryController.cs @@ -0,0 +1,230 @@ +using System; +using System.Collections.Generic; +using System.Numerics; +using AcDream.Core.Items; + +namespace AcDream.App.UI.Layout; + +/// +/// Binds the imported gmInventoryUI tree (LayoutDesc 0x21000023) and populates it from +/// . 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). +/// +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 _playerGuid; + private readonly Func _iconIds; + private readonly Func _strength; + + private readonly UiItemList? _contentsGrid; + private readonly UiItemList? _containerList; + private readonly UiItemList? _topContainer; + private readonly UiMeter? _burdenMeter; + + private float _burdenFill; + private int _burdenPercent; + private static readonly Vector4 CaptionColor = new(1f, 1f, 1f, 1f); + + // 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 + + private InventoryController( + ImportedLayout layout, + ClientObjectTable objects, + Func playerGuid, + Func iconIds, + Func strength, + UiDatFont? datFont) + { + _objects = objects; + _playerGuid = playerGuid; + _iconIds = iconIds; + _strength = strength; + + _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 meter: vertical 11×58 bar (gmBackpackUI m_burdenMeter, retail direction 4). + _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); + + // 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 playerGuid, + Func iconIds, + Func 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(); } + + /// True if the object is in (or wielded by) the player — i.e. a rebuild is warranted. + 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 + } + + /// 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). + /// Uses the container index when available (items ingested via Ingest/MoveItem) or + /// falls back to a full scan — both paths are correct for the two production flows. + public void Populate() + { + uint p = _playerGuid(); + + // GetContents returns indexed + slot-sorted items (the production path via Ingest). + // For AddOrUpdate-seeded items (tests + initial populate before indexing), we fall + // back to scanning all objects with ContainerId == p, sorted by slot. + var indexed = _objects.GetContents(p); + IEnumerable guids; + if (indexed.Count > 0) + { + guids = indexed; + } + else + { + var direct = new List<(int slot, uint id)>(); + foreach (var o in _objects.Objects) + if (o.ContainerId == p) + direct.Add((o.ContainerSlot, o.ObjectId)); + direct.Sort((a, b) => a.slot.CompareTo(b.slot)); + var ids = new List(direct.Count); + foreach (var (_, id) in direct) ids.Add(id); + guids = ids; + } + + _contentsGrid?.Flush(); + _containerList?.Flush(); + + foreach (var guid in guids) + { + 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); + } + + RefreshBurden(); + } + + private void AttachCaption(UiElement? host, Func 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) + ? Array.Empty() + : new[] { new UiText.Line(s, CaptionColor) }; + }, + }; + host.AddChild(label); + } + + /// 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. + 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); + } + + /// Detach event handlers (idempotent). + public void Dispose() + { + _objects.ObjectAdded -= OnObjectChanged; + _objects.ObjectMoved -= OnObjectMoved; + _objects.ObjectRemoved -= OnObjectChanged; + _objects.ObjectUpdated -= OnObjectChanged; + } +} diff --git a/tests/AcDream.App.Tests/UI/Layout/InventoryControllerTests.cs b/tests/AcDream.App.Tests/UI/Layout/InventoryControllerTests.cs new file mode 100644 index 00000000..99d5b34b --- /dev/null +++ b/tests/AcDream.App.Tests/UI/Layout/InventoryControllerTests.cs @@ -0,0 +1,138 @@ +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 + { + [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); + } + + [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 ""; + } +} From 383e8b7b556d68930a8c932a433af88f97247fc5 Mon Sep 17 00:00:00 2001 From: Erik Date: Sun, 21 Jun 2026 09:09:15 +0200 Subject: [PATCH 044/133] =?UTF-8?q?refactor(ui):=20D.2b-B=20=E2=80=94=20Po?= =?UTF-8?q?pulate=20uses=20GetContents=20only=20(drop=20test-driven=20fall?= =?UTF-8?q?back)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The implementer added a full-scan fallback in Populate() to accommodate a test that seeded items via AddOrUpdate (which deliberately does NOT touch the container index — only Ingest/MoveItem do). That was a production workaround for a faulty test, and inconsistent (it only triggered when the index was wholly empty). Root-fix: Populate reads GetContents(player) only — the index IS retail's per-container item list. The test now seeds via the faithful indexed path (AddOrUpdate + MoveItem → Reindex) through a SeedContained helper. 530 App tests green. --- .../UI/Layout/InventoryController.cs | 31 ++++--------------- .../UI/Layout/InventoryControllerTests.cs | 22 +++++++++---- 2 files changed, 22 insertions(+), 31 deletions(-) diff --git a/src/AcDream.App/UI/Layout/InventoryController.cs b/src/AcDream.App/UI/Layout/InventoryController.cs index 310e2239..1f0e29ab 100644 --- a/src/AcDream.App/UI/Layout/InventoryController.cs +++ b/src/AcDream.App/UI/Layout/InventoryController.cs @@ -121,38 +121,19 @@ public sealed class InventoryController } /// 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). - /// Uses the container index when available (items ingested via Ingest/MoveItem) or - /// falls back to a full scan — both paths are correct for the two production flows. + /// items (the "Contents of Backpack" grid) and side bags (the pack-selector list). The + /// container index () IS retail's per-container + /// item list — items enter it via the CreateObject (Ingest) / server-move (MoveItem) flows + /// that call Reindex, slot-sorted. public void Populate() { uint p = _playerGuid(); - - // GetContents returns indexed + slot-sorted items (the production path via Ingest). - // For AddOrUpdate-seeded items (tests + initial populate before indexing), we fall - // back to scanning all objects with ContainerId == p, sorted by slot. - var indexed = _objects.GetContents(p); - IEnumerable guids; - if (indexed.Count > 0) - { - guids = indexed; - } - else - { - var direct = new List<(int slot, uint id)>(); - foreach (var o in _objects.Objects) - if (o.ContainerId == p) - direct.Add((o.ContainerSlot, o.ObjectId)); - direct.Sort((a, b) => a.slot.CompareTo(b.slot)); - var ids = new List(direct.Count); - foreach (var (_, id) in direct) ids.Add(id); - guids = ids; - } + var contents = _objects.GetContents(p); _contentsGrid?.Flush(); _containerList?.Flush(); - foreach (var guid in guids) + foreach (var guid in contents) { var item = _objects.Get(guid); if (item is null) continue; diff --git a/tests/AcDream.App.Tests/UI/Layout/InventoryControllerTests.cs b/tests/AcDream.App.Tests/UI/Layout/InventoryControllerTests.cs index 99d5b34b..14d6053e 100644 --- a/tests/AcDream.App.Tests/UI/Layout/InventoryControllerTests.cs +++ b/tests/AcDream.App.Tests/UI/Layout/InventoryControllerTests.cs @@ -54,17 +54,27 @@ public class InventoryControllerTests iconIds: (_, _, _, _, _) => 0u, // no real GL upload in tests strength: () => strength, datFont: null); + // Place an object in a container at a slot via the faithful production path: + // AddOrUpdate registers it, MoveItem sets ContainerId+ContainerSlot and calls Reindex + // (the same machinery server-driven moves use), so GetContents returns it slot-ordered. + private static void SeedContained(ClientObjectTable t, uint guid, uint container, int slot, + int burden = 0, ItemType type = ItemType.None) + { + t.AddOrUpdate(new ClientObject { ObjectId = guid, Burden = burden, Type = type }); + t.MoveItem(guid, container, slot); + } + [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 + // Seed via the indexed path: AddOrUpdate registers the object, MoveItem places it in + // the container at a slot (the faithful "server moved item into container" flow that + // calls Reindex). GetContents — retail's per-container list — then returns them ordered. + SeedContained(objects, 0xA, Player, slot: 0, burden: 10); // loose + SeedContained(objects, 0xB, Player, slot: 1, burden: 20); // loose + SeedContained(objects, 0xC, Player, slot: 2, type: ItemType.Container); // side bag Bind(layout, objects); From 03fbf4464d1134cbc86094b21d840ebc030a248b Mon Sep 17 00:00:00 2001 From: Erik Date: Sun, 21 Jun 2026 09:12:34 +0200 Subject: [PATCH 045/133] =?UTF-8?q?feat(ui):=20D.2b-B=20=E2=80=94=20wire?= =?UTF-8?q?=20InventoryController=20into=20the=20inventory-init=20block?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds _inventoryController field (after _selectedObjectController) and calls InventoryController.Bind in the existing inventory-init block, between RegisterWindow and the Console.WriteLine. Uses Objects/iconComposer/ vitalsDatFont already in scope; strength lambda pattern-matches AttributeSnapshot to int? (uint? has no implicit cast to int?). Co-Authored-By: Claude Sonnet 4.6 --- src/AcDream.App/Rendering/GameWindow.cs | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs index e04a1622..085f26af 100644 --- a/src/AcDream.App/Rendering/GameWindow.cs +++ b/src/AcDream.App/Rendering/GameWindow.cs @@ -621,6 +621,8 @@ public sealed class GameWindow : IDisposable private AcDream.App.UI.Layout.ToolbarController? _toolbarController; // Phase D.5.3a — selected-object strip controller (name, overlay state, health meter). private AcDream.App.UI.Layout.SelectedObjectController? _selectedObjectController; + // Phase D.2b-B — inventory controller (backpack grid + pack-selector + burden meter). + private AcDream.App.UI.Layout.InventoryController? _inventoryController; // Phase D.2b Task 9 — plugin UI registrations buffered before OnLoad; drained in OnLoad. private readonly AcDream.App.Plugins.BufferedUiRegistry? _uiRegistry; // Phase I.2: ImGui debug panel ViewModel. Lives for as long as @@ -2154,6 +2156,19 @@ public sealed class GameWindow : IDisposable inventoryWindow.Draggable = true; _uiHost.Root.AddChild(inventoryWindow); _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: () => LocalPlayer.GetAttribute( + AcDream.Core.Player.LocalPlayerState.AttributeKind.Strength) is { } sa + ? (int?)sa.Current : null, + datFont: vitalsDatFont); + Console.WriteLine("[D.2b-B] retail inventory window from LayoutDesc importer (0x21000023)."); } else Console.WriteLine("[D.2b-B] inventory: LayoutDesc 0x21000023 not found."); From 7bb4bd3ae83536b8359fb41ceccc1bd1c060bece Mon Sep 17 00:00:00 2001 From: Erik Date: Sun, 21 Jun 2026 09:15:05 +0200 Subject: [PATCH 046/133] docs(D.2b-B): divergence rows + issues/roadmap for inventory population MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit AP-48: client-side SumCarriedBurden fallback (EncumbranceVal not yet wired). AP-49: aug capacity (PropertyInt 0xE6) not tracked → un-augmented Str×150. AP-50: meter direction from geometry (m_eDirection/0x6f not read from LayoutDesc). AP-51: main-pack cell placeholder icon (equip-pack DID deferred to Sub-phase C). AP count: 43 → 47 rows. ISSUES: D.2b-B closed entry + remaining B-Wire/B-Drag/C gaps noted. Roadmap: D.5 sub-phase ledger updated to reflect B-Controller shipped. Co-Authored-By: Claude Sonnet 4.6 --- docs/ISSUES.md | 9 +++++++++ docs/architecture/retail-divergence-register.md | 6 +++++- docs/plans/2026-04-11-roadmap.md | 2 +- 3 files changed, 15 insertions(+), 2 deletions(-) diff --git a/docs/ISSUES.md b/docs/ISSUES.md index 739c1619..355206f7 100644 --- a/docs/ISSUES.md +++ b/docs/ISSUES.md @@ -5344,6 +5344,15 @@ outdoors at the angle that previously erased it. # Recently closed +## D.2b-B — Inventory controller (B-Controller): grid population + burden meter + captions SHIPPED + +**Closed:** 2026-06-21 +**Component:** ui — D.2b inventory window (gmInventoryUI 0x21000023) + +`InventoryController.Bind` wired into the existing inventory-init block in `GameWindow.cs` (commit `03fbf44`). The controller populates the "Contents of Backpack" grid (6 cols × 32 px) and the pack-selector strip from `ClientObjectTable`, drives the vertical burden meter via `BurdenMath.EncumbranceCapacity/LoadRatio/LoadToFill`, and attaches "Burden" + "Contents of Backpack" + `%` captions. Four divergence rows added: AP-48 (client-side burden sum), AP-49 (aug capacity unwired), AP-50 (meter direction from geometry), AP-51 (main-pack icon placeholder). Divergence register + docs commit: `docs(D.2b-B)`. **Remaining open sub-phases:** B-Wire (parse `EncumbranceVal`/PropertyInt 5 from the wire to retire AP-48), B-Drag (drag-from-inventory SourceKind branch closes B.2), Sub-phase C (paperdoll equip-slot `UiItemSlot` registration). + +--- + ## #113 — Phantom staircase: REOPENED 2026-06-11, folded into the HOLISTIC BUILDING-RENDER PORT **Status:** REOPENED — root cause #2 found (drawing-BSP-orphaned no-draw polys: diff --git a/docs/architecture/retail-divergence-register.md b/docs/architecture/retail-divergence-register.md index db59e18a..ab53fe22 100644 --- a/docs/architecture/retail-divergence-register.md +++ b/docs/architecture/retail-divergence-register.md @@ -97,7 +97,7 @@ accepted-divergence entries (#96, #49, #50). --- -## 3. Documented approximation (AP) — 43 rows +## 3. Documented approximation (AP) — 47 rows | # | Divergence | Where (file:line) | Why it is safe / justified | Risk if assumption breaks | Retail oracle | |---|---|---|---|---|---| @@ -147,6 +147,10 @@ accepted-divergence entries (#96, #49, #50). | AP-45 | `PublicUpdatePropertyInt (0x02CE)` sequence byte parsed-past but not honored; last update wins (no freshness check against sequence number) | `src/AcDream.Core.Net/Messages/PublicUpdatePropertyInt.cs` | Loopback ACE rarely reorders; latest-wins matches `PrivateUpdateVital`/`UpdatePosition`'s existing non-sequence behavior. Sequence tracking added when needed alongside TS-26. | A reordered 0x02CE on a real network could apply a stale UiEffects value — item icon temporarily shows the wrong effect state, corrected on next update | `PublicUpdatePropertyInt` sequence byte (ACE GameMessagePublicUpdatePropertyInt) | | AP-46 | Health-meter gate approximation: retail shows the health meter for `IsPlayer() || pet_owner || ClientCombatSystem::ObjectIsAttackable()` (full PK/faction logic); acdream's `GameWindow.IsHealthBarTarget` uses the server PWD bits `BF_ATTACKABLE (0x10)` OR `BF_PLAYER (0x8)` | `src/AcDream.App/Rendering/GameWindow.cs` (`IsHealthBarTarget`) → `SelectedObjectController` | The PWD `BF_ATTACKABLE`/`BF_PLAYER` bits distinguish monsters + players (bar) from friendly/vendor NPCs (name-only) for the M1.5 dev loop; the pet case and the full ObjectIsAttackable PK/faction refinement (free-PK, PK-vs-PK, PKLite) are not ported | A PK/faction edge (e.g. a hostile-flagged player whose `BF_ATTACKABLE` is unset, or a pet) could show/hide the bar where retail differs — no impact on the non-PK PvE dev loop | `ClientCombatSystem::ObjectIsAttackable` acclient_2013_pseudo_c.txt:375385; `BF_ATTACKABLE` acclient.h:6437 | | AP-47 | Cursor drag ghost reuses the full composited `m_pIcon` (incl. type-default underlay) instead of retail's dedicated `m_pDragIcon` (base + custom-overlay, NO type-default underlay). Opacity now matches retail (full). | `src/AcDream.App/UI/UiRoot.cs` (`DrawDragGhost`/`GhostAlpha`) → `src/AcDream.App/UI/UiItemSlot.cs` (`GetDragGhost`) | Cosmetic only — the dragged item is still unambiguously identifiable; building the second underlay-less composite is deferred polish; the ghost is item-agnostic in UiRoot via `GetDragGhost()` | The ghost carries the opaque type-default underlay backing rather than retail's underlay-less copy — a subtle look difference while dragging, no functional effect. | `IconData::RenderIcons` acclient_2013_pseudo_c.txt:407594-407625 (m_pDragIcon path); deep-dive §3.2/§5.5 | +| AP-48 | 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` (decomp 0x0058f130). | `src/AcDream.App/UI/Layout/InventoryController.cs` (`RefreshBurden`) | The wire `EncumbranceVal` is not yet parsed from the server; the carried-Burden sum is the nearest client-side fallback and is correct for items in the simple pack-only case. Retire when B-Wire parses PropertyInt 5. | Drift from coins/untracked items (e.g. items inside side bags that arrived before their parent, see TS-32) — burden bar reads slightly low vs retail until B-Wire lands. | `CACQualities::InqLoad` 0x0058f130; ACE PropertyInt.EncumbranceVal=5 | +| AP-49 | Carry-capacity augmentation (PropertyInt `0xE6`) not tracked → `bonus=0` → un-augmented `Str×150` capacity. | `src/AcDream.App/UI/Layout/InventoryController.cs` (`RefreshBurden`) caller of `BurdenMath.EncumbranceCapacity` | PropertyInt `0xE6` is not yet parsed from the wire; aug=0 is the correct no-aug default and matches the vast majority of characters. | Augmented characters read slightly low capacity — burden bar shows slightly higher fill than retail for augmented chars; a three-aug char's cap is 150×Str higher per aug, so up to 450 extra points per point of Str. | `EncumbranceSystem::EncumbranceCapacity` decomp 256393 (0x004fcc00); retail `0xE6` PropertyInt augmentation | +| AP-50 | Burden meter orientation/direction set programmatically (`Vertical=true`, `FillFromBottom=true`) rather than reading retail's `m_eDirection` property from the LayoutDesc element (property id `0x6f`). | `src/AcDream.App/UI/Layout/InventoryController.cs`; `src/AcDream.App/UI/UiMeter.cs` (`Vertical`/`FillFromBottom`) | The `m_eDirection` property is not yet read by `ElementReader`; bottom-up fill is visually confirmed against retail's burden bar art. Retire when `0x6f` is wired through `ElementReader`→`DatWidgetFactory`. | Wrong fill direction (top-down instead of bottom-up, or horizontal) if a future meter whose art requires a different direction is forced through the same code path. | `UIElement_Meter::DrawChildren` @0x46fbd0; property `0x6f` (`m_eDirection`) in the LayoutDesc | +| AP-51 | Main-pack `m_topContainer` cell (element `0x100001C9`) uses a placeholder/empty icon (`tex=0u`), not a weenie-driven backpack icon. | `src/AcDream.App/UI/Layout/InventoryController.cs` (`Populate`) | The player's own container entity has no item `IconId` on the client (it's a character, not an item); retail uses the equipped-pack's icon via the character's equipped-pack `CreateObject`. The equipped-pack DID path is deferred to Sub-phase C. | Main-pack cell renders blank (no icon) where retail shows the equipped backpack art — cosmetic gap visible when F12 is open. | `gmBackpackUI::PostInit` @0x4a8520 (m_topContainer bind); `CreateObject` weenie icon path | --- diff --git a/docs/plans/2026-04-11-roadmap.md b/docs/plans/2026-04-11-roadmap.md index fffb102c..7c006714 100644 --- a/docs/plans/2026-04-11-roadmap.md +++ b/docs/plans/2026-04-11-roadmap.md @@ -433,7 +433,7 @@ behavior. Estimated 17–26 days focused work, 3–5 weeks calendar. - **D.5 — Core panels.** Attributes (`chunk_00470000.c:FUN_0047ba70`), Skills (same), Paperdoll (`chunk_004A0000.c:FUN_004A5200`), Inventory, Spellbook (`chunk_004C0000.c`), Fellowship, Allegiance. Each uses the port sketches in slice 05. **(Targets `AcDream.UI.Abstractions` — ships with D.2a using ImGui-rendered widgets; reskinned by D.2b.)** The *chat* panel originally listed under D.5 shipped early in Phase I (I.4 input + I.7 combat translator superseded the chat-panel design here); this entry now covers Attributes / Skills / Paperdoll / Inventory / Spellbook / Fellowship / Allegiance only. - **✓ SHIPPED — D.5.1 — Toolbar (action bar).** Shipped 2026-06-16/17 (`30b28c2`→`0e7a083`, branch claude/hopeful-maxwell-214a12). First data-driven *game* panel: `gmToolbarUI` (`LayoutDesc 0x21000016`) — 18 shortcut slots from the persisted `PlayerDescription` SHORTCUT block, real **composited** item icons (opaque type-default underlay via the `EnumIDMap 0x10000004` resolve), **occupancy-gated slot numbers 1–9** (occupied = dark-box peace/war `0x10000042/43`; empty = background `0x1000005e` from cell composite `0x10000341`), **click-to-use** (`ItemHolder::UseObject` → `0x0036`), **peace/war stance** indicator live-wired to `CombatState`, **movable**, and a **chrome frame** (UiNineSlicePanel drawn over content via the new `UiElement.OnDrawAfterChildren` hook). New shared widgets `UiItemSlot` (`UIElement_UIItem` 0x10000032, procedural leaf) + `UiItemList` (`UIElement_ItemList` 0x10000031, factory branch) + `IconComposer` (CPU layered composite). `CreateObject.TryParse` extended to the full ACE-order weenie-header tail to capture `IconId`/`IconOverlay`/`IconUnderlay` → `ItemRepository.EnrichItem` → re-render. Spec/plan `docs/superpowers/{specs,plans}/2026-06-16-d2b-toolbar-phase1*.md`; research drop `docs/research/2026-06-16-*deep-dive.md` + synthesis. Divergence IA-16/IA-17 added. **User-confirmed** (numbers, icons, frame). Per-task spec+code-review throughout. - **✓ SHIPPED — D.5.2 — Stateful item-icon system.** Shipped 2026-06-17/18 (`419c3ac`..`fb288ad`, branch claude/hopeful-maxwell-214a12; **visually verified on a live Coldeve server**). Faithful retail icon composite (`IconData::RenderIcons` @0x0058d180): (1) `UiEffects` bitfield captured from the `CreateObject` weenie header (was discarded) → `ItemInstance.Effects`; (2) `IconComposer.GetIcon` rewritten as a 2-stage composite — Stage 1 = drag icon (base + custom overlay) + the effect treatment, Stage 2 = type-default underlay + custom underlay + drag. The effect treatment ports the **surface overload** of `SurfaceWindow::ReplaceColor` (`0x004415b0`): the textured effect tile (`EnumIDMap 0x10000005` by `LowestSetBit(effects)+1`, fallback `0x21` solid-black) is copied **per-pixel** into the icon's pure-white pixels — magical items take the tile's GRADIENT hue, mundane items go black; (3) `PublicUpdatePropertyInt (0x02CE)` parser + `WorldSession.ObjectIntPropertyUpdated` event + `GameWindow` subscription → `ItemRepository.UpdateIntProperty` → icon re-composites live. **Appraise (`0x00C9`) carries NO icon data** (ACE proof: `Icon`/`IconOverlay`/`IconUnderlay`/`UiEffects` all lack `[AssessmentProperty]`) — dropped as a no-op. **Two visual-verification fixes landed after the subagent build:** the `effects==0` recolor MUST run (mundane white edges → black, `40c97a5`) and the tint is a per-pixel GRADIENT not a flat color (the surface overload, `fb288ad`) — both confirmed via clean Ghidra + named decomp. Divergence: IA-16 retired; IA-18 (per-pixel surface-copy anti-regression) + AP-45 (0x02CE sequence) added; **AP-43/AP-44 retired by the visual fixes**. Spec/plan/research: `docs/superpowers/{specs,plans}/2026-06-17-d2b-stateful-icon*.md`, `docs/research/2026-06-17-stateful-icon-RESOLVED.md`. -- **D.5 remaining — sub-phase ledger.** D.5.1 (toolbar + the `UiItemSlot`/`UiItemList`/`IconComposer` spine) ✅, D.5.2 (stateful icons) ✅, and D.5.4 (client object/item data model) ✅ are shipped. Build order from here: **(b) finish the bar: D.5.3 selected-object + spell shortcuts → (c) window manager → (d) core panels.** Each ☐ below gets its own brainstorm → spec → plan. +- **D.5 remaining — sub-phase ledger.** D.5.1 (toolbar + the `UiItemSlot`/`UiItemList`/`IconComposer` spine) ✅, D.5.2 (stateful icons) ✅, D.5.4 (client object/item data model) ✅, D.2b-B window manager (`abbd97b`) ✅, D.2b-B B-Grid (inventory sub-window mount + `UiItemList` grid) ✅, D.2b-B B-Controller (inventory population + burden meter + captions, `03fbf44`) ✅ are shipped. Build order from here: **(b) B-Wire (parse `EncumbranceVal`/PropertyInt 5 to retire AP-48) → (c) Sub-phase C (paperdoll `UiItemSlot` + `UiViewport` 3D doll) → (d) finish the bar: D.5.3 remaining (mana meter, stack entry/slider) + spell shortcuts.** Each ☐ below gets its own brainstorm → spec → plan. - **✓ SHIPPED — D.5.4 — Client object/item data model (foundation).** Shipped 2026-06-18 (`b506f53`..`a33e897`, 11 commits). Renamed `ItemRepository`→`ClientObjectTable` / `ItemInstance`→`ClientObject`; broadened the table to hold EVERY server object (retail `weenie_object_table` shape). `CreateObject` is now the canonical merge-upsert (`ClientObjectTable.Ingest`, retail `SetWeenieDesc` semantics) via a new Core.Net `ObjectTableWiring` (off GameWindow); `DeleteObject` evicts; `PlayerDescription` is a membership manifest (`RecordMembership`); live container-membership index (`GetContents`, retail `object_inventory_table`). `_liveEntityInfoByGuid` retired (selection/describe resolve from the one table). Root fix: the old enrich-existing-only `EnrichItem` dropped `CreateObject`s for items with no `PlayerDescription` stub — live-Coldeve 4/6 hotbar slots blank; items are now created, not dropped. **Crux resolved:** retail is TWO tables (`object_table` + `weenie_object_table`), NOT one — acdream's `WorldEntity` (3D system) + `ClientObjectTable` (data/UI) split was already architecturally faithful; the fix was the ingestion path, not a table unification. 2671 tests green. - **☐ D.5.3 — Toolbar selected-object display (issue #140) + spell shortcuts.** Wire the B.4 `WorldPicker`/selection state → the two hidden meters (`0x100001A1` health / `0x100001A2` mana) + the stack slider (`0x100001A4`) + the object-name line, so the bar shows the player's currently-selected world object. Plus **spell shortcuts** — pinned *spells* (vs items) don't render their glyphs yet (`ToolbarController.Populate` skips `ObjectGuid==0`). Together these finish "the bar." (Click-to-use + the peace/war stance indicator landed in D.5.1.) - **☐ D.5.5+ — Core panels.** Inventory (`gmInventoryUI`/`gmBackpackUI`), equipment/paperdoll (`gmPaperDollUI`/`gm3DItemsUI` + the `UiViewport` 3D doll), vendor, trade, spellbook. Research drop done (`docs/research/2026-06-16-*`). Depends on **D.5.4** (data model) + the item-slot/list/icon spine (D.5.1/D.5.2) + the **window manager** (Plan 2: open/close/z-order/persist + faithful grip/dragbar drag-resize) + the drag-drop spine wired (`UiRoot` has the chain; the per-cell accept/drop hooks are still stubs in `UiField`). Also deferred from D.5.1: drag/reorder + the `AddShortcut`/`RemoveShortcut` mutate wire. From 1ccf07b705a488f33155766364ece7eddac1d8a2 Mon Sep 17 00:00:00 2001 From: Erik Date: Sun, 21 Jun 2026 09:28:29 +0200 Subject: [PATCH 047/133] =?UTF-8?q?fix(ui):=20D.2b-B=20=E2=80=94=20address?= =?UTF-8?q?=20phase-boundary=20review=20(burden=20%=20saturation=20+=20equ?= =?UTF-8?q?ipped=20filter)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Opus phase-boundary review findings: - I1 (faithfulness): LoadToPercent now computes from the CLAMPED fill (floor(LoadToFill(load)*300)), so the burden % SATURATES at 300% like retail (decomp 176544-176576 clamps arg2 to [0,1] BEFORE the *300). The old floor(load*100) over-read to 400% at 4x capacity. Golden test corrected. - I2 (partition): exclude equipped items (CurrentlyEquippedLocation != None) from the contents grid + selector — a mid-session self-wield routes them through MoveItem(item, WielderGuid=player) into GetContents(player); retail's gm3DItemsUI shows pack contents only. New conformance test. - N1: drop dead 'using System.Collections.Generic' (left after the 383e8b7 cleanup). - N3: AP-48 risk wording (drift can be low OR high vs server EncumbranceVal). Build green; BurdenMath 17, InventoryController/UiMeter 10 tests pass. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../architecture/retail-divergence-register.md | 2 +- .../UI/Layout/InventoryController.cs | 6 +++++- src/AcDream.Core/Items/ClientObject.cs | 10 ++++++---- .../UI/Layout/InventoryControllerTests.cs | 18 ++++++++++++++++++ .../Items/BurdenMathTests.cs | 6 +++--- 5 files changed, 33 insertions(+), 9 deletions(-) diff --git a/docs/architecture/retail-divergence-register.md b/docs/architecture/retail-divergence-register.md index ab53fe22..b56d55c5 100644 --- a/docs/architecture/retail-divergence-register.md +++ b/docs/architecture/retail-divergence-register.md @@ -147,7 +147,7 @@ accepted-divergence entries (#96, #49, #50). | AP-45 | `PublicUpdatePropertyInt (0x02CE)` sequence byte parsed-past but not honored; last update wins (no freshness check against sequence number) | `src/AcDream.Core.Net/Messages/PublicUpdatePropertyInt.cs` | Loopback ACE rarely reorders; latest-wins matches `PrivateUpdateVital`/`UpdatePosition`'s existing non-sequence behavior. Sequence tracking added when needed alongside TS-26. | A reordered 0x02CE on a real network could apply a stale UiEffects value — item icon temporarily shows the wrong effect state, corrected on next update | `PublicUpdatePropertyInt` sequence byte (ACE GameMessagePublicUpdatePropertyInt) | | AP-46 | Health-meter gate approximation: retail shows the health meter for `IsPlayer() || pet_owner || ClientCombatSystem::ObjectIsAttackable()` (full PK/faction logic); acdream's `GameWindow.IsHealthBarTarget` uses the server PWD bits `BF_ATTACKABLE (0x10)` OR `BF_PLAYER (0x8)` | `src/AcDream.App/Rendering/GameWindow.cs` (`IsHealthBarTarget`) → `SelectedObjectController` | The PWD `BF_ATTACKABLE`/`BF_PLAYER` bits distinguish monsters + players (bar) from friendly/vendor NPCs (name-only) for the M1.5 dev loop; the pet case and the full ObjectIsAttackable PK/faction refinement (free-PK, PK-vs-PK, PKLite) are not ported | A PK/faction edge (e.g. a hostile-flagged player whose `BF_ATTACKABLE` is unset, or a pet) could show/hide the bar where retail differs — no impact on the non-PK PvE dev loop | `ClientCombatSystem::ObjectIsAttackable` acclient_2013_pseudo_c.txt:375385; `BF_ATTACKABLE` acclient.h:6437 | | AP-47 | Cursor drag ghost reuses the full composited `m_pIcon` (incl. type-default underlay) instead of retail's dedicated `m_pDragIcon` (base + custom-overlay, NO type-default underlay). Opacity now matches retail (full). | `src/AcDream.App/UI/UiRoot.cs` (`DrawDragGhost`/`GhostAlpha`) → `src/AcDream.App/UI/UiItemSlot.cs` (`GetDragGhost`) | Cosmetic only — the dragged item is still unambiguously identifiable; building the second underlay-less composite is deferred polish; the ghost is item-agnostic in UiRoot via `GetDragGhost()` | The ghost carries the opaque type-default underlay backing rather than retail's underlay-less copy — a subtle look difference while dragging, no functional effect. | `IconData::RenderIcons` acclient_2013_pseudo_c.txt:407594-407625 (m_pDragIcon path); deep-dive §3.2/§5.5 | -| AP-48 | 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` (decomp 0x0058f130). | `src/AcDream.App/UI/Layout/InventoryController.cs` (`RefreshBurden`) | The wire `EncumbranceVal` is not yet parsed from the server; the carried-Burden sum is the nearest client-side fallback and is correct for items in the simple pack-only case. Retire when B-Wire parses PropertyInt 5. | Drift from coins/untracked items (e.g. items inside side bags that arrived before their parent, see TS-32) — burden bar reads slightly low vs retail until B-Wire lands. | `CACQualities::InqLoad` 0x0058f130; ACE PropertyInt.EncumbranceVal=5 | +| AP-48 | 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` (decomp 0x0058f130). | `src/AcDream.App/UI/Layout/InventoryController.cs` (`RefreshBurden`) | The wire `EncumbranceVal` is not yet parsed from the server; the carried-Burden sum is the nearest client-side fallback and is correct for items in the simple pack-only case. Retire when B-Wire parses PropertyInt 5. | Drift from the server `EncumbranceVal` either way (untracked items read low; double-tracked or stale-but-present items read high) — e.g. items inside side bags that arrived before their parent (see TS-32) — until B-Wire lands. | `CACQualities::InqLoad` 0x0058f130; ACE PropertyInt.EncumbranceVal=5 | | AP-49 | Carry-capacity augmentation (PropertyInt `0xE6`) not tracked → `bonus=0` → un-augmented `Str×150` capacity. | `src/AcDream.App/UI/Layout/InventoryController.cs` (`RefreshBurden`) caller of `BurdenMath.EncumbranceCapacity` | PropertyInt `0xE6` is not yet parsed from the wire; aug=0 is the correct no-aug default and matches the vast majority of characters. | Augmented characters read slightly low capacity — burden bar shows slightly higher fill than retail for augmented chars; a three-aug char's cap is 150×Str higher per aug, so up to 450 extra points per point of Str. | `EncumbranceSystem::EncumbranceCapacity` decomp 256393 (0x004fcc00); retail `0xE6` PropertyInt augmentation | | AP-50 | Burden meter orientation/direction set programmatically (`Vertical=true`, `FillFromBottom=true`) rather than reading retail's `m_eDirection` property from the LayoutDesc element (property id `0x6f`). | `src/AcDream.App/UI/Layout/InventoryController.cs`; `src/AcDream.App/UI/UiMeter.cs` (`Vertical`/`FillFromBottom`) | The `m_eDirection` property is not yet read by `ElementReader`; bottom-up fill is visually confirmed against retail's burden bar art. Retire when `0x6f` is wired through `ElementReader`→`DatWidgetFactory`. | Wrong fill direction (top-down instead of bottom-up, or horizontal) if a future meter whose art requires a different direction is forced through the same code path. | `UIElement_Meter::DrawChildren` @0x46fbd0; property `0x6f` (`m_eDirection`) in the LayoutDesc | | AP-51 | Main-pack `m_topContainer` cell (element `0x100001C9`) uses a placeholder/empty icon (`tex=0u`), not a weenie-driven backpack icon. | `src/AcDream.App/UI/Layout/InventoryController.cs` (`Populate`) | The player's own container entity has no item `IconId` on the client (it's a character, not an item); retail uses the equipped-pack's icon via the character's equipped-pack `CreateObject`. The equipped-pack DID path is deferred to Sub-phase C. | Main-pack cell renders blank (no icon) where retail shows the equipped backpack art — cosmetic gap visible when F12 is open. | `gmBackpackUI::PostInit` @0x4a8520 (m_topContainer bind); `CreateObject` weenie icon path | diff --git a/src/AcDream.App/UI/Layout/InventoryController.cs b/src/AcDream.App/UI/Layout/InventoryController.cs index 1f0e29ab..187500c4 100644 --- a/src/AcDream.App/UI/Layout/InventoryController.cs +++ b/src/AcDream.App/UI/Layout/InventoryController.cs @@ -1,5 +1,4 @@ using System; -using System.Collections.Generic; using System.Numerics; using AcDream.Core.Items; @@ -137,6 +136,11 @@ public sealed class InventoryController { var item = _objects.Get(guid); if (item is null) continue; + // Equipped items belong on the paperdoll (Sub-phase C), never in the pack grid or + // the side-bag list. A mid-session self-wield routes an item through + // MoveItem(item, WielderGuid=player), so it transiently lands in GetContents(player); + // retail's gm3DItemsUI shows pack contents only, gated on the wielded location. + if (item.CurrentlyEquippedLocation != EquipMask.None) continue; bool isBag = item.Type.HasFlag(ItemType.Container) || item.ItemsCapacity > 0; var list = isBag ? _containerList : _contentsGrid; if (list is null) continue; diff --git a/src/AcDream.Core/Items/ClientObject.cs b/src/AcDream.Core/Items/ClientObject.cs index cf08a6b4..2ec5d309 100644 --- a/src/AcDream.Core/Items/ClientObject.cs +++ b/src/AcDream.Core/Items/ClientObject.cs @@ -258,12 +258,14 @@ public static class BurdenMath } /// - /// Retail gmBackpackUI::SetLoadLevel percent text (decomp 176576): after - /// arg2 = load/3, the text is floor(arg2 * 300) = floor(load * 100), - /// NOT clamped (reads up to 300%+). + /// Retail gmBackpackUI::SetLoadLevel percent text (decomp 176542-176576): the + /// percent is computed from the CLAMPED fill — arg2 = load/3 is clamped to [0,1] + /// (the 176544-176563 block sets arg2 = 1.0 when fill >= 1) BEFORE + /// floor(arg2 * 300). So the number SATURATES at 300% (it does NOT read 400% at + /// 4x capacity). Equivalent to floor(LoadToFill(load) * 300). /// public static int LoadToPercent(float load) - => (int)System.MathF.Floor(load * 100f); + => (int)System.MathF.Floor(LoadToFill(load) * 300f); public static int ComputeMax(int strength, int bonusBurden) => BurdenPerStrength * strength + strength * bonusBurden; diff --git a/tests/AcDream.App.Tests/UI/Layout/InventoryControllerTests.cs b/tests/AcDream.App.Tests/UI/Layout/InventoryControllerTests.cs index 14d6053e..7da7e627 100644 --- a/tests/AcDream.App.Tests/UI/Layout/InventoryControllerTests.cs +++ b/tests/AcDream.App.Tests/UI/Layout/InventoryControllerTests.cs @@ -85,6 +85,24 @@ public class InventoryControllerTests Assert.Equal(0xCu, containers.GetItem(0)!.ItemId); } + [Fact] + public void Equipped_items_are_excluded_from_the_grid_and_selector() + { + var (layout, grid, containers, _, _, _, _, _) = BuildLayout(); + var objects = new ClientObjectTable(); + SeedContained(objects, 0xA, Player, slot: 0); // loose pack item + // A self-wielded item: MoveItem with an equip location indexes it under the player + // (the live wield path), but it must NOT show in the pack grid or the selector. + objects.AddOrUpdate(new ClientObject { ObjectId = 0xD }); + objects.MoveItem(0xD, Player, newSlot: 1, newEquipLocation: EquipMask.MeleeWeapon); + + Bind(layout, objects); + + Assert.Equal(1, grid.GetNumUIItems()); // only the loose item + Assert.Equal(0xAu, grid.GetItem(0)!.ItemId); + Assert.Equal(0, containers.GetNumUIItems()); // equipped item is not a side bag either + } + [Fact] public void Grid_is_six_columns_thirtytwo_px() { diff --git a/tests/AcDream.Core.Tests/Items/BurdenMathTests.cs b/tests/AcDream.Core.Tests/Items/BurdenMathTests.cs index 25cfa043..7d9bcec7 100644 --- a/tests/AcDream.Core.Tests/Items/BurdenMathTests.cs +++ b/tests/AcDream.Core.Tests/Items/BurdenMathTests.cs @@ -33,8 +33,8 @@ public class BurdenMathTests [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) + [InlineData(3.0f, 300)] // 300% = full + [InlineData(4.0f, 300)] // SATURATES at 300% (computed from the clamped fill, decomp 176544-176576) + public void LoadToPercent_saturates_at_300(float load, int expected) => Assert.Equal(expected, BurdenMath.LoadToPercent(load)); } From 417b1375fd32a6f9fdee7ca5bc62be120b0bfc15 Mon Sep 17 00:00:00 2001 From: Erik Date: Sun, 21 Jun 2026 17:58:44 +0200 Subject: [PATCH 048/133] =?UTF-8?q?fix(ui):=20D.2b-B=20=E2=80=94=20invento?= =?UTF-8?q?ry=20panels=20render=20over=20the=20backdrop=20(#145=20continua?= =?UTF-8?q?tion)=20+=20captions?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Visual verification surfaced two render bugs (controller LOGIC was already correct): 1. BACKDROP WASH-OUT (the big one — #145 continuation). The mounted backpack/ 3D-items panels inherited their sub-window root's ZLevel 1000 via the merge's zero-wins-base rule. The #145 ZOrder fold (ReadOrder − ZLevel·10000) turned 1000 into ZOrder ≈ −10,000,000 — sinking the panels BEHIND the frame's Alphablend backdrop (ZLevel 100 → ≈ −1,000,000). The backdrop then overpainted the panels' captions/burden-meter/cells (the paperdoll root is ZLevel 0 so it escaped, which is why the previous session thought #145 was done). Fix: the sub-window mount now keeps each slot's OWN frame ZLevel, so panels sit in front of the backdrop. Root-caused via a one-shot sprite-segment-order dump (backdrop was painting after the panel content) + a live ZLevel probe. 2. CAPTIONS. The caption elements resolve to UiText; driving a nested child UiText didn't paint. AttachCaption now drives the host UiText directly. Locked by InventoryFrameImportProbe (real-dat smoke: asserts each mounted panel's ZOrder > the backdrop's). Visually confirmed by the user: dark backdrop behind, Burden 17% + vertical bar + Contents-of-Backpack + full item grid all visible. Build + App(532)/Core(1526) tests green. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../UI/Layout/InventoryController.cs | 30 +++++++- src/AcDream.App/UI/Layout/LayoutImporter.cs | 13 ++++ .../UI/Layout/InventoryFrameImportProbe.cs | 68 +++++++++++++++++++ 3 files changed, 108 insertions(+), 3 deletions(-) create mode 100644 tests/AcDream.App.Tests/UI/Layout/InventoryFrameImportProbe.cs diff --git a/src/AcDream.App/UI/Layout/InventoryController.cs b/src/AcDream.App/UI/Layout/InventoryController.cs index 187500c4..f051bb13 100644 --- a/src/AcDream.App/UI/Layout/InventoryController.cs +++ b/src/AcDream.App/UI/Layout/InventoryController.cs @@ -82,9 +82,9 @@ public sealed class InventoryController _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.) + // Captions: drive each host UiText directly with the known string (the caption + // elements resolve to UiText). "Contents of Backpack" + "%d%%" are procedural in retail + // (gm3DItemsUI/gmBackpackUI PostInit/SetLoadLevel); "Burden" is the dat label. (Spec §5.) AttachCaption(layout.FindElement(BurdenCaptionId), () => "Burden", datFont); AttachCaption(layout.FindElement(ContentsCaptionId), () => "Contents of Backpack", datFont); AttachCaption(layout.FindElement(BurdenTextId), () => _burdenPercent + "%", datFont); @@ -168,6 +168,30 @@ public sealed class InventoryController private void AttachCaption(UiElement? host, Func text, UiDatFont? datFont) { if (host is null) return; + + // The caption elements (0x100001D7 "Burden", 0x100001C5 "Contents of Backpack", + // 0x100001D8 "%") resolve to UiText (Type-0 inheriting a text base — confirmed live). + // Drive the host UiText DIRECTLY: it is already in the paint tree and renders, whereas + // a nested child UiText did not paint. Set it to a static centered single-line label. + if (host is UiText t) + { + t.Centered = true; + t.DatFont = datFont; + t.ClickThrough = true; + t.AcceptsFocus = false; + t.IsEditControl = false; + t.CapturesPointerDrag = false; + t.LinesProvider = () => + { + var s = text(); + return string.IsNullOrEmpty(s) + ? Array.Empty() + : new[] { new UiText.Line(s, CaptionColor) }; + }; + return; + } + + // Fallback (non-UiText host): attach a centered label child. var label = new UiText { Left = 0f, Top = 0f, Width = host.Width, Height = host.Height, diff --git a/src/AcDream.App/UI/Layout/LayoutImporter.cs b/src/AcDream.App/UI/Layout/LayoutImporter.cs index 26c63465..0a63d5dd 100644 --- a/src/AcDream.App/UI/Layout/LayoutImporter.cs +++ b/src/AcDream.App/UI/Layout/LayoutImporter.cs @@ -242,8 +242,21 @@ public static class LayoutImporter // (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); + // The mounted slot's layer WITHIN THE FRAME is its OWN ZLevel, not the mounted + // sub-window root's. The gm*UI sub-window roots carry ZLevel 1000 (their standalone + // top-window layer); ElementReader.Merge's zero-wins-base rule made the slot (own + // ZLevel 0) inherit that 1000, and the #145 ZOrder fold (ReadOrder − ZLevel·10000) + // turns 1000 into ZOrder ≈ −10,000,000 — sinking the whole panel BEHIND the frame's + // Alphablend backdrop (ZLevel 100 → ≈ −1,000,000). The backdrop then overpaints the + // panel's captions/meter/cells (the wash-out bug; the paperdoll root happens to be + // ZLevel 0 so it escaped). Restore the slot's own frame-layer so the panel sits in + // FRONT of the backdrop. (B-Controller debug 2026-06-21; continuation of #145.) + result.ZLevel = self.ZLevel; + } + return result; } diff --git a/tests/AcDream.App.Tests/UI/Layout/InventoryFrameImportProbe.cs b/tests/AcDream.App.Tests/UI/Layout/InventoryFrameImportProbe.cs new file mode 100644 index 00000000..bc4acf60 --- /dev/null +++ b/tests/AcDream.App.Tests/UI/Layout/InventoryFrameImportProbe.cs @@ -0,0 +1,68 @@ +using System; +using System.IO; +using AcDream.App.UI.Layout; +using DatReaderWriter; +using DatReaderWriter.Options; +using Xunit; + +namespace AcDream.App.Tests.UI.Layout; + +/// +/// Real-dat smoke test for the gmInventoryUI frame (0x21000023) import + the sub-window +/// mount. Locks the #145-continuation fix: the mount keeps each mounted panel slot's OWN +/// frame ZLevel instead of inheriting the sub-window root's ZLevel 1000. Before the fix the +/// merge's zero-wins-base rule gave the slots ZLevel 1000 → the #145 ZOrder fold +/// (ReadOrder − ZLevel·10000) sank them to ≈ −10,000,000, BEHIND the frame's Alphablend +/// backdrop (ZLevel 100 → ≈ −1,000,000), and the backdrop washed out the panels' captions, +/// burden meter, and item cells. Skips when the live dat directory is absent (CI). +/// +public class InventoryFrameImportProbe +{ + private const uint Frame = 0x21000023u; + private const uint Backdrop = 0x100001D0u; // full-window Alphablend backdrop (ZLevel 100) + private const uint BackpackPanel = 0x100001CEu; // mounted gmBackpackUI slot + private const uint ItemsPanel = 0x100001CFu; // mounted gm3DItemsUI slot + private const uint PaperdollPanel = 0x100001CDu; // mounted gmPaperDollUI slot + private const uint BurdenMeter = 0x100001D9u; // backpack content (proves the mount attached it) + private const uint ContentsGrid = 0x100001C6u; // 3D-items content + + 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 Mounted_panels_sit_in_front_of_the_backdrop() + { + var datDir = DatDir(); + if (datDir is null) return; // CI: no live dat — skip (this is a smoke test) + + using var dats = new DatCollection(datDir, DatAccessType.Read); + var layout = LayoutImporter.Import(dats, Frame, _ => (0u, 0, 0), null); + Assert.NotNull(layout); + + var backdrop = layout!.FindElement(Backdrop); + Assert.NotNull(backdrop); // the full-window backdrop is a direct child of the frame + + // The sub-window mount brings each panel's content into the by-id index. + Assert.NotNull(layout.FindElement(BurdenMeter)); // backpack burden meter + Assert.NotNull(layout.FindElement(ContentsGrid)); // 3D-items contents grid + + // The fix: every mounted panel slot must draw IN FRONT of the backdrop + // (higher ZOrder = painted later = on top), so the backdrop sits behind the content. + foreach (var (id, name) in new[] + { + (BackpackPanel, "backpack"), (ItemsPanel, "3D-items"), (PaperdollPanel, "paperdoll"), + }) + { + var panel = layout.FindElement(id); + Assert.True(panel is not null, $"{name} panel 0x{id:X8} missing from the imported tree"); + Assert.True(panel!.ZOrder > backdrop!.ZOrder, + $"{name} panel ZOrder {panel.ZOrder} must be > backdrop ZOrder {backdrop.ZOrder} " + + "(else the Alphablend backdrop overpaints/washes out the panel content)"); + } + } +} From c38f098ec0bc8daac34c3a412afb583eaa85431d Mon Sep 17 00:00:00 2001 From: Erik Date: Sun, 21 Jun 2026 18:01:38 +0200 Subject: [PATCH 049/133] docs(D.2b-B): B-Controller visually confirmed; #145 continuation + overflow follow-up MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - #145: the original fix was incomplete (mounted panels inherited ZLevel 1000 → behind the backdrop → washed out); continuation 417b137 closes it. Updated status. - D.2b-B SHIPPED entry: visually confirmed; the two render bugs (backdrop wash-out + captions host-direct) documented. - New OPEN issue: contents grid overflows (needs the gutter scrollbar wired; scroll was out-of-scope for B-Controller per the spec). - Roadmap ledger: B-Controller visually confirmed + the render fix noted. Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/ISSUES.md | 15 +++++++++++++-- docs/plans/2026-04-11-roadmap.md | 2 +- 2 files changed, 14 insertions(+), 3 deletions(-) diff --git a/docs/ISSUES.md b/docs/ISSUES.md index 355206f7..0f100722 100644 --- a/docs/ISSUES.md +++ b/docs/ISSUES.md @@ -48,7 +48,7 @@ Copy this block when adding a new issue: ## #145 — Inventory window panels occluded by the full-window backdrop (importer ignores ZLevel) -**Status:** DONE (2026-06-21 · `45a5cc5`) — occlusion fixed; the importer now folds `ZLevel` into `ZOrder`, so the backdrop sits behind the panels and they render (paperdoll equip-slot positions visually confirmed). The remaining gaps are NOT occlusion: empty backpack/3D-items content → B-Controller; per-slot paperdoll silhouettes (equip slots fall back to a generic `UiDatElement` sprite — they need `0x10000032` UiItemSlot + per-slot art) → Sub-phase C. Verify chat/toolbar (ZLevel 900 / 1,2) showed no regression. +**Status:** DONE (2026-06-21 · `45a5cc5` + continuation `417b137`) — VISUALLY CONFIRMED. The first fix (`45a5cc5`) folded `ZLevel` into `ZOrder` and put the backdrop behind the **ZLevel-0 top-level** panels, which made the **paperdoll** (its base root is ZLevel 0) render — so it *looked* done. But the **mounted backpack/3D-items panels** inherited their sub-window root's **ZLevel 1000** (via `ElementReader.Merge`'s zero-wins-base rule), so the `ReadOrder − ZLevel·10000` fold sank them to ZOrder ≈ −10,000,000 — *behind* the backdrop (ZLevel 100 → ≈ −1,000,000). The Alphablend backdrop then **washed out** the backpack/3D-items captions + burden meter + item cells (only the bright paperdoll survived the wash). Surfaced once B-Controller populated those panels. Continuation fix `417b137`: the sub-window mount keeps each slot's **own** frame ZLevel (not the base root's 1000), so panels sit in front of the backdrop. Confirmed live (Burden 17% + vertical bar + "Contents of Backpack" + full item grid all render on the dark backdrop). Locked by `InventoryFrameImportProbe` (real-dat: each mounted panel's ZOrder > the backdrop's). DO-NOT-RETRY: a mounted sub-window slot must NOT inherit the base layout root's ZLevel. Per-slot paperdoll silhouettes (generic `UiDatElement` sprite — need `0x10000032` UiItemSlot + per-slot art) → Sub-phase C. **Severity:** MEDIUM (blocks B-Grid visual acceptance — the gmInventoryUI nested panels render blank) **Filed:** 2026-06-20 **Component:** ui — LayoutImporter / DatWidgetFactory z-order @@ -5349,7 +5349,18 @@ outdoors at the angle that previously erased it. **Closed:** 2026-06-21 **Component:** ui — D.2b inventory window (gmInventoryUI 0x21000023) -`InventoryController.Bind` wired into the existing inventory-init block in `GameWindow.cs` (commit `03fbf44`). The controller populates the "Contents of Backpack" grid (6 cols × 32 px) and the pack-selector strip from `ClientObjectTable`, drives the vertical burden meter via `BurdenMath.EncumbranceCapacity/LoadRatio/LoadToFill`, and attaches "Burden" + "Contents of Backpack" + `%` captions. Four divergence rows added: AP-48 (client-side burden sum), AP-49 (aug capacity unwired), AP-50 (meter direction from geometry), AP-51 (main-pack icon placeholder). Divergence register + docs commit: `docs(D.2b-B)`. **Remaining open sub-phases:** B-Wire (parse `EncumbranceVal`/PropertyInt 5 from the wire to retire AP-48), B-Drag (drag-from-inventory SourceKind branch closes B.2), Sub-phase C (paperdoll equip-slot `UiItemSlot` registration). +`InventoryController.Bind` wired into the existing inventory-init block in `GameWindow.cs` (commit `03fbf44`). The controller populates the "Contents of Backpack" grid (6 cols × 32 px) and the pack-selector strip from `ClientObjectTable`, drives the vertical burden meter via `BurdenMath.EncumbranceCapacity/LoadRatio/LoadToFill`, and attaches "Burden" + "Contents of Backpack" + `%` captions. Four divergence rows added: AP-48 (client-side burden sum), AP-49 (aug capacity unwired), AP-50 (meter direction from geometry), AP-51 (main-pack icon placeholder). Divergence register + docs commit: `docs(D.2b-B)`. + +**VISUALLY CONFIRMED 2026-06-21.** At the visual gate the controller's *logic* was correct (live diagnostics: 36 items + 1 side bag bound, burden Str 290 → 17%, captions' `DrawStringDat` called, meter sprites resolved), but two RENDER bugs hid the result; both fixed (`417b137`): (1) backdrop wash-out — the #145 continuation above; (2) captions — the caption elements resolve to `UiText`, and driving a nested *child* `UiText` didn't paint, so `AttachCaption` now drives the host `UiText` directly. After the fixes: dark backdrop behind, "Burden 17%" + vertical bar + side-bag + "Contents of Backpack" + full item grid all render. **Remaining open sub-phases:** B-Wire (parse `EncumbranceVal`/PropertyInt 5 from the wire to retire AP-48), B-Drag (drag-from-inventory SourceKind branch closes B.2), Sub-phase C (paperdoll equip-slot `UiItemSlot` registration), + the contents-grid scroll polish (next issue). + +--- + +## Inventory "Contents of Backpack" grid overflows (no scroll) + +**Status:** OPEN — LOW (B-Controller polish) +**Component:** ui — D.2b inventory (gm3DItemsUI grid `0x100001C6`) + +**Description:** `InventoryController` populates the 6-col contents grid with ALL of the player's loose pack items via `UiItemList` grid mode, so a pack with >18 items (6 cols × 3 visible rows in the 192×96 panel) overflows BELOW the panel/frame ("part of the inventory hanging off the window"). Retail scrolls the grid via the side gutter scrollbar (`0x100001C7`). Fix: wire the gutter `UiScrollbar` to the grid + clip the grid to the panel (scroll the overflow). Spec listed scrolling as out-of-scope for B-Controller (`docs/superpowers/specs/2026-06-21-d2b-inventory-controller-design.md` §10), so this is a deliberate follow-up, not a regression. --- diff --git a/docs/plans/2026-04-11-roadmap.md b/docs/plans/2026-04-11-roadmap.md index 7c006714..28d6c2f7 100644 --- a/docs/plans/2026-04-11-roadmap.md +++ b/docs/plans/2026-04-11-roadmap.md @@ -433,7 +433,7 @@ behavior. Estimated 17–26 days focused work, 3–5 weeks calendar. - **D.5 — Core panels.** Attributes (`chunk_00470000.c:FUN_0047ba70`), Skills (same), Paperdoll (`chunk_004A0000.c:FUN_004A5200`), Inventory, Spellbook (`chunk_004C0000.c`), Fellowship, Allegiance. Each uses the port sketches in slice 05. **(Targets `AcDream.UI.Abstractions` — ships with D.2a using ImGui-rendered widgets; reskinned by D.2b.)** The *chat* panel originally listed under D.5 shipped early in Phase I (I.4 input + I.7 combat translator superseded the chat-panel design here); this entry now covers Attributes / Skills / Paperdoll / Inventory / Spellbook / Fellowship / Allegiance only. - **✓ SHIPPED — D.5.1 — Toolbar (action bar).** Shipped 2026-06-16/17 (`30b28c2`→`0e7a083`, branch claude/hopeful-maxwell-214a12). First data-driven *game* panel: `gmToolbarUI` (`LayoutDesc 0x21000016`) — 18 shortcut slots from the persisted `PlayerDescription` SHORTCUT block, real **composited** item icons (opaque type-default underlay via the `EnumIDMap 0x10000004` resolve), **occupancy-gated slot numbers 1–9** (occupied = dark-box peace/war `0x10000042/43`; empty = background `0x1000005e` from cell composite `0x10000341`), **click-to-use** (`ItemHolder::UseObject` → `0x0036`), **peace/war stance** indicator live-wired to `CombatState`, **movable**, and a **chrome frame** (UiNineSlicePanel drawn over content via the new `UiElement.OnDrawAfterChildren` hook). New shared widgets `UiItemSlot` (`UIElement_UIItem` 0x10000032, procedural leaf) + `UiItemList` (`UIElement_ItemList` 0x10000031, factory branch) + `IconComposer` (CPU layered composite). `CreateObject.TryParse` extended to the full ACE-order weenie-header tail to capture `IconId`/`IconOverlay`/`IconUnderlay` → `ItemRepository.EnrichItem` → re-render. Spec/plan `docs/superpowers/{specs,plans}/2026-06-16-d2b-toolbar-phase1*.md`; research drop `docs/research/2026-06-16-*deep-dive.md` + synthesis. Divergence IA-16/IA-17 added. **User-confirmed** (numbers, icons, frame). Per-task spec+code-review throughout. - **✓ SHIPPED — D.5.2 — Stateful item-icon system.** Shipped 2026-06-17/18 (`419c3ac`..`fb288ad`, branch claude/hopeful-maxwell-214a12; **visually verified on a live Coldeve server**). Faithful retail icon composite (`IconData::RenderIcons` @0x0058d180): (1) `UiEffects` bitfield captured from the `CreateObject` weenie header (was discarded) → `ItemInstance.Effects`; (2) `IconComposer.GetIcon` rewritten as a 2-stage composite — Stage 1 = drag icon (base + custom overlay) + the effect treatment, Stage 2 = type-default underlay + custom underlay + drag. The effect treatment ports the **surface overload** of `SurfaceWindow::ReplaceColor` (`0x004415b0`): the textured effect tile (`EnumIDMap 0x10000005` by `LowestSetBit(effects)+1`, fallback `0x21` solid-black) is copied **per-pixel** into the icon's pure-white pixels — magical items take the tile's GRADIENT hue, mundane items go black; (3) `PublicUpdatePropertyInt (0x02CE)` parser + `WorldSession.ObjectIntPropertyUpdated` event + `GameWindow` subscription → `ItemRepository.UpdateIntProperty` → icon re-composites live. **Appraise (`0x00C9`) carries NO icon data** (ACE proof: `Icon`/`IconOverlay`/`IconUnderlay`/`UiEffects` all lack `[AssessmentProperty]`) — dropped as a no-op. **Two visual-verification fixes landed after the subagent build:** the `effects==0` recolor MUST run (mundane white edges → black, `40c97a5`) and the tint is a per-pixel GRADIENT not a flat color (the surface overload, `fb288ad`) — both confirmed via clean Ghidra + named decomp. Divergence: IA-16 retired; IA-18 (per-pixel surface-copy anti-regression) + AP-45 (0x02CE sequence) added; **AP-43/AP-44 retired by the visual fixes**. Spec/plan/research: `docs/superpowers/{specs,plans}/2026-06-17-d2b-stateful-icon*.md`, `docs/research/2026-06-17-stateful-icon-RESOLVED.md`. -- **D.5 remaining — sub-phase ledger.** D.5.1 (toolbar + the `UiItemSlot`/`UiItemList`/`IconComposer` spine) ✅, D.5.2 (stateful icons) ✅, D.5.4 (client object/item data model) ✅, D.2b-B window manager (`abbd97b`) ✅, D.2b-B B-Grid (inventory sub-window mount + `UiItemList` grid) ✅, D.2b-B B-Controller (inventory population + burden meter + captions, `03fbf44`) ✅ are shipped. Build order from here: **(b) B-Wire (parse `EncumbranceVal`/PropertyInt 5 to retire AP-48) → (c) Sub-phase C (paperdoll `UiItemSlot` + `UiViewport` 3D doll) → (d) finish the bar: D.5.3 remaining (mana meter, stack entry/slider) + spell shortcuts.** Each ☐ below gets its own brainstorm → spec → plan. +- **D.5 remaining — sub-phase ledger.** D.5.1 (toolbar + the `UiItemSlot`/`UiItemList`/`IconComposer` spine) ✅, D.5.2 (stateful icons) ✅, D.5.4 (client object/item data model) ✅, D.2b-B window manager (`abbd97b`) ✅, D.2b-B B-Grid (inventory sub-window mount + `UiItemList` grid) ✅, D.2b-B B-Controller (inventory population + burden meter + captions, `03fbf44`; **visually confirmed 2026-06-21** — two render bugs fixed at the gate `417b137`: backdrop wash-out [a #145 continuation — mounted sub-window slots must keep their own frame ZLevel, not inherit the base root's 1000] + captions [drive the host UiText directly]) ✅ are shipped. Build order from here: **(b) B-Wire (parse `EncumbranceVal`/PropertyInt 5 to retire AP-48) → (c) Sub-phase C (paperdoll `UiItemSlot` + `UiViewport` 3D doll) → (d) finish the bar: D.5.3 remaining (mana meter, stack entry/slider) + spell shortcuts.** Each ☐ below gets its own brainstorm → spec → plan. - **✓ SHIPPED — D.5.4 — Client object/item data model (foundation).** Shipped 2026-06-18 (`b506f53`..`a33e897`, 11 commits). Renamed `ItemRepository`→`ClientObjectTable` / `ItemInstance`→`ClientObject`; broadened the table to hold EVERY server object (retail `weenie_object_table` shape). `CreateObject` is now the canonical merge-upsert (`ClientObjectTable.Ingest`, retail `SetWeenieDesc` semantics) via a new Core.Net `ObjectTableWiring` (off GameWindow); `DeleteObject` evicts; `PlayerDescription` is a membership manifest (`RecordMembership`); live container-membership index (`GetContents`, retail `object_inventory_table`). `_liveEntityInfoByGuid` retired (selection/describe resolve from the one table). Root fix: the old enrich-existing-only `EnrichItem` dropped `CreateObject`s for items with no `PlayerDescription` stub — live-Coldeve 4/6 hotbar slots blank; items are now created, not dropped. **Crux resolved:** retail is TWO tables (`object_table` + `weenie_object_table`), NOT one — acdream's `WorldEntity` (3D system) + `ClientObjectTable` (data/UI) split was already architecturally faithful; the fix was the ingestion path, not a table unification. 2671 tests green. - **☐ D.5.3 — Toolbar selected-object display (issue #140) + spell shortcuts.** Wire the B.4 `WorldPicker`/selection state → the two hidden meters (`0x100001A1` health / `0x100001A2` mana) + the stack slider (`0x100001A4`) + the object-name line, so the bar shows the player's currently-selected world object. Plus **spell shortcuts** — pinned *spells* (vs items) don't render their glyphs yet (`ToolbarController.Populate` skips `ObjectGuid==0`). Together these finish "the bar." (Click-to-use + the peace/war stance indicator landed in D.5.1.) - **☐ D.5.5+ — Core panels.** Inventory (`gmInventoryUI`/`gmBackpackUI`), equipment/paperdoll (`gmPaperDollUI`/`gm3DItemsUI` + the `UiViewport` 3D doll), vendor, trade, spellbook. Research drop done (`docs/research/2026-06-16-*`). Depends on **D.5.4** (data model) + the item-slot/list/icon spine (D.5.1/D.5.2) + the **window manager** (Plan 2: open/close/z-order/persist + faithful grip/dragbar drag-resize) + the drag-drop spine wired (`UiRoot` has the chain; the per-cell accept/drop hooks are still stubs in `UiField`). Also deferred from D.5.1: drag/reorder + the `AddShortcut`/`RemoveShortcut` mutate wire. From 1230c07af3616af48d6dc8e2f85da254a27cbd18 Mon Sep 17 00:00:00 2001 From: Erik Date: Sun, 21 Jun 2026 18:08:05 +0200 Subject: [PATCH 050/133] docs(handoff): D.2b B-Controller shipped + visually confirmed; B-Wire next Co-Authored-By: Claude Opus 4.8 (1M context) --- ...21-d2b-bcontroller-shipped-next-handoff.md | 167 ++++++++++++++++++ 1 file changed, 167 insertions(+) create mode 100644 docs/research/2026-06-21-d2b-bcontroller-shipped-next-handoff.md diff --git a/docs/research/2026-06-21-d2b-bcontroller-shipped-next-handoff.md b/docs/research/2026-06-21-d2b-bcontroller-shipped-next-handoff.md new file mode 100644 index 00000000..8ad39e1d --- /dev/null +++ b/docs/research/2026-06-21-d2b-bcontroller-shipped-next-handoff.md @@ -0,0 +1,167 @@ +# Handoff — D.2b inventory: B-Controller shipped + visually confirmed; B-Wire next + +**Date:** 2026-06-21 (later than the B-Grid handoff of the same date) +**From:** the session that shipped **B-Controller** (inventory population + burden meter + +captions) and root-caused/fixed the **backdrop wash-out** (a #145 continuation) at the visual gate. +**Predecessor:** `docs/research/2026-06-21-d2b-bgrid-shipped-bcontroller-next-handoff.md` +(window manager + B-Grid). This doc supersedes it for "what's next." +**Branch:** `claude/hopeful-maxwell-214a12`, tip **`c38f098`**. `main` is a clean +fast-forward ancestor — ff `main` + branch fresh, or continue this branch. +**Line numbers drift — grep the symbol.** + +--- + +## 0. What shipped this session (all committed, build + tests green, VISUALLY CONFIRMED) + +**B-Controller** — `InventoryController` (the `gm*UI::PostInit` analogue) binds the imported +`gmInventoryUI 0x21000023` tree by id and populates it from `ClientObjectTable`. F12 (with +`ACDREAM_RETAIL_UI=1`) now shows your live inventory on the dark backdrop: the "Contents of +Backpack" grid, the pack-selector strip, the vertical burden bar ("Burden 17%"), and the captions. + +Commits (after the input handoff's `4e23a7b`): +- `5875ac8` `BurdenMath` encumbrance ports (Core) +- `fb050ae` `ClientObjectTable.SumCarriedBurden` (Core) +- `5e75d2a` `UiMeter` vertical fill (`Vertical`/`FillFromBottom`/`DrawVBar`) +- `89c640a` `InventoryController` bind + populate + burden + captions +- `383e8b7` follow-up (Populate uses `GetContents` only — dropped a subagent's index-fallback workaround) +- `03fbf44` GameWindow wiring (the `InventoryController.Bind` in the inventory-init block) +- `7bb4bd3` divergence rows AP-48..51 + ISSUES/roadmap +- `1ccf07b` Opus phase-boundary review fixes (burden % saturation + equipped-item filter) +- `417b137` **render fixes** (backdrop wash-out [#145 continuation] + captions) — the visual-gate fix +- `c38f098` docs (ISSUES/roadmap: visually confirmed + #145 continuation + overflow follow-up) + +Spec: `docs/superpowers/specs/2026-06-21-d2b-inventory-controller-design.md`. +Plan: `docs/superpowers/plans/2026-06-21-d2b-inventory-controller.md`. +Tests: **App 532 / Core 1526 green.** `InventoryFrameImportProbe` is a real-dat smoke test +(gated on `ACDREAM_DAT_DIR`) that **locks the ZLevel fix** (asserts each mounted panel's +ZOrder > the backdrop's). + +--- + +## 1. Read first + +- This doc. +- `claude-memory/project_d2b_retail_ui.md` — the B-Controller SHIPPED entry + DO-NOT-RETRY (updated this session). +- `docs/superpowers/specs/2026-06-21-d2b-inventory-controller-design.md` — the B-Controller spec. +- `docs/research/2026-06-16-inventory-deep-dive.md` §4 — the inventory wire catalog (for **B-Wire**). +- `docs/research/2026-06-16-ui-panels-synthesis.md` §3.3 — the de-duped wire-gap TODO. +- `docs/ISSUES.md` — the **#145** entry (now closed via the continuation), the **D.2b-B SHIPPED** entry, and the new **contents-grid overflow** issue. + +--- + +## 2. Key discoveries (durable, non-obvious — DO-NOT-RETRY) + +1. **THE BIG ONE — a mounted sub-window slot must keep its OWN frame ZLevel, NOT inherit the + base layout root's.** The gm*UI sub-window roots (`0x100001C8` backpack, `0x100001C4` + 3D-items) carry **ZLevel 1000**. `ElementReader.Merge`'s zero-wins-base rule (`ElementReader.cs:161`, + `ZLevel = derived.ZLevel != 0 ? derived.ZLevel : base_.ZLevel`) made the mounted slots + (own ZLevel 0) inherit that 1000, and the #145 ZOrder fold (`ReadOrder − ZLevel·10000`) + turned 1000 into ZOrder ≈ −10,000,000 — *behind* the frame's Alphablend backdrop + (`0x100001D0`, ZLevel 100 → ≈ −1,000,000). The backdrop then **overpainted/washed out** the + panels' captions + burden meter + item cells. The **paperdoll** root happens to be ZLevel 0, + so it survived — which is why the B-Grid session believed #145 was "done." Fix + (`417b137`): `LayoutImporter.Resolve` sets `result.ZLevel = self.ZLevel` inside the + `ShouldMountBaseChildren` block. **Root-caused via a one-shot `TextRenderer` sprite-segment-order + dump** (the backdrop's segment was drawing AFTER the panel content) + a live ZLevel probe — when + "drawn but invisible," dump the actual paint order, don't keep theorizing z-math. + +2. **Burden formula (ported into `Core.Items.BurdenMath`):** + `capacity = Str × (150 + clamp(aug×30, 0, 150))` (`EncumbranceSystem::EncumbranceCapacity` + `0x004fcc00`); `load = burden / capacity` (`EncumbranceSystem::Load` `0x004fcc40` — the + decompiler MANGLED the FP divide to `return burden`; cross-checked vs the existing `ComputeMax` + + the r06 doc); `gmBackpackUI::SetLoadLevel` (`0x004a6ea0`) → fill = `clamp(load/3, 0, 1)`, + %text = `floor(load × 100)` **SATURATING at 300%** (computed from the CLAMPED fill, not + unclamped — caught in review). `CACQualities::InqLoad` `0x0058f130`. **acdream has no wire + `EncumbranceVal` yet** → client-side `SumCarriedBurden` fallback (divergence **AP-48**; + B-Wire pins the real wire value). + +3. **`DatWidgetFactory.BuildMeter`'s single-image path is ALREADY correct for the burden meter** + (meter-own DirectState `0x0600121D` = back/track; its one Type-3 child `0x0600121C` = fill) + per `UIElement_Meter::DrawChildren` `0x0046fbd0` (the child = `m_pcChildImage = GetChildRecursive(this, 2)`). + The inventory deep-dive's "0x0600121C = back" label was backwards re: draw role. The only + meter change was a **vertical** fill path (`UiMeter.Vertical`/`FillFromBottom`/`DrawVBar`). + A near-empty fill at low load is CORRECT (Str 290 → 17% → ~3px of a 58px bar), not a bug. + +4. **Captions: drive the HOST `UiText` directly.** The caption elements (`0x100001D7` "Burden", + `0x100001D8` %, `0x100001C5` "Contents of Backpack") resolve to `UiText`. A nested *child* + `UiText` does NOT paint — `AttachCaption` now sets the host UiText's `LinesProvider`/`Centered`/`DatFont`. + +5. **Equipped items excluded** from the pack grid + selector (`CurrentlyEquippedLocation != None`) + — a mid-session self-wield routes them through `MoveItem(item, WielderGuid=player)` into + `GetContents(player)`; retail's gm3DItemsUI shows pack contents only. + +--- + +## 3. Current visual state (F12, `ACDREAM_RETAIL_UI=1`) + +Renders correctly: dark backdrop BEHIND; paperdoll equip slots (generic blue border); backpack +strip ("Burden" + % + vertical bar + side-bag cell + main-pack cell); 3D-items "Contents of +Backpack" grid populated (6-col). **Expected gaps (all are the follow-ups below):** +- Contents grid OVERFLOWS the 96px panel (no scroll) — "part hanging off the window." +- Paperdoll equip slots show a generic blue `UiDatElement` border, not per-slot silhouettes → Sub-phase C. +- The inventory window starts HIDDEN; F12 toggles it (the auto-open debug hack was reverted). + +--- + +## 4. What's next (build order; each gets its own brainstorm → spec → plan → subagent flow) + +**(a) B-Wire — RECOMMENDED NEXT.** Parse the wire `EncumbranceVal` (ACE `PropertyInt 5`) for the +player so the burden bar reads the server value (retires **AP-48**, the client-side sum). Plus the +other inventory wire gaps catalogued in `2026-06-16-inventory-deep-dive.md §4` + the de-duped TODO +in `2026-06-16-ui-panels-synthesis.md §3.3`: builders `DropItem 0x001B`, `GetAndWieldItem 0x001A`, +`NoLongerViewingContents 0x0195`; parsers `ViewContents 0x0196`, `SetStackSize`, `InventoryRemoveObject`; +fix the dropped 4th field on `0x0022` + the dropped error on `0x00A0`; register the unwired parsers; +extend `CreateObject` if any icon/stack fields are still discarded. **Mandatory grep-named → +cross-ref → pseudocode → port** for each wire format; ACE handlers + holtburger fixtures are the oracles. + +**(b) Contents-grid scroll polish.** The 6-col grid shows ALL loose items, overflowing the 96px +panel. Wire the gutter `UiScrollbar` (`0x100001C7`) to the grid + clip the grid to the panel + +scroll the overflow. Filed as an OPEN issue in `docs/ISSUES.md`; was out-of-scope for B-Controller +(spec §10). Small, self-contained. + +**(c) B-Drag.** Inventory cell as a drag SOURCE (reuse the shipped spine — `UiItemSlot` is already a +full drag source/target) + the `SourceKind == Inventory` branch in `ToolbarController.HandleDropRelease`. + +**(d) Sub-phase C — the biggest piece.** Paperdoll: register `0x10000032` (`UiItemSlot`) so equip +slots draw per-slot silhouettes (fixes the generic-blue-slot art) + the `UiViewport` (Type `0xD`) +3D character doll, which needs a new Core→App `IUiViewportRenderer` seam (Code-Structure Rule 2). +Research: `docs/research/2026-06-16-equipment-paperdoll-deep-dive.md`. + +(Then D.5.3-remaining: the toolbar mana meter + stack slider + spell shortcuts; vendor/trade/spellbook.) + +--- + +## 5. Caveats / open items + +- **Chat + toolbar #145 z-order eyeball (the input handoff's first ask):** analytically cleared + this session — chat's only non-zero-ZLevel element (`0x1000000E`, ZLevel 900) was already the + backmost top-level sibling, so the #145 fold can't change its order; the toolbar now honors + ZLevel (retail-faithful). No regression seen across ~10 launches. Not separately user-confirmed + in isolation, but low-risk — glance once if convenient. +- The `InventoryFrameImportProbe` real-dat test SKIPS in CI (no dat). It only asserts when + `ACDREAM_DAT_DIR` (or the default `~/Documents/Asheron's Call`) exists. +- `ClientObjectTable.GetContents(playerGuid)` returns `IReadOnlyList` (guids), NOT + `ClientObject`s — a prior research agent's claim was wrong; trust the source. +- The main-pack cell (`m_topContainer 0x100001C9`) uses a placeholder icon (the main pack ≡ the + player, no item IconId) — AP-51. Pin a backpack RenderSurface DID if it bothers you. + +--- + +## 6. New-session prompt (paste into a fresh session) + +> Continue acdream's D.2b retail-UI inventory arc. **Read +> `docs/research/2026-06-21-d2b-bcontroller-shipped-next-handoff.md` first.** B-Controller +> (inventory population + burden meter + captions) shipped + visually confirmed on +> `claude/hopeful-maxwell-214a12` (tip `c38f098`; `main` is a clean ff ancestor — ff + branch +> fresh, or continue). The backdrop wash-out (a #145 continuation: mounted sub-window slots were +> inheriting the base root's ZLevel 1000 → behind the backdrop) is fixed. **Next: B-Wire** — parse +> the player's wire `EncumbranceVal` (PropertyInt 5) to retire the client-side burden-sum fallback +> (AP-48), plus the inventory wire gaps in `2026-06-16-inventory-deep-dive.md §4` / +> `2026-06-16-ui-panels-synthesis.md §3.3`. Use the full brainstorm → spec → plan → subagent-driven +> flow; mandatory grep-named → cross-ref → pseudocode → port for every wire format; conformance +> tests throughout. After B-Wire: the contents-grid scroll polish (wire gutter `0x100001C7`), +> B-Drag (inventory drag SOURCE), then Sub-phase C (paperdoll `UiViewport` doll + `0x10000032` +> UiItemSlot per-slot art). + +**MEMORY.md index line** (already added this session): +- [Project: D.2b retail UI engine](project_d2b_retail_ui.md) — … Inventory B-Controller SHIPPED 2026-06-21. DO-NOT-RETRY: a mounted sub-window slot must keep its OWN frame ZLevel — inheriting the base root's ZLevel 1000 sinks the panel behind the Alphablend backdrop (the #145-continuation wash-out). From 5737951a19f72a545525caadfcc271327a42ef4b Mon Sep 17 00:00:00 2001 From: Erik Date: Sun, 21 Jun 2026 18:25:58 +0200 Subject: [PATCH 051/133] =?UTF-8?q?docs(D.2b-B):=20B-Wire=20spec=20?= =?UTF-8?q?=E2=80=94=20inventory=20wire=20layer?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Design/spec for B-Wire (follows B-Controller c38f098). Root cause found: the burden binding already reads wire EncumbranceVal (PropertyInt 5) but the value never arrives — login PD parses the player's int table then drops it, the live 0x02CD (PrivateUpdatePropertyInt, no guid) is unparsed, and ObjectTableWiring gates all non-UiEffects ints out. Scope (user chose the full wire pass): player-property delivery (login PD bundle upsert + 0x02CD parse + loosen the apply gate, retiring AP-48/AP-49), latent-bug fixes (0x0022 4th field, 0x00A0 error), new C→S builders (DropItem 0x001B / GetAndWieldItem 0x001A / NoLongerViewingContents 0x0195), new S→C parsers (ViewContents 0x0196 GameEvent; SetStackSize 0x0197 + InventoryRemoveObject 0x0024 GameMessages), and WireAll registration. Opcodes pinned against ACE GameMessageOpcode.cs / GameActionType.cs; every format requires grep-named → cross-ref → pseudocode → port + conformance. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../2026-06-21-d2b-inventory-wire-design.md | 272 ++++++++++++++++++ 1 file changed, 272 insertions(+) create mode 100644 docs/superpowers/specs/2026-06-21-d2b-inventory-wire-design.md diff --git a/docs/superpowers/specs/2026-06-21-d2b-inventory-wire-design.md b/docs/superpowers/specs/2026-06-21-d2b-inventory-wire-design.md new file mode 100644 index 00000000..f1a3d5e7 --- /dev/null +++ b/docs/superpowers/specs/2026-06-21-d2b-inventory-wire-design.md @@ -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 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 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). +Oracle: ACE `GameEventViewContents.cs` + named-retail `ClientUISystem::OnViewContents` +(`?OnViewContents@ClientUISystem@@…PackableList`) + 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. From ca94d1d2f6b332fd477af4ad7583a2887c367b56 Mon Sep 17 00:00:00 2001 From: Erik Date: Sun, 21 Jun 2026 18:41:43 +0200 Subject: [PATCH 052/133] docs(D.2b-B): B-Wire implementation plan 16 bite-sized TDD tasks for the inventory wire pass: ClientObjectTable UpsertProperties/UpdateStackSize (Core); PrivateUpdatePropertyInt 0x02CD, SetStackSize 0x0197, InventoryRemoveObject 0x0024 parsers; ParseViewContents 0x0196; 0x0022 4th-field + 0x00A0 error fixes; DropItem/GetAndWieldItem/ NoLongerViewingContents builders + Send wrappers; PD player-property delivery + GameEvent registration; WorldSession dispatch; ObjectTableWiring apply-all- ints + player-int route + stack/remove; GameWindow + InventoryController wiring; divergence/ISSUES/roadmap/memory bookkeeping. Each pure parser/builder /table-method is TDD'd; glue follows the codebase convention (dispatcher- routed GameEvents unit-tested via the GameEventWiringTests harness, top-level GameMessages build+run-verified). Plan derived from reading every call site. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../plans/2026-06-21-d2b-inventory-wire.md | 1432 +++++++++++++++++ 1 file changed, 1432 insertions(+) create mode 100644 docs/superpowers/plans/2026-06-21-d2b-inventory-wire.md diff --git a/docs/superpowers/plans/2026-06-21-d2b-inventory-wire.md b/docs/superpowers/plans/2026-06-21-d2b-inventory-wire.md new file mode 100644 index 00000000..c8d4aeb1 --- /dev/null +++ b/docs/superpowers/plans/2026-06-21-d2b-inventory-wire.md @@ -0,0 +1,1432 @@ +# D.2b-B B-Wire — inventory wire layer 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:** Deliver the player's server-authoritative properties (so the burden bar reads the wire `EncumbranceVal`), fix two latent inbound-parse bugs, and add the missing inventory builders/parsers — completing the inventory wire layer so B-Drag and container-open are unblocked. + +**Architecture:** All wire code is in `AcDream.Core.Net` (pure data, no GL). New pure parsers/builders/table-methods are TDD'd directly. The glue (WorldSession's GameMessage switch, ObjectTableWiring subscriptions, the GameEventWiring PD handler, GameWindow call sites) follows the codebase's existing convention: dispatcher-routed GameEvents are unit-tested through the `GameEventWiringTests` harness; top-level GameMessages (no test seam on `WorldSession`) are wired and verified by build + the parser tests + the live run. + +**Tech Stack:** C# / .NET 10, xUnit, `System.Buffers.Binary.BinaryPrimitives`. Spec: `docs/superpowers/specs/2026-06-21-d2b-inventory-wire-design.md`. Oracle field orders: `docs/research/2026-06-16-inventory-deep-dive.md` §4 (ACE `file:line` + holtburger fixtures cited per format). + +**Mandatory per-format workflow:** before porting each wire format, grep `docs/research/named-retail/acclient_2013_pseudo_c.txt` for the client symbol + confirm the field order against the cited ACE writer. The golden bytes in each test below are derived from those confirmed orders; if grep-named reveals a discrepancy, fix the one test + impl and note it. + +**Opcodes (pinned against `references/ACE/.../GameMessageOpcode.cs` + `GameActionType.cs`):** +- `PrivateUpdatePropertyInt = 0x02CD` (top-level GameMessage, **no guid**) +- `SetStackSize = 0x0197` (top-level GameMessage, UIQueue) +- `InventoryRemoveObject = 0x0024` (top-level GameMessage, UIQueue) +- `ViewContents = 0x0196` (GameEvent, inside `0xF7B0`) +- C→S GameActions: `GetAndWieldItem = 0x001A`, `DropItem = 0x001B`, `NoLongerViewingContents = 0x0195` + +> **Namespace caution:** `SetStackSize 0x0197` / `InventoryRemoveObject 0x0024` are **GameMessage** opcodes (WorldSession top-level switch, like `0x02CE`). `ViewContents 0x0196` is a **GameEvent** eventType (dispatcher path). Adjacent numbers, different dispatchers — don't cross them. + +--- + +## Task 1: `ClientObjectTable.UpsertProperties` (Core) + +The PD handler needs to apply the player's property bundle even if the player's `ClientObject` doesn't exist yet (PD may arrive before the player's `CreateObject`). `UpdateProperties` no-ops on an unknown object; add a create-if-absent variant. + +**Files:** +- Modify: `src/AcDream.Core/Items/ClientObjectTable.cs` (add method after `UpdateProperties`, ~line 164) +- Test: `tests/AcDream.Core.Tests/Items/ClientObjectTableUpdateTests.cs` (create) + +- [ ] **Step 1: Write the failing test** + +Create `tests/AcDream.Core.Tests/Items/ClientObjectTableUpdateTests.cs`: + +```csharp +using AcDream.Core.Items; +using Xunit; + +namespace AcDream.Core.Tests.Items; + +public sealed class ClientObjectTableUpdateTests +{ + [Fact] + public void UpsertProperties_unknownObject_createsThenMerges() + { + var t = new ClientObjectTable(); + bool added = false; + t.ObjectAdded += _ => added = true; + + var bundle = new PropertyBundle(); + bundle.Ints[5] = 1234; // EncumbranceVal + + t.UpsertProperties(0x50000001u, bundle); + + var o = t.Get(0x50000001u); + Assert.NotNull(o); + Assert.Equal(1234, o!.Properties.Ints[5]); + Assert.True(added); + } + + [Fact] + public void UpsertProperties_existingObject_mergesAndFiresUpdated() + { + var t = new ClientObjectTable(); + t.AddOrUpdate(new ClientObject { ObjectId = 0x50000001u }); + bool updated = false; + t.ObjectUpdated += _ => updated = true; + + var bundle = new PropertyBundle(); + bundle.Ints[5] = 99; + t.UpsertProperties(0x50000001u, bundle); + + Assert.Equal(99, t.Get(0x50000001u)!.Properties.Ints[5]); + Assert.True(updated); + } +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `dotnet test tests/AcDream.Core.Tests/AcDream.Core.Tests.csproj --filter "FullyQualifiedName~ClientObjectTableUpdateTests"` +Expected: FAIL — compile error, `ClientObjectTable` has no `UpsertProperties`. + +- [ ] **Step 3: Write minimal implementation** + +In `src/AcDream.Core/Items/ClientObjectTable.cs`, immediately after the `UpdateProperties` method (the one ending at ~line 164), add: + +```csharp + /// + /// Apply a patch, creating the object if it does not + /// exist yet. Used for the local player's own properties from PlayerDescription + /// (0x0013), which may arrive BEFORE the player's CreateObject — unlike + /// , which no-ops on an unknown object. Fires + /// ObjectAdded on create, else ObjectUpdated. + /// + public void UpsertProperties(uint guid, PropertyBundle incoming) + { + ArgumentNullException.ThrowIfNull(incoming); + bool existed = _objects.TryGetValue(guid, out var item); + if (!existed || item is null) + { + item = new ClientObject { ObjectId = guid }; + _objects[guid] = item; + } + foreach (var kv in incoming.Ints) item.Properties.Ints[kv.Key] = kv.Value; + foreach (var kv in incoming.Int64s) item.Properties.Int64s[kv.Key] = kv.Value; + foreach (var kv in incoming.Bools) item.Properties.Bools[kv.Key] = kv.Value; + foreach (var kv in incoming.Floats) item.Properties.Floats[kv.Key] = kv.Value; + foreach (var kv in incoming.Strings) item.Properties.Strings[kv.Key] = kv.Value; + foreach (var kv in incoming.DataIds) item.Properties.DataIds[kv.Key] = kv.Value; + foreach (var kv in incoming.InstanceIds) item.Properties.InstanceIds[kv.Key] = kv.Value; + if (!existed) ObjectAdded?.Invoke(item); else ObjectUpdated?.Invoke(item); + } +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `dotnet test tests/AcDream.Core.Tests/AcDream.Core.Tests.csproj --filter "FullyQualifiedName~ClientObjectTableUpdateTests"` +Expected: PASS (2 tests). + +- [ ] **Step 5: Commit** + +```bash +git add src/AcDream.Core/Items/ClientObjectTable.cs tests/AcDream.Core.Tests/Items/ClientObjectTableUpdateTests.cs +git commit -m "feat(core): D.2b-B B-Wire — ClientObjectTable.UpsertProperties (create-if-absent)" +``` + +--- + +## Task 2: `ClientObjectTable.UpdateStackSize` (Core) + +`SetStackSize (0x0197)` updates a stack's count + value. Add the typed apply method (the parser + glue come later). + +**Files:** +- Modify: `src/AcDream.Core/Items/ClientObjectTable.cs` (add after `UpdateIntProperty`, ~line 180) +- Test: `tests/AcDream.Core.Tests/Items/ClientObjectTableUpdateTests.cs` (append) + +- [ ] **Step 1: Write the failing test** + +Append to `ClientObjectTableUpdateTests.cs` (inside the class): + +```csharp + [Fact] + public void UpdateStackSize_knownObject_setsFieldsAndFiresUpdated() + { + var t = new ClientObjectTable(); + t.AddOrUpdate(new ClientObject { ObjectId = 0x600u, StackSize = 1, Value = 5 }); + bool updated = false; + t.ObjectUpdated += _ => updated = true; + + bool ok = t.UpdateStackSize(0x600u, stackSize: 25, value: 125); + + Assert.True(ok); + Assert.Equal(25, t.Get(0x600u)!.StackSize); + Assert.Equal(125, t.Get(0x600u)!.Value); + Assert.True(updated); + } + + [Fact] + public void UpdateStackSize_unknownObject_returnsFalse() + => Assert.False(new ClientObjectTable().UpdateStackSize(0xDEADu, 1, 1)); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `dotnet test tests/AcDream.Core.Tests/AcDream.Core.Tests.csproj --filter "FullyQualifiedName~ClientObjectTableUpdateTests"` +Expected: FAIL — no `UpdateStackSize`. + +- [ ] **Step 3: Write minimal implementation** + +In `src/AcDream.Core/Items/ClientObjectTable.cs`, after `UpdateIntProperty` (~line 180), add: + +```csharp + /// + /// Apply a SetStackSize (0x0197) update: set the object's StackSize + Value and + /// fire ObjectUpdated so bound widgets refresh the quantity overlay. False if the + /// object is unknown. Retail: ACCWeenieObject::ServerSaysSetStackSize (0x0058...). + /// + public bool UpdateStackSize(uint guid, int stackSize, int value) + { + if (!_objects.TryGetValue(guid, out var item)) return false; + item.StackSize = stackSize; + item.Value = value; + ObjectUpdated?.Invoke(item); + return true; + } +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `dotnet test tests/AcDream.Core.Tests/AcDream.Core.Tests.csproj --filter "FullyQualifiedName~ClientObjectTableUpdateTests"` +Expected: PASS (4 tests). + +- [ ] **Step 5: Commit** + +```bash +git add src/AcDream.Core/Items/ClientObjectTable.cs tests/AcDream.Core.Tests/Items/ClientObjectTableUpdateTests.cs +git commit -m "feat(core): D.2b-B B-Wire — ClientObjectTable.UpdateStackSize" +``` + +--- + +## Task 3: `PrivateUpdatePropertyInt (0x02CD)` parser (Core.Net) + +Live burden updates ride `0x02CD` — the player's own object, **no guid**. Mirror `PublicUpdatePropertyInt.cs`. + +**Files:** +- Create: `src/AcDream.Core.Net/Messages/PrivateUpdatePropertyInt.cs` +- Test: `tests/AcDream.Core.Net.Tests/Messages/PrivateUpdatePropertyIntTests.cs` + +- [ ] **Step 1: Write the failing test** + +Create `tests/AcDream.Core.Net.Tests/Messages/PrivateUpdatePropertyIntTests.cs`: + +```csharp +using System.Buffers.Binary; +using AcDream.Core.Net.Messages; +using Xunit; + +namespace AcDream.Core.Net.Tests.Messages; + +public sealed class PrivateUpdatePropertyIntTests +{ + // 0x02CD body: opcode(4) + seq(1) + property(4) + value(4) = 13 bytes. NO guid. + private static byte[] Build(uint property, int value, byte seq = 1, uint opcode = 0x02CDu) + { + var b = new byte[13]; + BinaryPrimitives.WriteUInt32LittleEndian(b.AsSpan(0), opcode); + b[4] = seq; + BinaryPrimitives.WriteUInt32LittleEndian(b.AsSpan(5), property); + BinaryPrimitives.WriteInt32LittleEndian(b.AsSpan(9), value); + return b; + } + + [Fact] + public void TryParse_encumbranceVal_returnsPropValue() + { + var p = PrivateUpdatePropertyInt.TryParse(Build(property: 5u, value: 1500)); + Assert.NotNull(p); + Assert.Equal(5u, p!.Value.Property); + Assert.Equal(1500, p.Value.Value); + } + + [Fact] + public void TryParse_wrongOpcode_returnsNull() + => Assert.Null(PrivateUpdatePropertyInt.TryParse(Build(5, 1, opcode: 0x02CEu))); + + [Fact] + public void TryParse_truncated_returnsNull() + => Assert.Null(PrivateUpdatePropertyInt.TryParse(new byte[12])); +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `dotnet test tests/AcDream.Core.Net.Tests/AcDream.Core.Net.Tests.csproj --filter "FullyQualifiedName~PrivateUpdatePropertyIntTests"` +Expected: FAIL — `PrivateUpdatePropertyInt` does not exist. + +- [ ] **Step 3: Write minimal implementation** + +Create `src/AcDream.Core.Net/Messages/PrivateUpdatePropertyInt.cs`: + +```csharp +using System; +using System.Buffers.Binary; + +namespace AcDream.Core.Net.Messages; + +/// +/// Inbound PrivateUpdatePropertyInt (0x02CD) — the server updates one +/// PropertyInt on the player's OWN object. Unlike the sibling +/// (0x02CE), this carries NO guid (it +/// implicitly targets the local player). Burden (EncumbranceVal, PropertyInt 5) +/// changes ride this opcode on every pick-up / drop. +/// +/// Wire layout (ACE GameMessagePrivateUpdatePropertyInt): +/// +/// u32 opcode = 0x02CD +/// u8 sequence // single byte (ByteSequence) — not honored, latest-wins (DR-4) +/// u32 property // PropertyInt enum +/// i32 value +/// +/// +public static class PrivateUpdatePropertyInt +{ + public const uint Opcode = 0x02CDu; + + public readonly record struct Parsed(uint Property, int Value); + + /// Parse a raw 0x02CD body. Returns null on opcode mismatch / truncation. + public static Parsed? TryParse(ReadOnlySpan body) + { + if (body.Length < 13) return null; // 4 + 1 + 4 + 4 + if (BinaryPrimitives.ReadUInt32LittleEndian(body) != Opcode) return null; + int pos = 4; + pos += 1; // sequence byte (not honored) + uint prop = BinaryPrimitives.ReadUInt32LittleEndian(body[pos..]); pos += 4; + int value = BinaryPrimitives.ReadInt32LittleEndian(body[pos..]); + return new Parsed(prop, value); + } +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `dotnet test tests/AcDream.Core.Net.Tests/AcDream.Core.Net.Tests.csproj --filter "FullyQualifiedName~PrivateUpdatePropertyIntTests"` +Expected: PASS (3 tests). + +- [ ] **Step 5: Commit** + +```bash +git add src/AcDream.Core.Net/Messages/PrivateUpdatePropertyInt.cs tests/AcDream.Core.Net.Tests/Messages/PrivateUpdatePropertyIntTests.cs +git commit -m "feat(net): D.2b-B B-Wire — PrivateUpdatePropertyInt (0x02CD) parser" +``` + +--- + +## Task 4: `SetStackSize (0x0197)` parser (Core.Net) + +Top-level GameMessage. Wire layout (ACE `GameMessageSetStackSize.cs`, size hint 17 ⇒ byte seq): `opcode(4) + u8 seq + guid(4) + stackSize(4) + value(4)`. + +**Files:** +- Create: `src/AcDream.Core.Net/Messages/SetStackSize.cs` +- Test: `tests/AcDream.Core.Net.Tests/Messages/SetStackSizeTests.cs` + +- [ ] **Step 1: Write the failing test** + +Create `tests/AcDream.Core.Net.Tests/Messages/SetStackSizeTests.cs`: + +```csharp +using System.Buffers.Binary; +using AcDream.Core.Net.Messages; +using Xunit; + +namespace AcDream.Core.Net.Tests.Messages; + +public sealed class SetStackSizeTests +{ + private static byte[] Build(uint guid, int stackSize, int value, byte seq = 1, uint opcode = 0x0197u) + { + var b = new byte[17]; + BinaryPrimitives.WriteUInt32LittleEndian(b.AsSpan(0), opcode); + b[4] = seq; + BinaryPrimitives.WriteUInt32LittleEndian(b.AsSpan(5), guid); + BinaryPrimitives.WriteInt32LittleEndian(b.AsSpan(9), stackSize); + BinaryPrimitives.WriteInt32LittleEndian(b.AsSpan(13), value); + return b; + } + + [Fact] + public void TryParse_validStack_returnsGuidStackValue() + { + var p = SetStackSize.TryParse(Build(0x50000A01u, stackSize: 25, value: 125)); + Assert.NotNull(p); + Assert.Equal(0x50000A01u, p!.Value.Guid); + Assert.Equal(25, p.Value.StackSize); + Assert.Equal(125, p.Value.Value); + } + + [Fact] + public void TryParse_wrongOpcode_returnsNull() + => Assert.Null(SetStackSize.TryParse(Build(1, 1, 1, opcode: 0x0196u))); + + [Fact] + public void TryParse_truncated_returnsNull() + => Assert.Null(SetStackSize.TryParse(new byte[16])); +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `dotnet test tests/AcDream.Core.Net.Tests/AcDream.Core.Net.Tests.csproj --filter "FullyQualifiedName~SetStackSizeTests"` +Expected: FAIL — `SetStackSize` does not exist. + +- [ ] **Step 3: Write minimal implementation** + +Create `src/AcDream.Core.Net/Messages/SetStackSize.cs`: + +```csharp +using System; +using System.Buffers.Binary; + +namespace AcDream.Core.Net.Messages; + +/// +/// Inbound SetStackSize (0x0197) — a top-level GameMessage (UIQueue group), +/// NOT a GameEvent. The server updates a stack's count + value after a merge / split. +/// Client consumer: ACCWeenieObject::ServerSaysSetStackSize. +/// +/// Wire layout (ACE GameMessageSetStackSize.cs, size hint 17): +/// +/// u32 opcode = 0x0197 +/// u8 sequence // ByteSequence (UpdatePropertyInt) — not honored +/// u32 guid +/// i32 stackSize +/// i32 value +/// +/// +public static class SetStackSize +{ + public const uint Opcode = 0x0197u; + + public readonly record struct Parsed(uint Guid, int StackSize, int Value); + + /// Parse a raw 0x0197 body. Returns null on opcode mismatch / truncation. + public static Parsed? TryParse(ReadOnlySpan body) + { + if (body.Length < 17) return null; // 4 + 1 + 4 + 4 + 4 + if (BinaryPrimitives.ReadUInt32LittleEndian(body) != Opcode) return null; + int pos = 4; + pos += 1; // sequence byte (not honored) + uint guid = BinaryPrimitives.ReadUInt32LittleEndian(body[pos..]); pos += 4; + int stack = BinaryPrimitives.ReadInt32LittleEndian(body[pos..]); pos += 4; + int value = BinaryPrimitives.ReadInt32LittleEndian(body[pos..]); + return new Parsed(guid, stack, value); + } +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `dotnet test tests/AcDream.Core.Net.Tests/AcDream.Core.Net.Tests.csproj --filter "FullyQualifiedName~SetStackSizeTests"` +Expected: PASS (3 tests). + +- [ ] **Step 5: Commit** + +```bash +git add src/AcDream.Core.Net/Messages/SetStackSize.cs tests/AcDream.Core.Net.Tests/Messages/SetStackSizeTests.cs +git commit -m "feat(net): D.2b-B B-Wire — SetStackSize (0x0197) parser" +``` + +--- + +## Task 5: `InventoryRemoveObject (0x0024)` parser (Core.Net) + +Top-level GameMessage. Wire layout (ACE `GameMessageInventoryRemoveObject.cs`, size hint 8): `opcode(4) + guid(4)`. + +**Files:** +- Create: `src/AcDream.Core.Net/Messages/InventoryRemoveObject.cs` +- Test: `tests/AcDream.Core.Net.Tests/Messages/InventoryRemoveObjectTests.cs` + +- [ ] **Step 1: Write the failing test** + +Create `tests/AcDream.Core.Net.Tests/Messages/InventoryRemoveObjectTests.cs`: + +```csharp +using System.Buffers.Binary; +using AcDream.Core.Net.Messages; +using Xunit; + +namespace AcDream.Core.Net.Tests.Messages; + +public sealed class InventoryRemoveObjectTests +{ + private static byte[] Build(uint guid, uint opcode = 0x0024u) + { + var b = new byte[8]; + BinaryPrimitives.WriteUInt32LittleEndian(b.AsSpan(0), opcode); + BinaryPrimitives.WriteUInt32LittleEndian(b.AsSpan(4), guid); + return b; + } + + [Fact] + public void TryParse_valid_returnsGuid() + { + var p = InventoryRemoveObject.TryParse(Build(0x50000A07u)); + Assert.NotNull(p); + Assert.Equal(0x50000A07u, p!.Value.Guid); + } + + [Fact] + public void TryParse_wrongOpcode_returnsNull() + => Assert.Null(InventoryRemoveObject.TryParse(Build(1, opcode: 0x0023u))); + + [Fact] + public void TryParse_truncated_returnsNull() + => Assert.Null(InventoryRemoveObject.TryParse(new byte[7])); +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `dotnet test tests/AcDream.Core.Net.Tests/AcDream.Core.Net.Tests.csproj --filter "FullyQualifiedName~InventoryRemoveObjectTests"` +Expected: FAIL — `InventoryRemoveObject` does not exist. + +- [ ] **Step 3: Write minimal implementation** + +Create `src/AcDream.Core.Net/Messages/InventoryRemoveObject.cs`: + +```csharp +using System; +using System.Buffers.Binary; + +namespace AcDream.Core.Net.Messages; + +/// +/// Inbound InventoryRemoveObject (0x0024) — a top-level GameMessage (UIQueue), +/// NOT a GameEvent. The server tells the client an object left its inventory view +/// (given away / sold / destroyed). The client drops it from object maintenance. +/// +/// Wire layout (ACE GameMessageInventoryRemoveObject.cs, size hint 8): +/// +/// u32 opcode = 0x0024 +/// u32 guid +/// +/// +public static class InventoryRemoveObject +{ + public const uint Opcode = 0x0024u; + + public readonly record struct Parsed(uint Guid); + + /// Parse a raw 0x0024 body. Returns null on opcode mismatch / truncation. + public static Parsed? TryParse(ReadOnlySpan body) + { + if (body.Length < 8) return null; // 4 + 4 + if (BinaryPrimitives.ReadUInt32LittleEndian(body) != Opcode) return null; + uint guid = BinaryPrimitives.ReadUInt32LittleEndian(body[4..]); + return new Parsed(guid); + } +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `dotnet test tests/AcDream.Core.Net.Tests/AcDream.Core.Net.Tests.csproj --filter "FullyQualifiedName~InventoryRemoveObjectTests"` +Expected: PASS (3 tests). + +- [ ] **Step 5: Commit** + +```bash +git add src/AcDream.Core.Net/Messages/InventoryRemoveObject.cs tests/AcDream.Core.Net.Tests/Messages/InventoryRemoveObjectTests.cs +git commit -m "feat(net): D.2b-B B-Wire — InventoryRemoveObject (0x0024) parser" +``` + +--- + +## Task 6: `GameEvents.ParseViewContents (0x0196)` (Core.Net) + +GameEvent (payload = envelope stripped). Layout (ACE `GameEventViewContents.cs`): `containerGuid(4), count(4), [guid(4), containerType(4)] × count`. + +**Files:** +- Modify: `src/AcDream.Core.Net/Messages/GameEvents.cs` (add near `ParsePutObjInContainer`, ~line 359) +- Test: `tests/AcDream.Core.Net.Tests/Messages/GameEventsInventoryTests.cs` (create) + +- [ ] **Step 1: Write the failing test** + +Create `tests/AcDream.Core.Net.Tests/Messages/GameEventsInventoryTests.cs`: + +```csharp +using System.Buffers.Binary; +using AcDream.Core.Net.Messages; +using Xunit; + +namespace AcDream.Core.Net.Tests.Messages; + +public sealed class GameEventsInventoryTests +{ + [Fact] + public void ParseViewContents_twoEntries_returnsContainerAndItems() + { + // containerGuid + count(2) + 2×{guid, containerType} + var b = new byte[4 + 4 + 2 * 8]; + BinaryPrimitives.WriteUInt32LittleEndian(b.AsSpan(0), 0x500000C9u); // containerGuid + BinaryPrimitives.WriteUInt32LittleEndian(b.AsSpan(4), 2u); // count + BinaryPrimitives.WriteUInt32LittleEndian(b.AsSpan(8), 0x50000A01u); // guid 1 + BinaryPrimitives.WriteUInt32LittleEndian(b.AsSpan(12), 0u); // type 1 (item) + BinaryPrimitives.WriteUInt32LittleEndian(b.AsSpan(16), 0x50000A02u);// guid 2 + BinaryPrimitives.WriteUInt32LittleEndian(b.AsSpan(20), 1u); // type 2 (container) + + var p = GameEvents.ParseViewContents(b); + Assert.NotNull(p); + Assert.Equal(0x500000C9u, p!.Value.ContainerGuid); + Assert.Equal(2, p.Value.Items.Count); + Assert.Equal(0x50000A01u, p.Value.Items[0].Guid); + Assert.Equal(0u, p.Value.Items[0].ContainerType); + Assert.Equal(0x50000A02u, p.Value.Items[1].Guid); + Assert.Equal(1u, p.Value.Items[1].ContainerType); + } + + [Fact] + public void ParseViewContents_zeroCount_returnsEmptyList() + { + var b = new byte[8]; + BinaryPrimitives.WriteUInt32LittleEndian(b.AsSpan(0), 0x500000C9u); + BinaryPrimitives.WriteUInt32LittleEndian(b.AsSpan(4), 0u); + var p = GameEvents.ParseViewContents(b); + Assert.NotNull(p); + Assert.Empty(p!.Value.Items); + } + + [Fact] + public void ParseViewContents_truncated_returnsNull() + => Assert.Null(GameEvents.ParseViewContents(new byte[4])); +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `dotnet test tests/AcDream.Core.Net.Tests/AcDream.Core.Net.Tests.csproj --filter "FullyQualifiedName~GameEventsInventoryTests.ParseViewContents"` +Expected: FAIL — no `ParseViewContents` / no `ViewContents` record. + +- [ ] **Step 3: Write minimal implementation** + +In `src/AcDream.Core.Net/Messages/GameEvents.cs`, after `ParsePutObjInContainer` (~line 359), add: + +```csharp + /// 0x0196 ViewContents: full contents list of a container you opened. + /// Layout (ACE GameEventViewContents.cs): containerGuid, count, [guid, containerType]×count. + /// Client consumer: ClientUISystem::OnViewContents (PackableList<ContentProfile>). + public readonly record struct ViewContentsEntry(uint Guid, uint ContainerType); + public readonly record struct ViewContents(uint ContainerGuid, System.Collections.Generic.IReadOnlyList Items); + + public static ViewContents? ParseViewContents(ReadOnlySpan payload) + { + if (payload.Length < 8) return null; + uint containerGuid = BinaryPrimitives.ReadUInt32LittleEndian(payload); + uint count = BinaryPrimitives.ReadUInt32LittleEndian(payload.Slice(4)); + int pos = 8; + if ((long)payload.Length - pos < (long)count * 8) return null; + var items = new ViewContentsEntry[count]; + for (int i = 0; i < count; i++) + { + uint guid = BinaryPrimitives.ReadUInt32LittleEndian(payload.Slice(pos)); pos += 4; + uint type = BinaryPrimitives.ReadUInt32LittleEndian(payload.Slice(pos)); pos += 4; + items[i] = new ViewContentsEntry(guid, type); + } + return new ViewContents(containerGuid, items); + } +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `dotnet test tests/AcDream.Core.Net.Tests/AcDream.Core.Net.Tests.csproj --filter "FullyQualifiedName~GameEventsInventoryTests.ParseViewContents"` +Expected: PASS (3 tests). + +- [ ] **Step 5: Commit** + +```bash +git add src/AcDream.Core.Net/Messages/GameEvents.cs tests/AcDream.Core.Net.Tests/Messages/GameEventsInventoryTests.cs +git commit -m "feat(net): D.2b-B B-Wire — ParseViewContents (0x0196)" +``` + +--- + +## Task 7: Fix `ParsePutObjInContainer` 4th field (Core.Net) + +`0x0022` carries a 4th `containerType` u32 that the current parser drops. + +**Files:** +- Modify: `src/AcDream.Core.Net/Messages/GameEvents.cs` (the `InventoryPutObjInContainer` record + `ParsePutObjInContainer`, ~lines 346-359) +- Test: `tests/AcDream.Core.Net.Tests/Messages/GameEventsInventoryTests.cs` (append) + +- [ ] **Step 1: Write the failing test** + +Append to `GameEventsInventoryTests.cs` (inside the class): + +```csharp + [Fact] + public void ParsePutObjInContainer_readsAllFourFields() + { + var b = new byte[16]; + BinaryPrimitives.WriteUInt32LittleEndian(b.AsSpan(0), 0x50000A01u); // itemGuid + BinaryPrimitives.WriteUInt32LittleEndian(b.AsSpan(4), 0x500000C9u); // containerGuid + BinaryPrimitives.WriteUInt32LittleEndian(b.AsSpan(8), 3u); // placement + BinaryPrimitives.WriteUInt32LittleEndian(b.AsSpan(12), 1u); // containerType + + var p = GameEvents.ParsePutObjInContainer(b); + Assert.NotNull(p); + Assert.Equal(0x50000A01u, p!.Value.ItemGuid); + Assert.Equal(0x500000C9u, p.Value.ContainerGuid); + Assert.Equal(3u, p.Value.Placement); + Assert.Equal(1u, p.Value.ContainerType); + } +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `dotnet test tests/AcDream.Core.Net.Tests/AcDream.Core.Net.Tests.csproj --filter "FullyQualifiedName~GameEventsInventoryTests.ParsePutObjInContainer"` +Expected: FAIL — `InventoryPutObjInContainer` record has no `ContainerType`. + +- [ ] **Step 3: Write minimal implementation** + +In `src/AcDream.Core.Net/Messages/GameEvents.cs`, replace the `InventoryPutObjInContainer` record + parser (~lines 346-359) with: + +```csharp + /// 0x0022 InventoryPutObjInContainer: server puts item into container slot. + /// 4 fields (ACE GameEventItemServerSaysContainId.cs): itemGuid, containerGuid, + /// placement, containerType. ContainerType (0=item,1=container,2=foci) confirmed + /// vs holtburger events.rs fixture (slot=3 type=1). + public readonly record struct InventoryPutObjInContainer( + uint ItemGuid, + uint ContainerGuid, + uint Placement, + uint ContainerType); + + public static InventoryPutObjInContainer? ParsePutObjInContainer(ReadOnlySpan payload) + { + if (payload.Length < 16) return null; + return new InventoryPutObjInContainer( + BinaryPrimitives.ReadUInt32LittleEndian(payload), + BinaryPrimitives.ReadUInt32LittleEndian(payload.Slice(4)), + BinaryPrimitives.ReadUInt32LittleEndian(payload.Slice(8)), + BinaryPrimitives.ReadUInt32LittleEndian(payload.Slice(12))); + } +``` + +(The `GameEventWiring` caller at ~line 246 uses `.ItemGuid`/`.ContainerGuid`/`.Placement` — unaffected by the added field.) + +- [ ] **Step 4: Run test to verify it passes** + +Run: `dotnet test tests/AcDream.Core.Net.Tests/AcDream.Core.Net.Tests.csproj --filter "FullyQualifiedName~GameEventsInventoryTests.ParsePutObjInContainer"` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add src/AcDream.Core.Net/Messages/GameEvents.cs tests/AcDream.Core.Net.Tests/Messages/GameEventsInventoryTests.cs +git commit -m "fix(net): D.2b-B B-Wire — ParsePutObjInContainer reads containerType (4th field)" +``` + +--- + +## Task 8: Fix `ParseInventoryServerSaveFailed` to read the error (Core.Net) + +`0x00A0` carries `itemGuid, weenieError`; the current parser returns only the guid. Change to a record. (Parser is currently unwired — no caller breaks.) + +**Files:** +- Modify: `src/AcDream.Core.Net/Messages/GameEvents.cs` (`ParseInventoryServerSaveFailed`, ~line 377) +- Test: `tests/AcDream.Core.Net.Tests/Messages/GameEventsInventoryTests.cs` (append) + +- [ ] **Step 1: Write the failing test** + +Append to `GameEventsInventoryTests.cs`: + +```csharp + [Fact] + public void ParseInventoryServerSaveFailed_readsGuidAndError() + { + var b = new byte[8]; + BinaryPrimitives.WriteUInt32LittleEndian(b.AsSpan(0), 0x50000A01u); // itemGuid + BinaryPrimitives.WriteUInt32LittleEndian(b.AsSpan(4), 0x0Au); // weenieError + + var p = GameEvents.ParseInventoryServerSaveFailed(b); + Assert.NotNull(p); + Assert.Equal(0x50000A01u, p!.Value.ItemGuid); + Assert.Equal(0x0Au, p.Value.WeenieError); + } +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `dotnet test tests/AcDream.Core.Net.Tests/AcDream.Core.Net.Tests.csproj --filter "FullyQualifiedName~GameEventsInventoryTests.ParseInventoryServerSaveFailed"` +Expected: FAIL — current return type is `uint?`, has no `.ItemGuid`/`.WeenieError`. + +- [ ] **Step 3: Write minimal implementation** + +In `src/AcDream.Core.Net/Messages/GameEvents.cs`, replace `ParseInventoryServerSaveFailed` (~line 377) with: + +```csharp + /// 0x00A0 InventoryServerSaveFailed: revert a speculative local inventory op. + /// (itemGuid, weenieError) — ACE GameEventInventoryServerSaveFailed.cs; holtburger + /// events.rs:147 reads both fields. + public readonly record struct InventoryServerSaveFailed(uint ItemGuid, uint WeenieError); + + public static InventoryServerSaveFailed? ParseInventoryServerSaveFailed(ReadOnlySpan payload) + { + if (payload.Length < 8) return null; + return new InventoryServerSaveFailed( + BinaryPrimitives.ReadUInt32LittleEndian(payload), + BinaryPrimitives.ReadUInt32LittleEndian(payload.Slice(4))); + } +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `dotnet test tests/AcDream.Core.Net.Tests/AcDream.Core.Net.Tests.csproj --filter "FullyQualifiedName~GameEventsInventoryTests.ParseInventoryServerSaveFailed"` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add src/AcDream.Core.Net/Messages/GameEvents.cs tests/AcDream.Core.Net.Tests/Messages/GameEventsInventoryTests.cs +git commit -m "fix(net): D.2b-B B-Wire — ParseInventoryServerSaveFailed reads weenieError" +``` + +--- + +## Task 9: New C→S builders + `WorldSession.Send*` wrappers (Core.Net) + +`DropItem 0x001B` (`itemGuid`), `GetAndWieldItem 0x001A` (`itemGuid, equipMask`), `NoLongerViewingContents 0x0195` (`containerGuid`). All ride the `0xF7B1` GameAction envelope. + +**Files:** +- Modify: `src/AcDream.Core.Net/Messages/InventoryActions.cs` (add opcodes + 3 builders) +- Modify: `src/AcDream.Core.Net/WorldSession.cs` (add 3 `Send*` wrappers after `SendRemoveShortcut`, ~line 1157) +- Test: `tests/AcDream.Core.Net.Tests/Messages/InventoryActionsTests.cs` (append) + +- [ ] **Step 1: Write the failing test** + +Append to `tests/AcDream.Core.Net.Tests/Messages/InventoryActionsTests.cs` (inside the existing class): + +```csharp + [Fact] + public void BuildDropItem_layout() + { + byte[] b = InventoryActions.BuildDropItem(seq: 7, itemGuid: 0x50000A01u); + Assert.Equal(16, b.Length); + Assert.Equal(0xF7B1u, BinaryPrimitives.ReadUInt32LittleEndian(b.AsSpan(0))); + Assert.Equal(7u, BinaryPrimitives.ReadUInt32LittleEndian(b.AsSpan(4))); + Assert.Equal(0x001Bu, BinaryPrimitives.ReadUInt32LittleEndian(b.AsSpan(8))); + Assert.Equal(0x50000A01u, BinaryPrimitives.ReadUInt32LittleEndian(b.AsSpan(12))); + } + + [Fact] + public void BuildGetAndWieldItem_layout() + { + byte[] b = InventoryActions.BuildGetAndWieldItem(seq: 7, itemGuid: 0x50000A01u, equipMask: 0x02u); + Assert.Equal(20, b.Length); + Assert.Equal(0xF7B1u, BinaryPrimitives.ReadUInt32LittleEndian(b.AsSpan(0))); + Assert.Equal(7u, BinaryPrimitives.ReadUInt32LittleEndian(b.AsSpan(4))); + Assert.Equal(0x001Au, BinaryPrimitives.ReadUInt32LittleEndian(b.AsSpan(8))); + Assert.Equal(0x50000A01u, BinaryPrimitives.ReadUInt32LittleEndian(b.AsSpan(12))); + Assert.Equal(0x02u, BinaryPrimitives.ReadUInt32LittleEndian(b.AsSpan(16))); + } + + [Fact] + public void BuildNoLongerViewingContents_layout() + { + byte[] b = InventoryActions.BuildNoLongerViewingContents(seq: 7, containerGuid: 0x500000C9u); + Assert.Equal(16, b.Length); + Assert.Equal(0xF7B1u, BinaryPrimitives.ReadUInt32LittleEndian(b.AsSpan(0))); + Assert.Equal(7u, BinaryPrimitives.ReadUInt32LittleEndian(b.AsSpan(4))); + Assert.Equal(0x0195u, BinaryPrimitives.ReadUInt32LittleEndian(b.AsSpan(8))); + Assert.Equal(0x500000C9u, BinaryPrimitives.ReadUInt32LittleEndian(b.AsSpan(12))); + } +``` + +(If `InventoryActionsTests.cs` lacks `using System.Buffers.Binary;`, add it.) + +- [ ] **Step 2: Run test to verify it fails** + +Run: `dotnet test tests/AcDream.Core.Net.Tests/AcDream.Core.Net.Tests.csproj --filter "FullyQualifiedName~InventoryActionsTests.Build"` +Expected: FAIL — builders don't exist. + +- [ ] **Step 3: Write minimal implementation** + +In `src/AcDream.Core.Net/Messages/InventoryActions.cs`, add the opcode constants (after `TeleToPoiOpcode`, ~line 24): + +```csharp + public const uint GetAndWieldItemOpcode = 0x001Au; + public const uint DropItemOpcode = 0x001Bu; + public const uint NoLongerViewingContentsOpcode = 0x0195u; +``` + +And add the three builders (before the closing brace of the class): + +```csharp + /// Drop an item on the ground. ACE GameActionDropItem.Handle reads 1 u32. + public static byte[] BuildDropItem(uint seq, uint itemGuid) + { + byte[] body = new byte[16]; + BinaryPrimitives.WriteUInt32LittleEndian(body, GameActionEnvelope); + BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(4), seq); + BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(8), DropItemOpcode); + BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(12), itemGuid); + return body; + } + + /// Equip an item from inventory onto the doll. holtburger actions.rs + /// GetAndWieldItemActionData = (itemGuid, equipMask). + public static byte[] BuildGetAndWieldItem(uint seq, uint itemGuid, uint equipMask) + { + byte[] body = new byte[20]; + BinaryPrimitives.WriteUInt32LittleEndian(body, GameActionEnvelope); + BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(4), seq); + BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(8), GetAndWieldItemOpcode); + BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(12), itemGuid); + BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(16), equipMask); + return body; + } + + /// Close a side-pack / ground-container view. holtburger actions.rs = (containerGuid). + public static byte[] BuildNoLongerViewingContents(uint seq, uint containerGuid) + { + byte[] body = new byte[16]; + BinaryPrimitives.WriteUInt32LittleEndian(body, GameActionEnvelope); + BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(4), seq); + BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(8), NoLongerViewingContentsOpcode); + BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(12), containerGuid); + return body; + } +``` + +In `src/AcDream.Core.Net/WorldSession.cs`, after `SendRemoveShortcut` (~line 1157), add: + +```csharp + /// Send DropItem (0x001B) — drop an item on the ground. + public void SendDropItem(uint itemGuid) + { + uint seq = NextGameActionSequence(); + SendGameAction(InventoryActions.BuildDropItem(seq, itemGuid)); + } + + /// Send GetAndWieldItem (0x001A) — equip an item to an equip slot. + public void SendGetAndWieldItem(uint itemGuid, uint equipMask) + { + uint seq = NextGameActionSequence(); + SendGameAction(InventoryActions.BuildGetAndWieldItem(seq, itemGuid, equipMask)); + } + + /// Send NoLongerViewingContents (0x0195) — close a container view. + public void SendNoLongerViewingContents(uint containerGuid) + { + uint seq = NextGameActionSequence(); + SendGameAction(InventoryActions.BuildNoLongerViewingContents(seq, containerGuid)); + } +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `dotnet test tests/AcDream.Core.Net.Tests/AcDream.Core.Net.Tests.csproj --filter "FullyQualifiedName~InventoryActionsTests.Build"` +Expected: PASS (3 new tests). + +- [ ] **Step 5: Commit** + +```bash +git add 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(net): D.2b-B B-Wire — DropItem/GetAndWieldItem/NoLongerViewingContents builders + Send wrappers" +``` + +--- + +## Task 10: Login PD player-property delivery (Core.Net) + +`GameEventWiring.WireAll` gains an optional `Func? playerGuid`; the PD handler upserts the player's parsed `PropertyBundle` into the player `ClientObject`. TDD via the dispatcher harness. + +**Files:** +- Modify: `src/AcDream.Core.Net/GameEventWiring.cs` (add param ~line 68; add upsert in the PD handler ~line 408) +- Test: `tests/AcDream.Core.Net.Tests/GameEventWiringTests.cs` (append) + +- [ ] **Step 1: Write the failing test** + +Append to `GameEventWiringTests.cs` (inside the class): + +```csharp + [Fact] + public void WireAll_PlayerDescription_UpsertsPlayerPropertiesIntoClientObject() + { + // PD with a PropertyInt32 table carrying EncumbranceVal(5)=1500. The handler + // must land it in the player ClientObject so the burden bar reads the wire value. + const uint playerGuid = 0x50000001u; + var dispatcher = new GameEventDispatcher(); + var items = new ClientObjectTable(); + GameEventWiring.WireAll(dispatcher, items, new CombatState(), new Spellbook(), + new ChatLog(), playerGuid: () => playerGuid); + + var sb = new MemoryStream(); + using var w = new BinaryWriter(sb); + w.Write(0x00000001u); // propertyFlags = PropertyInt32 + w.Write(0x52u); // weenieType + // int table: u16 count, u16 buckets, then key/val pairs + w.Write((ushort)1); // count + w.Write((ushort)8); // buckets (ignored) + w.Write(5u); // key = EncumbranceVal + w.Write(1500u); // val + // vector + has_health (no vector blocks) + w.Write(0u); // vectorFlags = None + w.Write(0u); // has_health + // strict trailer + w.Write(0u); // option_flags = None + w.Write(0u); // options1 + w.Write(0u); // legacy hotbar count + w.Write(0u); // spellbook_filters + w.Write(0u); // inventory count + w.Write(0u); // equipped count + + var env = GameEventEnvelope.TryParse(WrapEnvelope(GameEventType.PlayerDescription, sb.ToArray())); + dispatcher.Dispatch(env!.Value); + + var player = items.Get(playerGuid); + Assert.NotNull(player); + Assert.Equal(1500, player!.Properties.Ints[5]); + } +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `dotnet test tests/AcDream.Core.Net.Tests/AcDream.Core.Net.Tests.csproj --filter "FullyQualifiedName~GameEventWiringTests.WireAll_PlayerDescription_UpsertsPlayerProperties"` +Expected: FAIL — `WireAll` has no `playerGuid` parameter. + +- [ ] **Step 3: Write minimal implementation** + +In `src/AcDream.Core.Net/GameEventWiring.cs`, add a new optional parameter at the END of the `WireAll` parameter list (after `onShortcuts`, ~line 68): + +```csharp + Action>? onShortcuts = null, + // B-Wire: the local player's server guid. When provided, the PD handler upserts + // the player's own PropertyBundle (EncumbranceVal etc.) into the player ClientObject. + Func? playerGuid = null) +``` + +In the PD handler, immediately after the `if (p is null) return;` guard (~line 290), add: + +```csharp + // B-Wire: deliver the player's OWN properties to the player ClientObject. + // (PD's "membership manifest" rule is about ITEMS, whose data comes from + // CreateObject; the player's own stats legitimately come from PD.) Upsert + // because PD can arrive before the player's CreateObject. Retires AP-48/AP-49. + if (playerGuid is not null) + items.UpsertProperties(playerGuid(), p.Value.Properties); +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `dotnet test tests/AcDream.Core.Net.Tests/AcDream.Core.Net.Tests.csproj --filter "FullyQualifiedName~GameEventWiringTests"` +Expected: PASS (all GameEventWiring tests, including the new one). + +- [ ] **Step 5: Commit** + +```bash +git add src/AcDream.Core.Net/GameEventWiring.cs tests/AcDream.Core.Net.Tests/GameEventWiringTests.cs +git commit -m "feat(net): D.2b-B B-Wire — PlayerDescription delivers player properties to ClientObject" +``` + +--- + +## Task 11: Register ViewContents (membership) + unwired inventory GameEvents (Core.Net) + +Register `ViewContents` (apply membership — testable), and the existing-but-unwired `InventoryPutObjectIn3D (0x019A)` (unparent to world), `InventoryServerSaveFailed (0x00A0)` (log), `CloseGroundContainer (0x0052)` (log). + +**Files:** +- Modify: `src/AcDream.Core.Net/GameEventWiring.cs` (add registrations after the `InventoryPutObjInContainer` registration, ~line 248) +- Test: `tests/AcDream.Core.Net.Tests/GameEventWiringTests.cs` (append) + +- [ ] **Step 1: Write the failing test** + +Append to `GameEventWiringTests.cs`: + +```csharp + [Fact] + public void WireAll_ViewContents_RecordsMembershipInClientObjectTable() + { + var (d, items, _, _, _) = MakeAll(); + + // containerGuid 0xC9 + 2 entries + byte[] payload = new byte[8 + 2 * 8]; + BinaryPrimitives.WriteUInt32LittleEndian(payload.AsSpan(0), 0x500000C9u); + BinaryPrimitives.WriteUInt32LittleEndian(payload.AsSpan(4), 2u); + BinaryPrimitives.WriteUInt32LittleEndian(payload.AsSpan(8), 0x50000A01u); + BinaryPrimitives.WriteUInt32LittleEndian(payload.AsSpan(12), 0u); + BinaryPrimitives.WriteUInt32LittleEndian(payload.AsSpan(16), 0x50000A02u); + BinaryPrimitives.WriteUInt32LittleEndian(payload.AsSpan(20), 1u); + + var env = GameEventEnvelope.TryParse(WrapEnvelope(GameEventType.ViewContents, payload)); + d.Dispatch(env!.Value); + + Assert.Equal(0x500000C9u, items.Get(0x50000A01u)!.ContainerId); + Assert.Equal(0x500000C9u, items.Get(0x50000A02u)!.ContainerId); + } + + [Fact] + public void WireAll_InventoryPutObjectIn3D_UnparentsFromContainer() + { + var (d, items, _, _, _) = MakeAll(); + items.RecordMembership(0x50000A01u, containerId: 0x500000C9u); + + byte[] payload = new byte[4]; + BinaryPrimitives.WriteUInt32LittleEndian(payload, 0x50000A01u); + var env = GameEventEnvelope.TryParse(WrapEnvelope(GameEventType.InventoryPutObjectIn3D, payload)); + d.Dispatch(env!.Value); + + Assert.Equal(0u, items.Get(0x50000A01u)!.ContainerId); + } +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `dotnet test tests/AcDream.Core.Net.Tests/AcDream.Core.Net.Tests.csproj --filter "FullyQualifiedName~GameEventWiringTests.WireAll_ViewContents|FullyQualifiedName~GameEventWiringTests.WireAll_InventoryPutObjectIn3D"` +Expected: FAIL — these events aren't registered, so nothing changes (the `Get` returns null / ContainerId unchanged). + +- [ ] **Step 3: Write minimal implementation** + +In `src/AcDream.Core.Net/GameEventWiring.cs`, after the `InventoryPutObjInContainer` registration block (the one ending ~line 248), add: + +```csharp + // B-Wire: ViewContents (0x0196) — the server's full contents list for a + // container you opened. Record membership for each entry so the table is + // correct before the container-open UI mounts the second item list. + dispatcher.Register(GameEventType.ViewContents, e => + { + var p = GameEvents.ParseViewContents(e.Payload.Span); + if (p is null) return; + foreach (var entry in p.Value.Items) + items.RecordMembership(entry.Guid, containerId: p.Value.ContainerGuid); + }); + + // B-Wire: InventoryPutObjectIn3D (0x019A) — server confirms an item dropped + // to the world. Unparent it from its container (it's now a ground object) so + // the inventory grid drops the cell; the object itself survives. + dispatcher.Register(GameEventType.InventoryPutObjectIn3D, e => + { + var guid = GameEvents.ParsePutObjectIn3D(e.Payload.Span); + if (guid is not null) items.MoveItem(guid.Value, newContainerId: 0u); + }); + + // B-Wire: InventoryServerSaveFailed (0x00A0) — rollback of a speculative move. + // acdream does not do speculative moves yet (read-only inventory); parse + log + // so the wire is correct. B-Drag wires the rollback behavior. (Divergence note.) + dispatcher.Register(GameEventType.InventoryServerSaveFailed, e => + { + var p = GameEvents.ParseInventoryServerSaveFailed(e.Payload.Span); + if (p is not null) + Console.WriteLine($"[B-Wire] InventoryServerSaveFailed guid=0x{p.Value.ItemGuid:X8} err=0x{p.Value.WeenieError:X}"); + }); + + // B-Wire: CloseGroundContainer (0x0052) — server closed a ground-container + // view. No table change (the view is UI-only, wired in container-open); log. + dispatcher.Register(GameEventType.CloseGroundContainer, e => + { + var guid = GameEvents.ParseCloseGroundContainer(e.Payload.Span); + if (guid is not null) + Console.WriteLine($"[B-Wire] CloseGroundContainer guid=0x{guid.Value:X8}"); + }); +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `dotnet test tests/AcDream.Core.Net.Tests/AcDream.Core.Net.Tests.csproj --filter "FullyQualifiedName~GameEventWiringTests"` +Expected: PASS (all GameEventWiring tests). + +- [ ] **Step 5: Commit** + +```bash +git add src/AcDream.Core.Net/GameEventWiring.cs tests/AcDream.Core.Net.Tests/GameEventWiringTests.cs +git commit -m "feat(net): D.2b-B B-Wire — register ViewContents + InventoryPutObjectIn3D/SaveFailed/CloseGroundContainer" +``` + +--- + +## Task 12: WorldSession dispatch + events for the 3 top-level GameMessages (Core.Net) + +Add events + switch cases for `PrivateUpdatePropertyInt (0x02CD)`, `SetStackSize (0x0197)`, `InventoryRemoveObject (0x0024)`. Glue — no test seam on the switch; verified by build + the parser tests + the live run (per the codebase convention documented in `ObjectTableWiringTests`). + +**Files:** +- Modify: `src/AcDream.Core.Net/WorldSession.cs` (add events near `ObjectIntPropertyUpdated` ~line 196; add switch cases after the `PublicUpdatePropertyInt` branch ~line 960) + +- [ ] **Step 1: Add the events** + +In `src/AcDream.Core.Net/WorldSession.cs`, after the `ObjectIntPropertyUpdated` event (~line 196), add: + +```csharp + /// Payload for : a PropertyInt change on + /// the player's OWN object (from PrivateUpdatePropertyInt 0x02CD — no guid on the wire). + public readonly record struct PlayerIntPropertyUpdate(uint Property, int Value); + + /// Fires when the session parses a PrivateUpdatePropertyInt (0x02CD) — one + /// PropertyInt updated on the player. B-Wire routes EncumbranceVal (5) to the burden bar. + public event Action? PlayerIntPropertyUpdated; + + /// Payload for : SetStackSize (0x0197) — a stack's + /// count + value after a merge / split. + public readonly record struct StackSizeUpdate(uint Guid, int StackSize, int Value); + + /// Fires when the session parses a SetStackSize (0x0197) top-level GameMessage. + public event Action? StackSizeUpdated; + + /// Fires when the session parses an InventoryRemoveObject (0x0024) — the guid left + /// the player's inventory view. + public event Action? InventoryObjectRemoved; +``` + +- [ ] **Step 2: Add the switch cases** + +In `src/AcDream.Core.Net/WorldSession.cs`, immediately after the `else if (op == PublicUpdatePropertyInt.Opcode)` branch (the one ending ~line 960), add: + +```csharp + else if (op == PrivateUpdatePropertyInt.Opcode) + { + var p = PrivateUpdatePropertyInt.TryParse(body); + if (p is not null) + PlayerIntPropertyUpdated?.Invoke( + new PlayerIntPropertyUpdate(p.Value.Property, p.Value.Value)); + } + else if (op == SetStackSize.Opcode) + { + var p = SetStackSize.TryParse(body); + if (p is not null) + StackSizeUpdated?.Invoke( + new StackSizeUpdate(p.Value.Guid, p.Value.StackSize, p.Value.Value)); + } + else if (op == InventoryRemoveObject.Opcode) + { + var p = InventoryRemoveObject.TryParse(body); + if (p is not null) InventoryObjectRemoved?.Invoke(p.Value.Guid); + } +``` + +- [ ] **Step 3: Build to verify it compiles** + +Run: `dotnet build src/AcDream.Core.Net/AcDream.Core.Net.csproj` +Expected: Build succeeded. + +- [ ] **Step 4: Run the full Core.Net test suite (no regressions)** + +Run: `dotnet test tests/AcDream.Core.Net.Tests/AcDream.Core.Net.Tests.csproj` +Expected: PASS (all green). + +- [ ] **Step 5: Commit** + +```bash +git add src/AcDream.Core.Net/WorldSession.cs +git commit -m "feat(net): D.2b-B B-Wire — WorldSession dispatch for 0x02CD/SetStackSize/InventoryRemoveObject" +``` + +--- + +## Task 13: ObjectTableWiring — apply all ints + player-int route + stack/remove (Core.Net) + +Loosen the int gate (apply all ints, not just UiEffects), route the player int to the player object, and apply SetStackSize / InventoryRemoveObject. Glue — verified by build + the underlying table-method tests (Tasks 1/2) + run. + +**Files:** +- Modify: `src/AcDream.Core.Net/ObjectTableWiring.cs` (the `Wire` method) + +- [ ] **Step 1: Replace the `Wire` method body** + +In `src/AcDream.Core.Net/ObjectTableWiring.cs`, change the `Wire` signature + subscriptions. Replace the whole method (lines ~19-31) with: + +```csharp + public static void Wire(WorldSession session, ClientObjectTable table, Func? playerGuid = null) + { + ArgumentNullException.ThrowIfNull(session); + ArgumentNullException.ThrowIfNull(table); + + session.EntitySpawned += s => table.Ingest(ToWeenieData(s)); + session.EntityDeleted += d => table.Remove(d.Guid); + + // B-Wire: apply EVERY PropertyInt update on a visible object (0x02CE), not just + // UiEffects — the server is the authority on object properties. UpdateIntProperty + // stores it in the bundle and still mirrors UiEffects → the typed Effects field. + session.ObjectIntPropertyUpdated += u => + table.UpdateIntProperty(u.Guid, u.Property, u.Value); + + // B-Wire: PrivateUpdatePropertyInt (0x02CD) carries no guid — it targets the + // local player. Route it to the player object so live EncumbranceVal updates the + // burden bar. (No-op if the player guid isn't wired yet.) + session.PlayerIntPropertyUpdated += u => + { + if (playerGuid is not null) + table.UpdateIntProperty(playerGuid(), u.Property, u.Value); + }; + + // B-Wire: SetStackSize (0x0197) — update the object's stack count + value. + session.StackSizeUpdated += u => table.UpdateStackSize(u.Guid, u.StackSize, u.Value); + + // B-Wire: InventoryRemoveObject (0x0024) — the object left the player's inventory + // view; drop it from the table (retail ClientUISystem removes it from maintenance). + session.InventoryObjectRemoved += guid => table.Remove(guid); + } +``` + +Also update the class doc-comment (line ~8) to mention the new routes: + +```csharp +/// = evict, PublicUpdatePropertyInt (0x02CE) = live int apply, PrivateUpdatePropertyInt +/// (0x02CD) = player int (burden), SetStackSize (0x0197) = stack count, InventoryRemoveObject +/// (0x0024) = inventory-view removal. +``` + +- [ ] **Step 2: Build to verify it compiles** + +Run: `dotnet build src/AcDream.Core.Net/AcDream.Core.Net.csproj` +Expected: Build succeeded. + +- [ ] **Step 3: Run the full Core.Net test suite** + +Run: `dotnet test tests/AcDream.Core.Net.Tests/AcDream.Core.Net.Tests.csproj` +Expected: PASS (`ObjectTableWiringTests.ToWeenieData_CopiesFieldsFromSpawn` still green — the signature change is source-compatible since `playerGuid` is optional). + +- [ ] **Step 4: Commit** + +```bash +git add src/AcDream.Core.Net/ObjectTableWiring.cs +git commit -m "feat(net): D.2b-B B-Wire — ObjectTableWiring applies all ints + player int + stack/remove" +``` + +--- + +## Task 14: GameWindow — pass the player guid into both wirings (App) + +**Files:** +- Modify: `src/AcDream.App/Rendering/GameWindow.cs` (line ~2471 `ObjectTableWiring.Wire`; line ~2569 the `WireAll` `onShortcuts` arg) + +- [ ] **Step 1: Pass playerGuid to ObjectTableWiring.Wire** + +In `src/AcDream.App/Rendering/GameWindow.cs` at ~line 2471, change: + +```csharp + AcDream.Core.Net.ObjectTableWiring.Wire(session, Objects); +``` + +to: + +```csharp + AcDream.Core.Net.ObjectTableWiring.Wire(session, Objects, () => _playerServerGuid); +``` + +- [ ] **Step 2: Pass playerGuid to GameEventWiring.WireAll** + +In `src/AcDream.App/Rendering/GameWindow.cs`, in the `WireAll(...)` call (~line 2525-2569), change the final argument line: + +```csharp + onShortcuts: list => Shortcuts = list); +``` + +to: + +```csharp + onShortcuts: list => Shortcuts = list, + playerGuid: () => _playerServerGuid); +``` + +- [ ] **Step 3: Build to verify it compiles** + +Run: `dotnet build src/AcDream.App/AcDream.App.csproj` +Expected: Build succeeded. + +- [ ] **Step 4: Commit** + +```bash +git add src/AcDream.App/Rendering/GameWindow.cs +git commit -m "feat(app): D.2b-B B-Wire — wire player guid into ObjectTableWiring + GameEventWiring" +``` + +--- + +## Task 15: InventoryController refreshes burden on player-object update (App) + +`Concerns()` excludes the player's own object, so a live `EncumbranceVal` update wouldn't repaint the burden bar. The player object IS the burden source — include it. + +**Files:** +- Modify: `src/AcDream.App/UI/Layout/InventoryController.cs` (`Concerns`, ~line 115) + +- [ ] **Step 1: Make the change** + +In `src/AcDream.App/UI/Layout/InventoryController.cs`, change `Concerns` (~line 115-120): + +```csharp + 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 + } +``` + +to: + +```csharp + private bool Concerns(ClientObject o) + { + uint p = _playerGuid(); + return o.ObjectId == p // B-Wire: the player object IS the burden source + || o.ContainerId == p || o.WielderId == p + || (o.ContainerId != 0 && _objects.Get(o.ContainerId)?.ContainerId == p); // 2-deep + } +``` + +- [ ] **Step 2: Build to verify it compiles** + +Run: `dotnet build src/AcDream.App/AcDream.App.csproj` +Expected: Build succeeded. + +- [ ] **Step 3: Commit** + +```bash +git add src/AcDream.App/UI/Layout/InventoryController.cs +git commit -m "fix(app): D.2b-B B-Wire — burden bar refreshes on player-object property update" +``` + +--- + +## Task 16: Full verification + divergence register + ISSUES/roadmap + memory + +**Files:** +- Modify: `docs/architecture/retail-divergence-register.md` (retire AP-48, AP-49) +- Modify: `docs/ISSUES.md` (B-Wire SHIPPED note) +- Modify: `docs/plans/2026-04-11-roadmap.md` (B-Wire shipped row) +- Modify: `claude-memory/project_d2b_retail_ui.md` (B-Wire SHIPPED entry + the 0x02CD/no-guid + apply-gate DO-NOT-RETRY) + +- [ ] **Step 1: Full build + test (the phase gate)** + +Run: `dotnet build` then `dotnet test` +Expected: Build succeeded; all tests green (App + Core + Core.Net). Note the new counts vs the B-Controller baseline (App 532 / Core 1526). + +- [ ] **Step 2: Retire the divergence rows** + +In `docs/architecture/retail-divergence-register.md`, find rows **AP-48** (client-side `SumCarriedBurden`) and **AP-49** (carry-aug unwired). Delete them (the wire mechanism is now ported), OR if `SumCarriedBurden` remains reachable as a fallback when the wire value is genuinely absent, reword AP-48 to "fallback only when EncumbranceVal absent" rather than deleting. State which you did in the commit. Confirm no other row references them. + +- [ ] **Step 3: Update ISSUES + roadmap** + +In `docs/ISSUES.md`: add a "Recently closed / shipped" note for D.2b-B B-Wire referencing the commits, and note that the contents-grid overflow + B-Drag remain open. + +In `docs/plans/2026-04-11-roadmap.md`: move B-Wire to the shipped column for the D.2b inventory arc. + +- [ ] **Step 4: Update the memory digest** + +In `claude-memory/project_d2b_retail_ui.md`, add a B-Wire SHIPPED entry (after the B-Controller entry) capturing: EncumbranceVal delivery (login PD upsert + live 0x02CD, no-guid) retires AP-48/AP-49; the apply-gate now passes all ints; SetStackSize 0x0197 / InventoryRemoveObject 0x0024 are top-level GameMessages (not GameEvents); ViewContents 0x0196 is a GameEvent applying membership. Update the MEMORY.md index line if the one-liner changed. + +- [ ] **Step 5: Commit** + +```bash +git add docs/architecture/retail-divergence-register.md docs/ISSUES.md docs/plans/2026-04-11-roadmap.md claude-memory/ +git commit -m "docs(D.2b-B): B-Wire shipped — retire AP-48/AP-49 + ISSUES/roadmap/memory" +``` + +--- + +## Post-implementation: visual gate + +After Task 16, B-Wire is code-complete and test-green, but the **burden-bar-reads-server-value** behavior is best confirmed live (the project's acceptance model). Launch the client against ACE (per CLAUDE.md "Running the client"), open the inventory (F12, `ACDREAM_RETAIL_UI=1`), and confirm: the burden % matches what a retail client / ACE shows for `+Acdream`, and it updates when you pick up / drop an item (live 0x02CD). Report the observation; do not claim the behavior verified without it. + +--- + +## Self-review notes (coverage vs. spec) + +- **C1a** login delivery → Task 10 (+ Task 1 UpsertProperties). **C1b** live 0x02CD → Tasks 3, 12, 13. **C1c** gate → Task 13. **C1d** consumer refresh → Task 15. +- **C2a** 0x0022 4th field → Task 7. **C2b** 0x00A0 error → Task 8. +- **C3** builders → Task 9. **C4a** ViewContents → Tasks 6, 11. **C4b** SetStackSize → Tasks 4, 12, 13 (+ Task 2 UpdateStackSize). **C4c** InventoryRemoveObject → Tasks 5, 12, 13. +- **C5** registration → Tasks 11 (GameEvents) + 12 (WorldSession switch). +- Divergence/AP-48/AP-49 + bookkeeping → Task 16. +- **CreateObject extension** (spec §7): already done (`ToWeenieData`); if Step-1 grep-named of `CreateObject.TryParse` finds a still-discarded inventory field, file it as a follow-up issue — out of scope here. From b56087b498888475248a6a8827e276cf3e7af858 Mon Sep 17 00:00:00 2001 From: Erik Date: Sun, 21 Jun 2026 18:48:04 +0200 Subject: [PATCH 053/133] =?UTF-8?q?feat(core):=20D.2b-B=20B-Wire=20?= =?UTF-8?q?=E2=80=94=20ClientObjectTable.UpsertProperties=20(create-if-abs?= =?UTF-8?q?ent)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 (1M context) --- src/AcDream.Core/Items/ClientObjectTable.cs | 26 ++++++++++++ .../Items/ClientObjectTableUpdateTests.cs | 41 +++++++++++++++++++ 2 files changed, 67 insertions(+) create mode 100644 tests/AcDream.Core.Tests/Items/ClientObjectTableUpdateTests.cs diff --git a/src/AcDream.Core/Items/ClientObjectTable.cs b/src/AcDream.Core/Items/ClientObjectTable.cs index 356fa7b6..5ed850a8 100644 --- a/src/AcDream.Core/Items/ClientObjectTable.cs +++ b/src/AcDream.Core/Items/ClientObjectTable.cs @@ -163,6 +163,32 @@ public sealed class ClientObjectTable return true; } + /// + /// Apply a patch, creating the object if it does not + /// exist yet. Used for the local player's own properties from PlayerDescription + /// (0x0013), which may arrive BEFORE the player's CreateObject — unlike + /// , which no-ops on an unknown object. Fires + /// ObjectAdded on create, else ObjectUpdated. + /// + public void UpsertProperties(uint guid, PropertyBundle incoming) + { + ArgumentNullException.ThrowIfNull(incoming); + bool existed = _objects.TryGetValue(guid, out var item); + if (!existed || item is null) + { + item = new ClientObject { ObjectId = guid }; + _objects[guid] = item; + } + foreach (var kv in incoming.Ints) item.Properties.Ints[kv.Key] = kv.Value; + foreach (var kv in incoming.Int64s) item.Properties.Int64s[kv.Key] = kv.Value; + foreach (var kv in incoming.Bools) item.Properties.Bools[kv.Key] = kv.Value; + foreach (var kv in incoming.Floats) item.Properties.Floats[kv.Key] = kv.Value; + foreach (var kv in incoming.Strings) item.Properties.Strings[kv.Key] = kv.Value; + foreach (var kv in incoming.DataIds) item.Properties.DataIds[kv.Key] = kv.Value; + foreach (var kv in incoming.InstanceIds) item.Properties.InstanceIds[kv.Key] = kv.Value; + if (!existed) ObjectAdded?.Invoke(item); else ObjectUpdated?.Invoke(item); + } + /// /// Apply a single PropertyInt update (from PublicUpdatePropertyInt 0x02CE) to an /// object: store it in the bundle and, for known typed ints, mirror to the typed diff --git a/tests/AcDream.Core.Tests/Items/ClientObjectTableUpdateTests.cs b/tests/AcDream.Core.Tests/Items/ClientObjectTableUpdateTests.cs new file mode 100644 index 00000000..b5ec1c78 --- /dev/null +++ b/tests/AcDream.Core.Tests/Items/ClientObjectTableUpdateTests.cs @@ -0,0 +1,41 @@ +using AcDream.Core.Items; +using Xunit; + +namespace AcDream.Core.Tests.Items; + +public sealed class ClientObjectTableUpdateTests +{ + [Fact] + public void UpsertProperties_unknownObject_createsThenMerges() + { + var t = new ClientObjectTable(); + bool added = false; + t.ObjectAdded += _ => added = true; + + var bundle = new PropertyBundle(); + bundle.Ints[5] = 1234; // EncumbranceVal + + t.UpsertProperties(0x50000001u, bundle); + + var o = t.Get(0x50000001u); + Assert.NotNull(o); + Assert.Equal(1234, o!.Properties.Ints[5]); + Assert.True(added); + } + + [Fact] + public void UpsertProperties_existingObject_mergesAndFiresUpdated() + { + var t = new ClientObjectTable(); + t.AddOrUpdate(new ClientObject { ObjectId = 0x50000001u }); + bool updated = false; + t.ObjectUpdated += _ => updated = true; + + var bundle = new PropertyBundle(); + bundle.Ints[5] = 99; + t.UpsertProperties(0x50000001u, bundle); + + Assert.Equal(99, t.Get(0x50000001u)!.Properties.Ints[5]); + Assert.True(updated); + } +} From 3f190811beecc469d6e191156d6b8aac8f5cdce7 Mon Sep 17 00:00:00 2001 From: Erik Date: Sun, 21 Jun 2026 18:48:54 +0200 Subject: [PATCH 054/133] =?UTF-8?q?feat(core):=20D.2b-B=20B-Wire=20?= =?UTF-8?q?=E2=80=94=20ClientObjectTable.UpdateStackSize?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 (1M context) --- src/AcDream.Core/Items/ClientObjectTable.cs | 14 +++++++++++++ .../Items/ClientObjectTableUpdateTests.cs | 20 +++++++++++++++++++ 2 files changed, 34 insertions(+) diff --git a/src/AcDream.Core/Items/ClientObjectTable.cs b/src/AcDream.Core/Items/ClientObjectTable.cs index 5ed850a8..b789c11c 100644 --- a/src/AcDream.Core/Items/ClientObjectTable.cs +++ b/src/AcDream.Core/Items/ClientObjectTable.cs @@ -205,6 +205,20 @@ public sealed class ClientObjectTable return true; } + /// + /// Apply a SetStackSize (0x0197) update: set the object's StackSize + Value and + /// fire ObjectUpdated so bound widgets refresh the quantity overlay. False if the + /// object is unknown. Retail: ACCWeenieObject::ServerSaysSetStackSize (0x0058...). + /// + public bool UpdateStackSize(uint guid, int stackSize, int value) + { + if (!_objects.TryGetValue(guid, out var item)) return false; + item.StackSize = stackSize; + item.Value = value; + ObjectUpdated?.Invoke(item); + return true; + } + /// /// Canonical CreateObject ingestion: create-if-absent, else patch the /// wire-carried fields in place (retail SetWeenieDesc). Preserves the diff --git a/tests/AcDream.Core.Tests/Items/ClientObjectTableUpdateTests.cs b/tests/AcDream.Core.Tests/Items/ClientObjectTableUpdateTests.cs index b5ec1c78..e2bab4ea 100644 --- a/tests/AcDream.Core.Tests/Items/ClientObjectTableUpdateTests.cs +++ b/tests/AcDream.Core.Tests/Items/ClientObjectTableUpdateTests.cs @@ -38,4 +38,24 @@ public sealed class ClientObjectTableUpdateTests Assert.Equal(99, t.Get(0x50000001u)!.Properties.Ints[5]); Assert.True(updated); } + + [Fact] + public void UpdateStackSize_knownObject_setsFieldsAndFiresUpdated() + { + var t = new ClientObjectTable(); + t.AddOrUpdate(new ClientObject { ObjectId = 0x600u, StackSize = 1, Value = 5 }); + bool updated = false; + t.ObjectUpdated += _ => updated = true; + + bool ok = t.UpdateStackSize(0x600u, stackSize: 25, value: 125); + + Assert.True(ok); + Assert.Equal(25, t.Get(0x600u)!.StackSize); + Assert.Equal(125, t.Get(0x600u)!.Value); + Assert.True(updated); + } + + [Fact] + public void UpdateStackSize_unknownObject_returnsFalse() + => Assert.False(new ClientObjectTable().UpdateStackSize(0xDEADu, 1, 1)); } From 7b25d78506a368a2972ce0fd241aba9adac35f4e Mon Sep 17 00:00:00 2001 From: Erik Date: Sun, 21 Jun 2026 18:50:56 +0200 Subject: [PATCH 055/133] =?UTF-8?q?feat(net):=20D.2b-B=20B-Wire=20?= =?UTF-8?q?=E2=80=94=20PrivateUpdatePropertyInt=20(0x02CD)=20parser?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mirror of PublicUpdatePropertyInt (0x02CE) but with no guid field — targets the local player's own object. Burden (EncumbranceVal, PropertyInt 5) changes ride this opcode on every pick-up / drop. Layout: opcode(4) + seq(1) + property(4) + value(4) = 13 bytes. 3 tests: happy path, wrong opcode, truncated. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../Messages/PrivateUpdatePropertyInt.cs | 38 +++++++++++++++++++ .../Messages/PrivateUpdatePropertyIntTests.cs | 36 ++++++++++++++++++ 2 files changed, 74 insertions(+) create mode 100644 src/AcDream.Core.Net/Messages/PrivateUpdatePropertyInt.cs create mode 100644 tests/AcDream.Core.Net.Tests/Messages/PrivateUpdatePropertyIntTests.cs diff --git a/src/AcDream.Core.Net/Messages/PrivateUpdatePropertyInt.cs b/src/AcDream.Core.Net/Messages/PrivateUpdatePropertyInt.cs new file mode 100644 index 00000000..5d60af03 --- /dev/null +++ b/src/AcDream.Core.Net/Messages/PrivateUpdatePropertyInt.cs @@ -0,0 +1,38 @@ +using System; +using System.Buffers.Binary; + +namespace AcDream.Core.Net.Messages; + +/// +/// Inbound PrivateUpdatePropertyInt (0x02CD) — the server updates one +/// PropertyInt on the player's OWN object. Unlike the sibling +/// (0x02CE), this carries NO guid (it +/// implicitly targets the local player). Burden (EncumbranceVal, PropertyInt 5) +/// changes ride this opcode on every pick-up / drop. +/// +/// Wire layout (ACE GameMessagePrivateUpdatePropertyInt): +/// +/// u32 opcode = 0x02CD +/// u8 sequence // single byte (ByteSequence) — not honored, latest-wins (DR-4) +/// u32 property // PropertyInt enum +/// i32 value +/// +/// +public static class PrivateUpdatePropertyInt +{ + public const uint Opcode = 0x02CDu; + + public readonly record struct Parsed(uint Property, int Value); + + /// Parse a raw 0x02CD body. Returns null on opcode mismatch / truncation. + public static Parsed? TryParse(ReadOnlySpan body) + { + if (body.Length < 13) return null; // 4 + 1 + 4 + 4 + if (BinaryPrimitives.ReadUInt32LittleEndian(body) != Opcode) return null; + int pos = 4; + pos += 1; // sequence byte (not honored) + uint prop = BinaryPrimitives.ReadUInt32LittleEndian(body[pos..]); pos += 4; + int value = BinaryPrimitives.ReadInt32LittleEndian(body[pos..]); + return new Parsed(prop, value); + } +} diff --git a/tests/AcDream.Core.Net.Tests/Messages/PrivateUpdatePropertyIntTests.cs b/tests/AcDream.Core.Net.Tests/Messages/PrivateUpdatePropertyIntTests.cs new file mode 100644 index 00000000..f696c010 --- /dev/null +++ b/tests/AcDream.Core.Net.Tests/Messages/PrivateUpdatePropertyIntTests.cs @@ -0,0 +1,36 @@ +using System.Buffers.Binary; +using AcDream.Core.Net.Messages; +using Xunit; + +namespace AcDream.Core.Net.Tests.Messages; + +public sealed class PrivateUpdatePropertyIntTests +{ + // 0x02CD body: opcode(4) + seq(1) + property(4) + value(4) = 13 bytes. NO guid. + private static byte[] Build(uint property, int value, byte seq = 1, uint opcode = 0x02CDu) + { + var b = new byte[13]; + BinaryPrimitives.WriteUInt32LittleEndian(b.AsSpan(0), opcode); + b[4] = seq; + BinaryPrimitives.WriteUInt32LittleEndian(b.AsSpan(5), property); + BinaryPrimitives.WriteInt32LittleEndian(b.AsSpan(9), value); + return b; + } + + [Fact] + public void TryParse_encumbranceVal_returnsPropValue() + { + var p = PrivateUpdatePropertyInt.TryParse(Build(property: 5u, value: 1500)); + Assert.NotNull(p); + Assert.Equal(5u, p!.Value.Property); + Assert.Equal(1500, p.Value.Value); + } + + [Fact] + public void TryParse_wrongOpcode_returnsNull() + => Assert.Null(PrivateUpdatePropertyInt.TryParse(Build(5, 1, opcode: 0x02CEu))); + + [Fact] + public void TryParse_truncated_returnsNull() + => Assert.Null(PrivateUpdatePropertyInt.TryParse(new byte[12])); +} From 87e354f583c2b3677d29d9339fb270c19f6552f2 Mon Sep 17 00:00:00 2001 From: Erik Date: Sun, 21 Jun 2026 18:51:42 +0200 Subject: [PATCH 056/133] =?UTF-8?q?feat(net):=20D.2b-B=20B-Wire=20?= =?UTF-8?q?=E2=80=94=20SetStackSize=20(0x0197)=20parser?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Top-level GameMessage (UIQueue), not a GameEvent. Server sends after a stack merge / split to update count + value. Layout: opcode(4) + seq(1) + guid(4) + stackSize(4) + value(4) = 17 bytes. 3 tests: happy path, wrong opcode, truncated. Client consumer: ACCWeenieObject::ServerSaysSetStackSize. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/AcDream.Core.Net/Messages/SetStackSize.cs | 38 +++++++++++++++++++ .../Messages/SetStackSizeTests.cs | 37 ++++++++++++++++++ 2 files changed, 75 insertions(+) create mode 100644 src/AcDream.Core.Net/Messages/SetStackSize.cs create mode 100644 tests/AcDream.Core.Net.Tests/Messages/SetStackSizeTests.cs diff --git a/src/AcDream.Core.Net/Messages/SetStackSize.cs b/src/AcDream.Core.Net/Messages/SetStackSize.cs new file mode 100644 index 00000000..5d7a8313 --- /dev/null +++ b/src/AcDream.Core.Net/Messages/SetStackSize.cs @@ -0,0 +1,38 @@ +using System; +using System.Buffers.Binary; + +namespace AcDream.Core.Net.Messages; + +/// +/// Inbound SetStackSize (0x0197) — a top-level GameMessage (UIQueue group), +/// NOT a GameEvent. The server updates a stack's count + value after a merge / split. +/// Client consumer: ACCWeenieObject::ServerSaysSetStackSize. +/// +/// Wire layout (ACE GameMessageSetStackSize.cs, size hint 17): +/// +/// u32 opcode = 0x0197 +/// u8 sequence // ByteSequence (UpdatePropertyInt) — not honored +/// u32 guid +/// i32 stackSize +/// i32 value +/// +/// +public static class SetStackSize +{ + public const uint Opcode = 0x0197u; + + public readonly record struct Parsed(uint Guid, int StackSize, int Value); + + /// Parse a raw 0x0197 body. Returns null on opcode mismatch / truncation. + public static Parsed? TryParse(ReadOnlySpan body) + { + if (body.Length < 17) return null; // 4 + 1 + 4 + 4 + 4 + if (BinaryPrimitives.ReadUInt32LittleEndian(body) != Opcode) return null; + int pos = 4; + pos += 1; // sequence byte (not honored) + uint guid = BinaryPrimitives.ReadUInt32LittleEndian(body[pos..]); pos += 4; + int stack = BinaryPrimitives.ReadInt32LittleEndian(body[pos..]); pos += 4; + int value = BinaryPrimitives.ReadInt32LittleEndian(body[pos..]); + return new Parsed(guid, stack, value); + } +} diff --git a/tests/AcDream.Core.Net.Tests/Messages/SetStackSizeTests.cs b/tests/AcDream.Core.Net.Tests/Messages/SetStackSizeTests.cs new file mode 100644 index 00000000..7f09c4f1 --- /dev/null +++ b/tests/AcDream.Core.Net.Tests/Messages/SetStackSizeTests.cs @@ -0,0 +1,37 @@ +using System.Buffers.Binary; +using AcDream.Core.Net.Messages; +using Xunit; + +namespace AcDream.Core.Net.Tests.Messages; + +public sealed class SetStackSizeTests +{ + private static byte[] Build(uint guid, int stackSize, int value, byte seq = 1, uint opcode = 0x0197u) + { + var b = new byte[17]; + BinaryPrimitives.WriteUInt32LittleEndian(b.AsSpan(0), opcode); + b[4] = seq; + BinaryPrimitives.WriteUInt32LittleEndian(b.AsSpan(5), guid); + BinaryPrimitives.WriteInt32LittleEndian(b.AsSpan(9), stackSize); + BinaryPrimitives.WriteInt32LittleEndian(b.AsSpan(13), value); + return b; + } + + [Fact] + public void TryParse_validStack_returnsGuidStackValue() + { + var p = SetStackSize.TryParse(Build(0x50000A01u, stackSize: 25, value: 125)); + Assert.NotNull(p); + Assert.Equal(0x50000A01u, p!.Value.Guid); + Assert.Equal(25, p.Value.StackSize); + Assert.Equal(125, p.Value.Value); + } + + [Fact] + public void TryParse_wrongOpcode_returnsNull() + => Assert.Null(SetStackSize.TryParse(Build(1, 1, 1, opcode: 0x0196u))); + + [Fact] + public void TryParse_truncated_returnsNull() + => Assert.Null(SetStackSize.TryParse(new byte[16])); +} From 79c0374d532430d563d2dbea47e6ae82395f4cd7 Mon Sep 17 00:00:00 2001 From: Erik Date: Sun, 21 Jun 2026 18:52:21 +0200 Subject: [PATCH 057/133] =?UTF-8?q?feat(net):=20D.2b-B=20B-Wire=20?= =?UTF-8?q?=E2=80=94=20InventoryRemoveObject=20(0x0024)=20parser?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Top-level GameMessage (UIQueue), not a GameEvent. Server sends when an object leaves the client's inventory view (sold / dropped / destroyed). Layout: opcode(4) + guid(4) = 8 bytes — the smallest inventory wire. 3 tests: happy path, wrong opcode, truncated. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../Messages/InventoryRemoveObject.cs | 31 ++++++++++++++++++ .../Messages/InventoryRemoveObjectTests.cs | 32 +++++++++++++++++++ 2 files changed, 63 insertions(+) create mode 100644 src/AcDream.Core.Net/Messages/InventoryRemoveObject.cs create mode 100644 tests/AcDream.Core.Net.Tests/Messages/InventoryRemoveObjectTests.cs diff --git a/src/AcDream.Core.Net/Messages/InventoryRemoveObject.cs b/src/AcDream.Core.Net/Messages/InventoryRemoveObject.cs new file mode 100644 index 00000000..b89d9cd4 --- /dev/null +++ b/src/AcDream.Core.Net/Messages/InventoryRemoveObject.cs @@ -0,0 +1,31 @@ +using System; +using System.Buffers.Binary; + +namespace AcDream.Core.Net.Messages; + +/// +/// Inbound InventoryRemoveObject (0x0024) — a top-level GameMessage (UIQueue), +/// NOT a GameEvent. The server tells the client an object left its inventory view +/// (given away / sold / destroyed). The client drops it from object maintenance. +/// +/// Wire layout (ACE GameMessageInventoryRemoveObject.cs, size hint 8): +/// +/// u32 opcode = 0x0024 +/// u32 guid +/// +/// +public static class InventoryRemoveObject +{ + public const uint Opcode = 0x0024u; + + public readonly record struct Parsed(uint Guid); + + /// Parse a raw 0x0024 body. Returns null on opcode mismatch / truncation. + public static Parsed? TryParse(ReadOnlySpan body) + { + if (body.Length < 8) return null; // 4 + 4 + if (BinaryPrimitives.ReadUInt32LittleEndian(body) != Opcode) return null; + uint guid = BinaryPrimitives.ReadUInt32LittleEndian(body[4..]); + return new Parsed(guid); + } +} diff --git a/tests/AcDream.Core.Net.Tests/Messages/InventoryRemoveObjectTests.cs b/tests/AcDream.Core.Net.Tests/Messages/InventoryRemoveObjectTests.cs new file mode 100644 index 00000000..b4588fe9 --- /dev/null +++ b/tests/AcDream.Core.Net.Tests/Messages/InventoryRemoveObjectTests.cs @@ -0,0 +1,32 @@ +using System.Buffers.Binary; +using AcDream.Core.Net.Messages; +using Xunit; + +namespace AcDream.Core.Net.Tests.Messages; + +public sealed class InventoryRemoveObjectTests +{ + private static byte[] Build(uint guid, uint opcode = 0x0024u) + { + var b = new byte[8]; + BinaryPrimitives.WriteUInt32LittleEndian(b.AsSpan(0), opcode); + BinaryPrimitives.WriteUInt32LittleEndian(b.AsSpan(4), guid); + return b; + } + + [Fact] + public void TryParse_valid_returnsGuid() + { + var p = InventoryRemoveObject.TryParse(Build(0x50000A07u)); + Assert.NotNull(p); + Assert.Equal(0x50000A07u, p!.Value.Guid); + } + + [Fact] + public void TryParse_wrongOpcode_returnsNull() + => Assert.Null(InventoryRemoveObject.TryParse(Build(1, opcode: 0x0023u))); + + [Fact] + public void TryParse_truncated_returnsNull() + => Assert.Null(InventoryRemoveObject.TryParse(new byte[7])); +} From 43afcc2adbbd8ffa3e74324035f84a562e21d6e3 Mon Sep 17 00:00:00 2001 From: Erik Date: Sun, 21 Jun 2026 18:54:25 +0200 Subject: [PATCH 058/133] =?UTF-8?q?feat(net):=20D.2b-B=20B-Wire=20?= =?UTF-8?q?=E2=80=94=20ParseViewContents=20(0x0196)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit GameEvents.ParseViewContents parses the ViewContents GameEvent payload: containerGuid + count + [guid, containerType]×count. Records ViewContentsEntry and ViewContents added. 3 unit tests added in GameEventsInventoryTests.cs (two-entry, zero-count, truncated). Co-Authored-By: Claude Opus 4.8 (1M context) --- src/AcDream.Core.Net/Messages/GameEvents.cs | 23 ++++++++++ .../Messages/GameEventsInventoryTests.cs | 45 +++++++++++++++++++ 2 files changed, 68 insertions(+) create mode 100644 tests/AcDream.Core.Net.Tests/Messages/GameEventsInventoryTests.cs diff --git a/src/AcDream.Core.Net/Messages/GameEvents.cs b/src/AcDream.Core.Net/Messages/GameEvents.cs index d9131628..bd28a283 100644 --- a/src/AcDream.Core.Net/Messages/GameEvents.cs +++ b/src/AcDream.Core.Net/Messages/GameEvents.cs @@ -358,6 +358,29 @@ public static class GameEvents BinaryPrimitives.ReadUInt32LittleEndian(payload.Slice(8))); } + /// 0x0196 ViewContents: full contents list of a container you opened. + /// Layout (ACE GameEventViewContents.cs): containerGuid, count, [guid, containerType]×count. + /// Client consumer: ClientUISystem::OnViewContents (PackableList<ContentProfile>). + public readonly record struct ViewContentsEntry(uint Guid, uint ContainerType); + public readonly record struct ViewContents(uint ContainerGuid, System.Collections.Generic.IReadOnlyList Items); + + public static ViewContents? ParseViewContents(ReadOnlySpan payload) + { + if (payload.Length < 8) return null; + uint containerGuid = BinaryPrimitives.ReadUInt32LittleEndian(payload); + uint count = BinaryPrimitives.ReadUInt32LittleEndian(payload.Slice(4)); + int pos = 8; + if ((long)payload.Length - pos < (long)count * 8) return null; + var items = new ViewContentsEntry[count]; + for (int i = 0; i < count; i++) + { + uint guid = BinaryPrimitives.ReadUInt32LittleEndian(payload.Slice(pos)); pos += 4; + uint type = BinaryPrimitives.ReadUInt32LittleEndian(payload.Slice(pos)); pos += 4; + items[i] = new ViewContentsEntry(guid, type); + } + return new ViewContents(containerGuid, items); + } + // ── Other small-payload events ────────────────────────────────────────── /// 0x01C7 UseDone: the Use/UseWithTarget completion signal (WeenieError code). diff --git a/tests/AcDream.Core.Net.Tests/Messages/GameEventsInventoryTests.cs b/tests/AcDream.Core.Net.Tests/Messages/GameEventsInventoryTests.cs new file mode 100644 index 00000000..5a49b53a --- /dev/null +++ b/tests/AcDream.Core.Net.Tests/Messages/GameEventsInventoryTests.cs @@ -0,0 +1,45 @@ +using System.Buffers.Binary; +using AcDream.Core.Net.Messages; +using Xunit; + +namespace AcDream.Core.Net.Tests.Messages; + +public sealed class GameEventsInventoryTests +{ + [Fact] + public void ParseViewContents_twoEntries_returnsContainerAndItems() + { + // containerGuid + count(2) + 2×{guid, containerType} + var b = new byte[4 + 4 + 2 * 8]; + BinaryPrimitives.WriteUInt32LittleEndian(b.AsSpan(0), 0x500000C9u); // containerGuid + BinaryPrimitives.WriteUInt32LittleEndian(b.AsSpan(4), 2u); // count + BinaryPrimitives.WriteUInt32LittleEndian(b.AsSpan(8), 0x50000A01u); // guid 1 + BinaryPrimitives.WriteUInt32LittleEndian(b.AsSpan(12), 0u); // type 1 (item) + BinaryPrimitives.WriteUInt32LittleEndian(b.AsSpan(16), 0x50000A02u);// guid 2 + BinaryPrimitives.WriteUInt32LittleEndian(b.AsSpan(20), 1u); // type 2 (container) + + var p = GameEvents.ParseViewContents(b); + Assert.NotNull(p); + Assert.Equal(0x500000C9u, p!.Value.ContainerGuid); + Assert.Equal(2, p.Value.Items.Count); + Assert.Equal(0x50000A01u, p.Value.Items[0].Guid); + Assert.Equal(0u, p.Value.Items[0].ContainerType); + Assert.Equal(0x50000A02u, p.Value.Items[1].Guid); + Assert.Equal(1u, p.Value.Items[1].ContainerType); + } + + [Fact] + public void ParseViewContents_zeroCount_returnsEmptyList() + { + var b = new byte[8]; + BinaryPrimitives.WriteUInt32LittleEndian(b.AsSpan(0), 0x500000C9u); + BinaryPrimitives.WriteUInt32LittleEndian(b.AsSpan(4), 0u); + var p = GameEvents.ParseViewContents(b); + Assert.NotNull(p); + Assert.Empty(p!.Value.Items); + } + + [Fact] + public void ParseViewContents_truncated_returnsNull() + => Assert.Null(GameEvents.ParseViewContents(new byte[4])); +} From 01910e3fabdca97eb717c3aadfadb9f665fe68b8 Mon Sep 17 00:00:00 2001 From: Erik Date: Sun, 21 Jun 2026 18:55:14 +0200 Subject: [PATCH 059/133] =?UTF-8?q?fix(net):=20D.2b-B=20B-Wire=20=E2=80=94?= =?UTF-8?q?=20ParsePutObjInContainer=20reads=20containerType=20(4th=20fiel?= =?UTF-8?q?d)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 0x0022 InventoryPutObjInContainer carries 4 u32s per ACE GameEventItemServerSaysContainId.cs: itemGuid, containerGuid, placement, containerType. The parser was reading only 3 (12 bytes) and silently dropping containerType. Fixed the record struct to add ContainerType and raised the length guard to 16. GameEventWiring caller uses only .ItemGuid/.ContainerGuid/.Placement — adding a positional field is source-compatible. 1 new test in GameEventsInventoryTests. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/AcDream.Core.Net/Messages/GameEvents.cs | 13 +++++++++---- .../Messages/GameEventsInventoryTests.cs | 17 +++++++++++++++++ 2 files changed, 26 insertions(+), 4 deletions(-) diff --git a/src/AcDream.Core.Net/Messages/GameEvents.cs b/src/AcDream.Core.Net/Messages/GameEvents.cs index bd28a283..edb98809 100644 --- a/src/AcDream.Core.Net/Messages/GameEvents.cs +++ b/src/AcDream.Core.Net/Messages/GameEvents.cs @@ -343,19 +343,24 @@ public static class GameEvents BinaryPrimitives.ReadUInt32LittleEndian(payload.Slice(8))); } - /// 0x0022 InventoryPutObjInContainer: server puts item into container slot. + /// 0x0022 InventoryPutObjInContainer: server puts item into container slot. + /// 4 fields (ACE GameEventItemServerSaysContainId.cs): itemGuid, containerGuid, + /// placement, containerType. ContainerType (0=item,1=container,2=foci) confirmed + /// vs holtburger events.rs fixture (slot=3 type=1). public readonly record struct InventoryPutObjInContainer( uint ItemGuid, uint ContainerGuid, - uint Placement); + uint Placement, + uint ContainerType); public static InventoryPutObjInContainer? ParsePutObjInContainer(ReadOnlySpan payload) { - if (payload.Length < 12) return null; + if (payload.Length < 16) return null; return new InventoryPutObjInContainer( BinaryPrimitives.ReadUInt32LittleEndian(payload), BinaryPrimitives.ReadUInt32LittleEndian(payload.Slice(4)), - BinaryPrimitives.ReadUInt32LittleEndian(payload.Slice(8))); + BinaryPrimitives.ReadUInt32LittleEndian(payload.Slice(8)), + BinaryPrimitives.ReadUInt32LittleEndian(payload.Slice(12))); } /// 0x0196 ViewContents: full contents list of a container you opened. diff --git a/tests/AcDream.Core.Net.Tests/Messages/GameEventsInventoryTests.cs b/tests/AcDream.Core.Net.Tests/Messages/GameEventsInventoryTests.cs index 5a49b53a..c77f646b 100644 --- a/tests/AcDream.Core.Net.Tests/Messages/GameEventsInventoryTests.cs +++ b/tests/AcDream.Core.Net.Tests/Messages/GameEventsInventoryTests.cs @@ -42,4 +42,21 @@ public sealed class GameEventsInventoryTests [Fact] public void ParseViewContents_truncated_returnsNull() => Assert.Null(GameEvents.ParseViewContents(new byte[4])); + + [Fact] + public void ParsePutObjInContainer_readsAllFourFields() + { + var b = new byte[16]; + BinaryPrimitives.WriteUInt32LittleEndian(b.AsSpan(0), 0x50000A01u); // itemGuid + BinaryPrimitives.WriteUInt32LittleEndian(b.AsSpan(4), 0x500000C9u); // containerGuid + BinaryPrimitives.WriteUInt32LittleEndian(b.AsSpan(8), 3u); // placement + BinaryPrimitives.WriteUInt32LittleEndian(b.AsSpan(12), 1u); // containerType + + var p = GameEvents.ParsePutObjInContainer(b); + Assert.NotNull(p); + Assert.Equal(0x50000A01u, p!.Value.ItemGuid); + Assert.Equal(0x500000C9u, p.Value.ContainerGuid); + Assert.Equal(3u, p.Value.Placement); + Assert.Equal(1u, p.Value.ContainerType); + } } From 7013651a904bd748a2fc3ded87f2907f5e388436 Mon Sep 17 00:00:00 2001 From: Erik Date: Sun, 21 Jun 2026 18:55:56 +0200 Subject: [PATCH 060/133] =?UTF-8?q?fix(net):=20D.2b-B=20B-Wire=20=E2=80=94?= =?UTF-8?q?=20ParseInventoryServerSaveFailed=20reads=20weenieError?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 0x00A0 InventoryServerSaveFailed carries (itemGuid, weenieError) per ACE GameEventInventoryServerSaveFailed.cs and holtburger events.rs:147. The old parser returned only the itemGuid as uint?, silently dropping the error code. Replaced with a typed InventoryServerSaveFailed record that reads both u32s (8-byte guard). Parser was unwired (no callers in GameEvents.cs or GameEventWiring.cs) so the signature change is safe. 1 new test in GameEventsInventoryTests. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/AcDream.Core.Net/Messages/GameEvents.cs | 14 ++++++++++---- .../Messages/GameEventsInventoryTests.cs | 13 +++++++++++++ 2 files changed, 23 insertions(+), 4 deletions(-) diff --git a/src/AcDream.Core.Net/Messages/GameEvents.cs b/src/AcDream.Core.Net/Messages/GameEvents.cs index edb98809..71c7a4f5 100644 --- a/src/AcDream.Core.Net/Messages/GameEvents.cs +++ b/src/AcDream.Core.Net/Messages/GameEvents.cs @@ -402,11 +402,17 @@ public static class GameEvents return BinaryPrimitives.ReadUInt32LittleEndian(payload); } - /// 0x00A0 InventoryServerSaveFailed: revert a speculative local inventory op. - public static uint? ParseInventoryServerSaveFailed(ReadOnlySpan payload) + /// 0x00A0 InventoryServerSaveFailed: revert a speculative local inventory op. + /// (itemGuid, weenieError) — ACE GameEventInventoryServerSaveFailed.cs; holtburger + /// events.rs:147 reads both fields. + public readonly record struct InventoryServerSaveFailed(uint ItemGuid, uint WeenieError); + + public static InventoryServerSaveFailed? ParseInventoryServerSaveFailed(ReadOnlySpan payload) { - if (payload.Length < 4) return null; - return BinaryPrimitives.ReadUInt32LittleEndian(payload); + if (payload.Length < 8) return null; + return new InventoryServerSaveFailed( + BinaryPrimitives.ReadUInt32LittleEndian(payload), + BinaryPrimitives.ReadUInt32LittleEndian(payload.Slice(4))); } /// 0x0052 CloseGroundContainer: server closed a ground container view. diff --git a/tests/AcDream.Core.Net.Tests/Messages/GameEventsInventoryTests.cs b/tests/AcDream.Core.Net.Tests/Messages/GameEventsInventoryTests.cs index c77f646b..0517a1ff 100644 --- a/tests/AcDream.Core.Net.Tests/Messages/GameEventsInventoryTests.cs +++ b/tests/AcDream.Core.Net.Tests/Messages/GameEventsInventoryTests.cs @@ -59,4 +59,17 @@ public sealed class GameEventsInventoryTests Assert.Equal(3u, p.Value.Placement); Assert.Equal(1u, p.Value.ContainerType); } + + [Fact] + public void ParseInventoryServerSaveFailed_readsGuidAndError() + { + var b = new byte[8]; + BinaryPrimitives.WriteUInt32LittleEndian(b.AsSpan(0), 0x50000A01u); // itemGuid + BinaryPrimitives.WriteUInt32LittleEndian(b.AsSpan(4), 0x0Au); // weenieError + + var p = GameEvents.ParseInventoryServerSaveFailed(b); + Assert.NotNull(p); + Assert.Equal(0x50000A01u, p!.Value.ItemGuid); + Assert.Equal(0x0Au, p.Value.WeenieError); + } } From de8baa1aa1766e31eed57271ccbf1acc25a56254 Mon Sep 17 00:00:00 2001 From: Erik Date: Sun, 21 Jun 2026 18:58:30 +0200 Subject: [PATCH 061/133] =?UTF-8?q?feat(net):=20D.2b-B=20B-Wire=20?= =?UTF-8?q?=E2=80=94=20DropItem/GetAndWieldItem/NoLongerViewingContents=20?= =?UTF-8?q?builders=20+=20Send=20wrappers?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Task 9 of the B-Wire inventory-wire plan. Adds three outbound C→S GameAction builders to InventoryActions.cs (DropItem 0x001B, GetAndWieldItem 0x001A, NoLongerViewingContents 0x0195) plus matching opcode constants after TeleToPoiOpcode. Adds SendDropItem/SendGetAndWieldItem/SendNoLongerViewingContents wrappers to WorldSession.cs (after SendRemoveShortcut, ~line 1157), following the exact same NextGameActionSequence() + SendGameAction() pattern as the existing Send* family. Three byte-layout tests added to InventoryActionsTests.cs (12 total, all green). Core.Net build clean. Unblocks B-Drag (drop) and container-open (NoLongerViewingContents on close) call sites in the inventory controller. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../Messages/InventoryActions.cs | 38 +++++++++++++++++++ src/AcDream.Core.Net/WorldSession.cs | 21 ++++++++++ .../Messages/InventoryActionsTests.cs | 34 +++++++++++++++++ 3 files changed, 93 insertions(+) diff --git a/src/AcDream.Core.Net/Messages/InventoryActions.cs b/src/AcDream.Core.Net/Messages/InventoryActions.cs index 72fe566c..cebe9a82 100644 --- a/src/AcDream.Core.Net/Messages/InventoryActions.cs +++ b/src/AcDream.Core.Net/Messages/InventoryActions.cs @@ -22,6 +22,9 @@ public static class InventoryActions public const uint AddShortcutOpcode = 0x019Cu; public const uint RemoveShortcutOpcode = 0x019Du; public const uint TeleToPoiOpcode = 0x00B1u; + public const uint GetAndWieldItemOpcode = 0x001Au; + public const uint DropItemOpcode = 0x001Bu; + public const uint NoLongerViewingContentsOpcode = 0x0195u; /// /// Merge stack A into stack B of the same item type. Server validates @@ -133,4 +136,39 @@ public static class InventoryActions BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(12), poiId); return body; } + + /// Drop an item on the ground. ACE GameActionDropItem.Handle reads 1 u32. + public static byte[] BuildDropItem(uint seq, uint itemGuid) + { + byte[] body = new byte[16]; + BinaryPrimitives.WriteUInt32LittleEndian(body, GameActionEnvelope); + BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(4), seq); + BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(8), DropItemOpcode); + BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(12), itemGuid); + return body; + } + + /// Equip an item from inventory onto the doll. holtburger actions.rs + /// GetAndWieldItemActionData = (itemGuid, equipMask). + public static byte[] BuildGetAndWieldItem(uint seq, uint itemGuid, uint equipMask) + { + byte[] body = new byte[20]; + BinaryPrimitives.WriteUInt32LittleEndian(body, GameActionEnvelope); + BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(4), seq); + BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(8), GetAndWieldItemOpcode); + BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(12), itemGuid); + BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(16), equipMask); + return body; + } + + /// Close a side-pack / ground-container view. holtburger actions.rs = (containerGuid). + public static byte[] BuildNoLongerViewingContents(uint seq, uint containerGuid) + { + byte[] body = new byte[16]; + BinaryPrimitives.WriteUInt32LittleEndian(body, GameActionEnvelope); + BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(4), seq); + BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(8), NoLongerViewingContentsOpcode); + BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(12), containerGuid); + return body; + } } diff --git a/src/AcDream.Core.Net/WorldSession.cs b/src/AcDream.Core.Net/WorldSession.cs index 3f6dbbe2..d00edbd9 100644 --- a/src/AcDream.Core.Net/WorldSession.cs +++ b/src/AcDream.Core.Net/WorldSession.cs @@ -1156,6 +1156,27 @@ public sealed class WorldSession : IDisposable SendGameAction(InventoryActions.BuildRemoveShortcut(seq, index)); } + /// Send DropItem (0x001B) — drop an item on the ground. + public void SendDropItem(uint itemGuid) + { + uint seq = NextGameActionSequence(); + SendGameAction(InventoryActions.BuildDropItem(seq, itemGuid)); + } + + /// Send GetAndWieldItem (0x001A) — equip an item to an equip slot. + public void SendGetAndWieldItem(uint itemGuid, uint equipMask) + { + uint seq = NextGameActionSequence(); + SendGameAction(InventoryActions.BuildGetAndWieldItem(seq, itemGuid, equipMask)); + } + + /// Send NoLongerViewingContents (0x0195) — close a container view. + public void SendNoLongerViewingContents(uint containerGuid) + { + uint seq = NextGameActionSequence(); + SendGameAction(InventoryActions.BuildNoLongerViewingContents(seq, containerGuid)); + } + /// Send retail QueryHealth (0x01BF). Server replies UpdateHealth (0x01C0). /// /// Retail anchor: CM_Combat::Event_QueryHealth / gmToolbarUI::HandleSelectionChanged:198635 diff --git a/tests/AcDream.Core.Net.Tests/Messages/InventoryActionsTests.cs b/tests/AcDream.Core.Net.Tests/Messages/InventoryActionsTests.cs index 298448ae..750147d0 100644 --- a/tests/AcDream.Core.Net.Tests/Messages/InventoryActionsTests.cs +++ b/tests/AcDream.Core.Net.Tests/Messages/InventoryActionsTests.cs @@ -113,4 +113,38 @@ public sealed class InventoryActionsTests Assert.Equal(42u, BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(12))); } + + [Fact] + public void BuildDropItem_layout() + { + byte[] b = InventoryActions.BuildDropItem(seq: 7, itemGuid: 0x50000A01u); + Assert.Equal(16, b.Length); + Assert.Equal(0xF7B1u, BinaryPrimitives.ReadUInt32LittleEndian(b.AsSpan(0))); + Assert.Equal(7u, BinaryPrimitives.ReadUInt32LittleEndian(b.AsSpan(4))); + Assert.Equal(0x001Bu, BinaryPrimitives.ReadUInt32LittleEndian(b.AsSpan(8))); + Assert.Equal(0x50000A01u, BinaryPrimitives.ReadUInt32LittleEndian(b.AsSpan(12))); + } + + [Fact] + public void BuildGetAndWieldItem_layout() + { + byte[] b = InventoryActions.BuildGetAndWieldItem(seq: 7, itemGuid: 0x50000A01u, equipMask: 0x02u); + Assert.Equal(20, b.Length); + Assert.Equal(0xF7B1u, BinaryPrimitives.ReadUInt32LittleEndian(b.AsSpan(0))); + Assert.Equal(7u, BinaryPrimitives.ReadUInt32LittleEndian(b.AsSpan(4))); + Assert.Equal(0x001Au, BinaryPrimitives.ReadUInt32LittleEndian(b.AsSpan(8))); + Assert.Equal(0x50000A01u, BinaryPrimitives.ReadUInt32LittleEndian(b.AsSpan(12))); + Assert.Equal(0x02u, BinaryPrimitives.ReadUInt32LittleEndian(b.AsSpan(16))); + } + + [Fact] + public void BuildNoLongerViewingContents_layout() + { + byte[] b = InventoryActions.BuildNoLongerViewingContents(seq: 7, containerGuid: 0x500000C9u); + Assert.Equal(16, b.Length); + Assert.Equal(0xF7B1u, BinaryPrimitives.ReadUInt32LittleEndian(b.AsSpan(0))); + Assert.Equal(7u, BinaryPrimitives.ReadUInt32LittleEndian(b.AsSpan(4))); + Assert.Equal(0x0195u, BinaryPrimitives.ReadUInt32LittleEndian(b.AsSpan(8))); + Assert.Equal(0x500000C9u, BinaryPrimitives.ReadUInt32LittleEndian(b.AsSpan(12))); + } } From d97d84d7caff9a764ed43507d9999b2fcb165ce2 Mon Sep 17 00:00:00 2001 From: Erik Date: Sun, 21 Jun 2026 19:00:59 +0200 Subject: [PATCH 062/133] =?UTF-8?q?feat(net):=20D.2b-B=20B-Wire=20?= =?UTF-8?q?=E2=80=94=20PlayerDescription=20delivers=20player=20properties?= =?UTF-8?q?=20to=20ClientObject?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add optional Func? playerGuid parameter (last in WireAll signature so all existing callers compile unchanged). When provided, the PD handler calls items.UpsertProperties(playerGuid(), p.Value.Properties) immediately after the null-guard, landing EncumbranceVal (PropertyInt 5) and other player stats into the player ClientObject. Upsert (create-if-absent) handles PD arriving before the player's CreateObject. Retires AP-48/AP-49 divergence rows (wired in Task 16). Co-Authored-By: Claude Opus 4.8 (1M context) --- src/AcDream.Core.Net/GameEventWiring.cs | 12 +++++- .../GameEventWiringTests.cs | 39 +++++++++++++++++++ 2 files changed, 50 insertions(+), 1 deletion(-) diff --git a/src/AcDream.Core.Net/GameEventWiring.cs b/src/AcDream.Core.Net/GameEventWiring.cs index dba723c6..0413611d 100644 --- a/src/AcDream.Core.Net/GameEventWiring.cs +++ b/src/AcDream.Core.Net/GameEventWiring.cs @@ -65,7 +65,10 @@ public static class GameEventWiring // D.5.1 Task 4: persists Shortcuts from each PlayerDescription so the // toolbar can populate itself at login without keeping a parser reference. // Optional so all existing callers and tests compile unchanged. - Action>? onShortcuts = null) + Action>? onShortcuts = null, + // B-Wire: the local player's server guid. When provided, the PD handler upserts + // the player's own PropertyBundle (EncumbranceVal etc.) into the player ClientObject. + Func? playerGuid = null) { ArgumentNullException.ThrowIfNull(dispatcher); ArgumentNullException.ThrowIfNull(items); @@ -289,6 +292,13 @@ public static class GameEventWiring Console.WriteLine($"vitals: PlayerDescription body.len={e.Payload.Length} parsed={(p is null ? "NULL" : $"vec={p.Value.VectorFlags} attrs={p.Value.Attributes.Count} spells={p.Value.Spells.Count}")}"); if (p is null) return; + // B-Wire: deliver the player's OWN properties to the player ClientObject. + // (PD's "membership manifest" rule is about ITEMS, whose data comes from + // CreateObject; the player's own stats legitimately come from PD.) Upsert + // because PD can arrive before the player's CreateObject. Retires AP-48/AP-49. + if (playerGuid is not null) + items.UpsertProperties(playerGuid(), p.Value.Properties); + // K-fix13 (2026-04-26): build attrId → current map while // iterating attributes so the skill-formula resolver below // can apply (attr1.current * mult1 + attr2.current * mult2) diff --git a/tests/AcDream.Core.Net.Tests/GameEventWiringTests.cs b/tests/AcDream.Core.Net.Tests/GameEventWiringTests.cs index d0a92463..ca0a54db 100644 --- a/tests/AcDream.Core.Net.Tests/GameEventWiringTests.cs +++ b/tests/AcDream.Core.Net.Tests/GameEventWiringTests.cs @@ -496,4 +496,43 @@ public sealed class GameEventWiringTests Assert.Equal(0x5001u, got![0].ObjectGuid); } + [Fact] + public void WireAll_PlayerDescription_UpsertsPlayerPropertiesIntoClientObject() + { + // PD with a PropertyInt32 table carrying EncumbranceVal(5)=1500. The handler + // must land it in the player ClientObject so the burden bar reads the wire value. + const uint playerGuid = 0x50000001u; + var dispatcher = new GameEventDispatcher(); + var items = new ClientObjectTable(); + GameEventWiring.WireAll(dispatcher, items, new CombatState(), new Spellbook(), + new ChatLog(), playerGuid: () => playerGuid); + + var sb = new MemoryStream(); + using var w = new BinaryWriter(sb); + w.Write(0x00000001u); // propertyFlags = PropertyInt32 + w.Write(0x52u); // weenieType + // int table: u16 count, u16 buckets, then key/val pairs + w.Write((ushort)1); // count + w.Write((ushort)8); // buckets (ignored) + w.Write(5u); // key = EncumbranceVal + w.Write(1500u); // val + // vector + has_health (no vector blocks) + w.Write(0u); // vectorFlags = None + w.Write(0u); // has_health + // strict trailer + w.Write(0u); // option_flags = None + w.Write(0u); // options1 + w.Write(0u); // legacy hotbar count + w.Write(0u); // spellbook_filters + w.Write(0u); // inventory count + w.Write(0u); // equipped count + + var env = GameEventEnvelope.TryParse(WrapEnvelope(GameEventType.PlayerDescription, sb.ToArray())); + dispatcher.Dispatch(env!.Value); + + var player = items.Get(playerGuid); + Assert.NotNull(player); + Assert.Equal(1500, player!.Properties.Ints[5]); + } + } From 3a9c18823324d1d05704ab8d4f78d12d53dd829c Mon Sep 17 00:00:00 2001 From: Erik Date: Sun, 21 Jun 2026 19:01:53 +0200 Subject: [PATCH 063/133] =?UTF-8?q?feat(net):=20D.2b-B=20B-Wire=20?= =?UTF-8?q?=E2=80=94=20register=20ViewContents=20+=20InventoryPutObjectIn3?= =?UTF-8?q?D/SaveFailed/CloseGroundContainer?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Register four previously-unwired GameEvent handlers after InventoryPutObjInContainer: - ViewContents (0x0196): iterates ParseViewContents entries and calls items.RecordMembership(entry.Guid, containerId) for each — so the object table is correct before the container-open UI mounts. - InventoryPutObjectIn3D (0x019A): calls items.MoveItem(guid, 0u) to unparent a dropped item from its container (it's now a ground object). - InventoryServerSaveFailed (0x00A0): parse + log; rollback behavior deferred to B-Drag (acdream has no speculative moves yet). - CloseGroundContainer (0x0052): parse + log; no table change needed (the container-open view is UI-only). Two dispatcher tests cover ViewContents membership and Put3D unparenting. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/AcDream.Core.Net/GameEventWiring.cs | 40 +++++++++++++++++++ .../GameEventWiringTests.cs | 35 ++++++++++++++++ 2 files changed, 75 insertions(+) diff --git a/src/AcDream.Core.Net/GameEventWiring.cs b/src/AcDream.Core.Net/GameEventWiring.cs index 0413611d..6676f0a1 100644 --- a/src/AcDream.Core.Net/GameEventWiring.cs +++ b/src/AcDream.Core.Net/GameEventWiring.cs @@ -249,6 +249,46 @@ public static class GameEventWiring if (p is not null) items.MoveItem(p.Value.ItemGuid, p.Value.ContainerGuid, newSlot: (int)p.Value.Placement); }); + + // B-Wire: ViewContents (0x0196) — the server's full contents list for a + // container you opened. Record membership for each entry so the table is + // correct before the container-open UI mounts the second item list. + dispatcher.Register(GameEventType.ViewContents, e => + { + var p = GameEvents.ParseViewContents(e.Payload.Span); + if (p is null) return; + foreach (var entry in p.Value.Items) + items.RecordMembership(entry.Guid, containerId: p.Value.ContainerGuid); + }); + + // B-Wire: InventoryPutObjectIn3D (0x019A) — server confirms an item dropped + // to the world. Unparent it from its container (it's now a ground object) so + // the inventory grid drops the cell; the object itself survives. + dispatcher.Register(GameEventType.InventoryPutObjectIn3D, e => + { + var guid = GameEvents.ParsePutObjectIn3D(e.Payload.Span); + if (guid is not null) items.MoveItem(guid.Value, newContainerId: 0u); + }); + + // B-Wire: InventoryServerSaveFailed (0x00A0) — rollback of a speculative move. + // acdream does not do speculative moves yet (read-only inventory); parse + log + // so the wire is correct. B-Drag wires the rollback behavior. (Divergence note.) + dispatcher.Register(GameEventType.InventoryServerSaveFailed, e => + { + var p = GameEvents.ParseInventoryServerSaveFailed(e.Payload.Span); + if (p is not null) + Console.WriteLine($"[B-Wire] InventoryServerSaveFailed guid=0x{p.Value.ItemGuid:X8} err=0x{p.Value.WeenieError:X}"); + }); + + // B-Wire: CloseGroundContainer (0x0052) — server closed a ground-container + // view. No table change (the view is UI-only, wired in container-open); log. + dispatcher.Register(GameEventType.CloseGroundContainer, e => + { + var guid = GameEvents.ParseCloseGroundContainer(e.Payload.Span); + if (guid is not null) + Console.WriteLine($"[B-Wire] CloseGroundContainer guid=0x{guid.Value:X8}"); + }); + dispatcher.Register(GameEventType.IdentifyObjectResponse, e => { var p = AppraiseInfoParser.TryParse(e.Payload.Span); diff --git a/tests/AcDream.Core.Net.Tests/GameEventWiringTests.cs b/tests/AcDream.Core.Net.Tests/GameEventWiringTests.cs index ca0a54db..96ae5a54 100644 --- a/tests/AcDream.Core.Net.Tests/GameEventWiringTests.cs +++ b/tests/AcDream.Core.Net.Tests/GameEventWiringTests.cs @@ -535,4 +535,39 @@ public sealed class GameEventWiringTests Assert.Equal(1500, player!.Properties.Ints[5]); } + [Fact] + public void WireAll_ViewContents_RecordsMembershipInClientObjectTable() + { + var (d, items, _, _, _) = MakeAll(); + + // containerGuid 0xC9 + 2 entries + byte[] payload = new byte[8 + 2 * 8]; + BinaryPrimitives.WriteUInt32LittleEndian(payload.AsSpan(0), 0x500000C9u); + BinaryPrimitives.WriteUInt32LittleEndian(payload.AsSpan(4), 2u); + BinaryPrimitives.WriteUInt32LittleEndian(payload.AsSpan(8), 0x50000A01u); + BinaryPrimitives.WriteUInt32LittleEndian(payload.AsSpan(12), 0u); + BinaryPrimitives.WriteUInt32LittleEndian(payload.AsSpan(16), 0x50000A02u); + BinaryPrimitives.WriteUInt32LittleEndian(payload.AsSpan(20), 1u); + + var env = GameEventEnvelope.TryParse(WrapEnvelope(GameEventType.ViewContents, payload)); + d.Dispatch(env!.Value); + + Assert.Equal(0x500000C9u, items.Get(0x50000A01u)!.ContainerId); + Assert.Equal(0x500000C9u, items.Get(0x50000A02u)!.ContainerId); + } + + [Fact] + public void WireAll_InventoryPutObjectIn3D_UnparentsFromContainer() + { + var (d, items, _, _, _) = MakeAll(); + items.RecordMembership(0x50000A01u, containerId: 0x500000C9u); + + byte[] payload = new byte[4]; + BinaryPrimitives.WriteUInt32LittleEndian(payload, 0x50000A01u); + var env = GameEventEnvelope.TryParse(WrapEnvelope(GameEventType.InventoryPutObjectIn3D, payload)); + d.Dispatch(env!.Value); + + Assert.Equal(0u, items.Get(0x50000A01u)!.ContainerId); + } + } From 672df23010fcf8ebe54107e6621a32a2b3d588fe Mon Sep 17 00:00:00 2001 From: Erik Date: Sun, 21 Jun 2026 19:04:22 +0200 Subject: [PATCH 064/133] =?UTF-8?q?feat(net):=20D.2b-B=20B-Wire=20?= =?UTF-8?q?=E2=80=94=20WorldSession=20dispatch=20for=200x02CD/SetStackSize?= =?UTF-8?q?/InventoryRemoveObject?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add 3 new events (PlayerIntPropertyUpdated, StackSizeUpdated, InventoryObjectRemoved) and their payload record types near the existing ObjectIntPropertyUpdated event. Add 3 switch cases in the GameMessage dispatcher immediately after the PublicUpdatePropertyInt (0x02CE) branch for: - PrivateUpdatePropertyInt (0x02CD) — no-guid player-own property - SetStackSize (0x0197) — stack count + value update - InventoryRemoveObject (0x0024) — remove from inventory view No test seam on the switch; verified by build + parser tests (Tasks 3/4/5) + live run per codebase convention (see ObjectTableWiringTests comment). Co-Authored-By: Claude Opus 4.8 (1M context) --- src/AcDream.Core.Net/WorldSession.cs | 38 ++++++++++++++++++++++++++++ 1 file changed, 38 insertions(+) diff --git a/src/AcDream.Core.Net/WorldSession.cs b/src/AcDream.Core.Net/WorldSession.cs index d00edbd9..a904140c 100644 --- a/src/AcDream.Core.Net/WorldSession.cs +++ b/src/AcDream.Core.Net/WorldSession.cs @@ -195,6 +195,25 @@ public sealed class WorldSession : IDisposable /// public event Action? ObjectIntPropertyUpdated; + /// Payload for : a PropertyInt change on + /// the player's OWN object (from PrivateUpdatePropertyInt 0x02CD — no guid on the wire). + public readonly record struct PlayerIntPropertyUpdate(uint Property, int Value); + + /// Fires when the session parses a PrivateUpdatePropertyInt (0x02CD) — one + /// PropertyInt updated on the player. B-Wire routes EncumbranceVal (5) to the burden bar. + public event Action? PlayerIntPropertyUpdated; + + /// Payload for : SetStackSize (0x0197) — a stack's + /// count + value after a merge / split. + public readonly record struct StackSizeUpdate(uint Guid, int StackSize, int Value); + + /// Fires when the session parses a SetStackSize (0x0197) top-level GameMessage. + public event Action? StackSizeUpdated; + + /// Fires when the session parses an InventoryRemoveObject (0x0024) — the guid left + /// the player's inventory view. + public event Action? InventoryObjectRemoved; + /// /// Fires when the server sends a PlayerTeleport (0xF751) game message, /// signalling that the player is entering portal space. The uint payload @@ -958,6 +977,25 @@ public sealed class WorldSession : IDisposable ObjectIntPropertyUpdated?.Invoke( new ObjectIntPropertyUpdate(p.Value.Guid, p.Value.Property, p.Value.Value)); } + else if (op == PrivateUpdatePropertyInt.Opcode) + { + var p = PrivateUpdatePropertyInt.TryParse(body); + if (p is not null) + PlayerIntPropertyUpdated?.Invoke( + new PlayerIntPropertyUpdate(p.Value.Property, p.Value.Value)); + } + else if (op == SetStackSize.Opcode) + { + var p = SetStackSize.TryParse(body); + if (p is not null) + StackSizeUpdated?.Invoke( + new StackSizeUpdate(p.Value.Guid, p.Value.StackSize, p.Value.Value)); + } + else if (op == InventoryRemoveObject.Opcode) + { + var p = InventoryRemoveObject.TryParse(body); + if (p is not null) InventoryObjectRemoved?.Invoke(p.Value.Guid); + } else if (op == GameEventEnvelope.Opcode) { // Phase F.1: 0xF7B0 is the GameEvent envelope. Parse the From 275319461ed0dc7b9695e2e395f9acf144eff85f Mon Sep 17 00:00:00 2001 From: Erik Date: Sun, 21 Jun 2026 19:05:00 +0200 Subject: [PATCH 065/133] =?UTF-8?q?feat(net):=20D.2b-B=20B-Wire=20?= =?UTF-8?q?=E2=80=94=20ObjectTableWiring=20applies=20all=20ints=20+=20play?= =?UTF-8?q?er=20int=20+=20stack/remove?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Wire signature gains optional 3rd param `Func? playerGuid` (existing GameWindow caller `Wire(session, Objects)` still compiles — default null). - ObjectIntPropertyUpdated gate loosened: was UiEffects-only, now applies ALL PropertyInt updates on visible objects (server is the authority on object props). - PlayerIntPropertyUpdated → UpdateIntProperty(playerGuid(), ...) so live EncumbranceVal (0x02CD) updates the player's burden bar. - StackSizeUpdated → UpdateStackSize(guid, stackSize, value). - InventoryObjectRemoved → Remove(guid). - Updated class doc-comment to list all 5 wired opcodes. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/AcDream.Core.Net/ObjectTableWiring.cs | 27 +++++++++++++++++++---- 1 file changed, 23 insertions(+), 4 deletions(-) diff --git a/src/AcDream.Core.Net/ObjectTableWiring.cs b/src/AcDream.Core.Net/ObjectTableWiring.cs index 463d7d9e..c94c343d 100644 --- a/src/AcDream.Core.Net/ObjectTableWiring.cs +++ b/src/AcDream.Core.Net/ObjectTableWiring.cs @@ -5,7 +5,9 @@ namespace AcDream.Core.Net; /// /// Wires WorldSession GameMessage-level object events into the client object /// table: CreateObject (0xF745) = canonical merge-upsert, DeleteObject (0xF747) -/// = evict, PublicUpdatePropertyInt (0x02CE) UiEffects = live icon re-composite. +/// = evict, PublicUpdatePropertyInt (0x02CE) = live int apply, PrivateUpdatePropertyInt +/// (0x02CD) = player int (burden), SetStackSize (0x0197) = stack count, InventoryRemoveObject +/// (0x0024) = inventory-view removal. /// Keeps object ingestion in Core.Net (pure data, no GL) and off GameWindow. /// Retail: ACCObjectMaint::CreateObject / DeleteObject (the weenie_object_table side). /// @@ -16,18 +18,35 @@ public static class ObjectTableWiring /// on . Call this BEFORE the render handler subscribes /// to EntitySpawned so the table is populated before the render path runs. /// - public static void Wire(WorldSession session, ClientObjectTable table) + public static void Wire(WorldSession session, ClientObjectTable table, Func? playerGuid = null) { ArgumentNullException.ThrowIfNull(session); ArgumentNullException.ThrowIfNull(table); session.EntitySpawned += s => table.Ingest(ToWeenieData(s)); session.EntityDeleted += d => table.Remove(d.Guid); + + // B-Wire: apply EVERY PropertyInt update on a visible object (0x02CE), not just + // UiEffects — the server is the authority on object properties. UpdateIntProperty + // stores it in the bundle and still mirrors UiEffects → the typed Effects field. session.ObjectIntPropertyUpdated += u => + table.UpdateIntProperty(u.Guid, u.Property, u.Value); + + // B-Wire: PrivateUpdatePropertyInt (0x02CD) carries no guid — it targets the + // local player. Route it to the player object so live EncumbranceVal updates the + // burden bar. (No-op if the player guid isn't wired yet.) + session.PlayerIntPropertyUpdated += u => { - if (u.Property == ClientObjectTable.UiEffectsPropertyId) - table.UpdateIntProperty(u.Guid, u.Property, u.Value); + if (playerGuid is not null) + table.UpdateIntProperty(playerGuid(), u.Property, u.Value); }; + + // B-Wire: SetStackSize (0x0197) — update the object's stack count + value. + session.StackSizeUpdated += u => table.UpdateStackSize(u.Guid, u.StackSize, u.Value); + + // B-Wire: InventoryRemoveObject (0x0024) — the object left the player's inventory + // view; drop it from the table (retail ClientUISystem removes it from maintenance). + session.InventoryObjectRemoved += guid => table.Remove(guid); } /// Translate the wire spawn into the table's merge patch. From a69c733e6e3073086ecda51f392963be9c917f7f Mon Sep 17 00:00:00 2001 From: Erik Date: Sun, 21 Jun 2026 19:06:05 +0200 Subject: [PATCH 066/133] fix(test): update AppraiseTests.ParsePutObjInContainer_RoundTrip for 4-field parser Task 7 extended ParsePutObjInContainer to require 16 bytes (added ContainerType as the 4th field). The pre-existing AppraiseTests round-trip test built only 12 bytes (old 3-field layout), so it returned null and failed. Update the test to supply 16 bytes and also assert ContainerType. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../AcDream.Core.Net.Tests/Messages/AppraiseTests.cs | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/tests/AcDream.Core.Net.Tests/Messages/AppraiseTests.cs b/tests/AcDream.Core.Net.Tests/Messages/AppraiseTests.cs index 79dcd1d4..9977a71f 100644 --- a/tests/AcDream.Core.Net.Tests/Messages/AppraiseTests.cs +++ b/tests/AcDream.Core.Net.Tests/Messages/AppraiseTests.cs @@ -56,15 +56,18 @@ public sealed class AppraiseTests [Fact] public void ParsePutObjInContainer_RoundTrip() { - byte[] payload = new byte[12]; - BinaryPrimitives.WriteUInt32LittleEndian(payload, 0x1001u); - BinaryPrimitives.WriteUInt32LittleEndian(payload.AsSpan(4), 0x2001u); - BinaryPrimitives.WriteUInt32LittleEndian(payload.AsSpan(8), 3u); + // 4 fields (Task 7 fix: containerType added) — 16 bytes required. + byte[] payload = new byte[16]; + BinaryPrimitives.WriteUInt32LittleEndian(payload, 0x1001u); + BinaryPrimitives.WriteUInt32LittleEndian(payload.AsSpan(4), 0x2001u); + BinaryPrimitives.WriteUInt32LittleEndian(payload.AsSpan(8), 3u); + BinaryPrimitives.WriteUInt32LittleEndian(payload.AsSpan(12), 1u); // containerType var parsed = GameEvents.ParsePutObjInContainer(payload); Assert.NotNull(parsed); Assert.Equal(0x1001u, parsed!.Value.ItemGuid); Assert.Equal(0x2001u, parsed.Value.ContainerGuid); Assert.Equal(3u, parsed.Value.Placement); + Assert.Equal(1u, parsed.Value.ContainerType); } } From 102c46c8e3263da996f1eb2aa9f7e9b235948a95 Mon Sep 17 00:00:00 2001 From: Erik Date: Sun, 21 Jun 2026 19:08:07 +0200 Subject: [PATCH 067/133] =?UTF-8?q?feat(app):=20D.2b-B=20B-Wire=20?= =?UTF-8?q?=E2=80=94=20wire=20player=20guid=20into=20ObjectTableWiring=20+?= =?UTF-8?q?=20GameEventWiring?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pass () => _playerServerGuid to ObjectTableWiring.Wire and GameEventWiring.WireAll so the new Batch 5/6 playerGuid parameters are populated. These were previously left as null (default), meaning PD property upserts and PrivateUpdatePropertyInt delivery would not route to the correct player object. Task 14 of the B-Wire plan. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/AcDream.App/Rendering/GameWindow.cs | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs index 085f26af..a2832cc3 100644 --- a/src/AcDream.App/Rendering/GameWindow.cs +++ b/src/AcDream.App/Rendering/GameWindow.cs @@ -2468,7 +2468,7 @@ public sealed class GameWindow : IDisposable // D.5.4: ingest CreateObject into the object table (upsert) and wire Delete + // UiEffects live update. Wire BEFORE EntitySpawned += OnLiveEntitySpawned so // the table is populated before the render handler runs. - AcDream.Core.Net.ObjectTableWiring.Wire(session, Objects); + AcDream.Core.Net.ObjectTableWiring.Wire(session, Objects, () => _playerServerGuid); _liveSession.EntitySpawned += OnLiveEntitySpawned; _liveSession.EntityDeleted += OnLiveEntityDeleted; _liveSession.MotionUpdated += OnLiveMotionUpdated; @@ -2566,7 +2566,8 @@ public sealed class GameWindow : IDisposable Console.WriteLine($"player: applied server skills run={_lastSeenRunSkill} jump={_lastSeenJumpSkill}"); } }, - onShortcuts: list => Shortcuts = list); + onShortcuts: list => Shortcuts = list, + playerGuid: () => _playerServerGuid); // Phase I.7: subscribe to CombatState events and emit // retail-faithful "You hit X for Y damage" chat lines into From aa0ecaeb4d64325f11c4849840abcbd13f24bc90 Mon Sep 17 00:00:00 2001 From: Erik Date: Sun, 21 Jun 2026 19:08:26 +0200 Subject: [PATCH 068/133] =?UTF-8?q?fix(app):=20D.2b-B=20B-Wire=20=E2=80=94?= =?UTF-8?q?=20burden=20bar=20refreshes=20on=20player-object=20property=20u?= =?UTF-8?q?pdate?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit InventoryController.Concerns() now returns true when the updated object IS the player object itself (o.ObjectId == playerGuid). Previously the method only triggered a repopulate for objects that the player *contains* or *wields*, but the player's own ClientObject is the carrier of EncumbranceVal — so a live PrivateUpdatePropertyInt for burden would be silently ignored. Task 15 of the B-Wire plan. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/AcDream.App/UI/Layout/InventoryController.cs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/AcDream.App/UI/Layout/InventoryController.cs b/src/AcDream.App/UI/Layout/InventoryController.cs index f051bb13..41fd325a 100644 --- a/src/AcDream.App/UI/Layout/InventoryController.cs +++ b/src/AcDream.App/UI/Layout/InventoryController.cs @@ -115,7 +115,8 @@ public sealed class InventoryController private bool Concerns(ClientObject o) { uint p = _playerGuid(); - return o.ContainerId == p || o.WielderId == p + return o.ObjectId == p // B-Wire: the player object IS the burden source + || o.ContainerId == p || o.WielderId == p || (o.ContainerId != 0 && _objects.Get(o.ContainerId)?.ContainerId == p); // 2-deep } From 7badecf387a6aa87996569a15d4eb371810339d6 Mon Sep 17 00:00:00 2001 From: Erik Date: Sun, 21 Jun 2026 19:19:13 +0200 Subject: [PATCH 069/133] =?UTF-8?q?test(app):=20D.2b-B=20B-Wire=20?= =?UTF-8?q?=E2=80=94=20burden=20wire-first=20+=20live-refresh=20tests=20+?= =?UTF-8?q?=20review=20notes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Final-review response. Adds the spec §5 / Task-16 burden end-to-end coverage the per-task plan missed: (1) burden reads wire EncumbranceVal over the carried sum (asserts 50% from wire 7500, not 20% from sum 3000 — retires AP-48's fallback as the primary), (2) a live player-int update repaints the bar (60%), which only happens via the C1d Concerns `o.ObjectId == p` branch. Both are discriminating (fail if the respective branch is reverted). Plus two clarifying comments from the review: the 0x02CD player route depends on the login PD upsert having created the player object (no-ops, no phantom, if not); and a TODO that the container-open phase must treat ViewContents as a full replace, not the additive merge used here. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/AcDream.Core.Net/GameEventWiring.cs | 4 ++ src/AcDream.Core.Net/ObjectTableWiring.cs | 5 ++- .../UI/Layout/InventoryControllerTests.cs | 40 +++++++++++++++++++ 3 files changed, 48 insertions(+), 1 deletion(-) diff --git a/src/AcDream.Core.Net/GameEventWiring.cs b/src/AcDream.Core.Net/GameEventWiring.cs index 6676f0a1..c9aec8a5 100644 --- a/src/AcDream.Core.Net/GameEventWiring.cs +++ b/src/AcDream.Core.Net/GameEventWiring.cs @@ -253,6 +253,10 @@ public static class GameEventWiring // B-Wire: ViewContents (0x0196) — the server's full contents list for a // container you opened. Record membership for each entry so the table is // correct before the container-open UI mounts the second item list. + // TODO(container-open): ViewContents is the AUTHORITATIVE full snapshot. When the + // container-open phase consumes it, treat it as a full REPLACE (flush the container's + // prior membership first), not the additive merge below — otherwise items removed + // server-side since the last open would linger. dispatcher.Register(GameEventType.ViewContents, e => { var p = GameEvents.ParseViewContents(e.Payload.Span); diff --git a/src/AcDream.Core.Net/ObjectTableWiring.cs b/src/AcDream.Core.Net/ObjectTableWiring.cs index c94c343d..ca21c79a 100644 --- a/src/AcDream.Core.Net/ObjectTableWiring.cs +++ b/src/AcDream.Core.Net/ObjectTableWiring.cs @@ -34,7 +34,10 @@ public static class ObjectTableWiring // B-Wire: PrivateUpdatePropertyInt (0x02CD) carries no guid — it targets the // local player. Route it to the player object so live EncumbranceVal updates the - // burden bar. (No-op if the player guid isn't wired yet.) + // burden bar. The player ClientObject is created at login by the PD UpsertProperties + // call (which precedes any live 0x02CD), so UpdateIntProperty finds it. If it somehow + // hasn't yet, this no-ops (UpdateIntProperty returns false on an unknown guid) rather + // than creating a phantom — the next PD / CreateObject seeds it. session.PlayerIntPropertyUpdated += u => { if (playerGuid is not null) diff --git a/tests/AcDream.App.Tests/UI/Layout/InventoryControllerTests.cs b/tests/AcDream.App.Tests/UI/Layout/InventoryControllerTests.cs index 7da7e627..d1fb4746 100644 --- a/tests/AcDream.App.Tests/UI/Layout/InventoryControllerTests.cs +++ b/tests/AcDream.App.Tests/UI/Layout/InventoryControllerTests.cs @@ -152,6 +152,46 @@ public class InventoryControllerTests Assert.Contains("Contents of Backpack", CaptionText(contentsCap)); } + [Fact] + public void Burden_reads_wire_EncumbranceVal_over_carried_sum() + { + // B-Wire: the burden bar must read the server's wire EncumbranceVal (PropertyInt 5), + // NOT the client-side carried-Burden sum (retires AP-48 — the sum is only the fallback). + var (layout, _, _, _, meter, burdenText, _, _) = BuildLayout(); + var objects = new ClientObjectTable(); + // Str 100 → capacity 15000. A carried item sums to 3000 (would read 20%), but the + // server says EncumbranceVal = 7500 → load 0.5 → fill 0.1667 → "50%". Wire wins. + objects.AddOrUpdate(new ClientObject { ObjectId = 0xA, ContainerId = Player, Burden = 3000 }); + var bundle = new PropertyBundle(); + bundle.Ints[5] = 7500; // EncumbranceVal (PropertyInt 5) + objects.UpsertProperties(Player, bundle); + + Bind(layout, objects, strength: 100); + + Assert.Equal(0.16667f, meter.Fill() ?? -1f, 3); // 7500/15000/3 (wire), not 3000-based 0.0667 + Assert.Contains("50%", CaptionText(burdenText)); // not "20%" + } + + [Fact] + public void Live_player_int_update_refreshes_burden() + { + // B-Wire C1d: a live PrivateUpdatePropertyInt (0x02CD) for the player's EncumbranceVal + // fires ObjectUpdated on the PLAYER object; Concerns now includes o.ObjectId == player, + // so the burden bar repaints. Without the C1d branch this update would be ignored. + var (layout, _, _, _, meter, burdenText, _, _) = BuildLayout(); + var objects = new ClientObjectTable(); + var bundle = new PropertyBundle(); + bundle.Ints[5] = 3000; // initial EncumbranceVal → load 0.2 → "20%" + objects.UpsertProperties(Player, bundle); + Bind(layout, objects, strength: 100); + Assert.Contains("20%", CaptionText(burdenText)); + + objects.UpdateIntProperty(Player, 5u, 9000); // live 0x02CD: → load 0.6 → "60%" + + Assert.Contains("60%", CaptionText(burdenText)); + Assert.Equal(0.2f, meter.Fill() ?? -1f, 3); // 9000/15000/3 + } + // Reads the text of the UiText caption child attached by the controller. private static string CaptionText(UiElement host) { From 7c006d103a34243b91acb5d2fac55e5914f4efae Mon Sep 17 00:00:00 2001 From: Erik Date: Sun, 21 Jun 2026 19:24:25 +0200 Subject: [PATCH 070/133] =?UTF-8?q?docs(D.2b-B):=20B-Wire=20shipped=20?= =?UTF-8?q?=E2=80=94=20AP-48/AP-49=20fallback-only=20+=20ISSUES=20entry?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reword AP-48 (burden) and AP-49 (carry-aug) to fallback/default-only: B-Wire ports the retail wire read (login PD-bundle UpsertProperties + live 0x02CD; ObjectTableWiring applies all ints), so SumCarriedBurden / aug=0 are now defensive only. Confirm the server sends EncumbranceVal/0xE6 at the visual gate, then delete the rows. Add the D.2b-B B-Wire SHIPPED entry to ISSUES. (Memory digest project_d2b_retail_ui.md + MEMORY.md updated out-of-tree.) Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/ISSUES.md | 9 +++++++++ docs/architecture/retail-divergence-register.md | 4 ++-- 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/docs/ISSUES.md b/docs/ISSUES.md index 0f100722..20b08888 100644 --- a/docs/ISSUES.md +++ b/docs/ISSUES.md @@ -5344,6 +5344,15 @@ outdoors at the angle that previously erased it. # Recently closed +## D.2b-B — Inventory wire layer (B-Wire): player-property delivery + builders/parsers SHIPPED + +**Closed:** 2026-06-21 +**Component:** net/ui — D.2b inventory wire (player props + inventory GameMessages/GameActions) + +The burden bar now reads the server's wire `EncumbranceVal` (PropertyInt 5) instead of the client-side sum. **Root cause was delivery, not binding** — the binding already read `Properties.Ints[5]`, but the value never arrived: login PD parsed the player's int table then dropped it; live `PrivateUpdatePropertyInt 0x02CD` was unparsed; `ObjectTableWiring` gated all non-UiEffects ints out. Fixes: `ClientObjectTable.UpsertProperties` (create-if-absent) + the PD handler upserts the player's `PropertyBundle`; new `PrivateUpdatePropertyInt 0x02CD` parser + WorldSession dispatch + player-int route; loosened the int-apply gate to apply ALL ints; `InventoryController.Concerns` refreshes on the player's own object (C1d). Plus latent-bug fixes (`0x0022` 4th field `containerType`; `0x00A0` `weenieError`), new builders (`DropItem 0x001B`, `GetAndWieldItem 0x001A`, `NoLongerViewingContents 0x0195`) + `Send*` wrappers, new parsers (`ViewContents 0x0196`, `SetStackSize 0x0197`, `InventoryRemoveObject 0x0024`) + GameEvent registration. Spec/plan `docs/superpowers/{specs,plans}/2026-06-21-d2b-inventory-wire*.md`. Commits `b56087b`→`7badecf` (build + full suite green: Core.Net 334 / App 534 / UI 425 / Core 1530; Opus phase-boundary review APPROVED with byte-for-byte ACE verification). AP-48/AP-49 reworded to fallback-only (confirm + delete at the visual gate). **Pending the burden-bar visual gate** (does the live bar match ACE + update on pick-up/drop?). Remaining D.2b inventory: contents-grid scroll polish (below), B-Drag (inventory drag SOURCE + `SourceKind==Inventory`), Sub-phase C (paperdoll). + +--- + ## D.2b-B — Inventory controller (B-Controller): grid population + burden meter + captions SHIPPED **Closed:** 2026-06-21 diff --git a/docs/architecture/retail-divergence-register.md b/docs/architecture/retail-divergence-register.md index b56d55c5..55dde69b 100644 --- a/docs/architecture/retail-divergence-register.md +++ b/docs/architecture/retail-divergence-register.md @@ -147,8 +147,8 @@ accepted-divergence entries (#96, #49, #50). | AP-45 | `PublicUpdatePropertyInt (0x02CE)` sequence byte parsed-past but not honored; last update wins (no freshness check against sequence number) | `src/AcDream.Core.Net/Messages/PublicUpdatePropertyInt.cs` | Loopback ACE rarely reorders; latest-wins matches `PrivateUpdateVital`/`UpdatePosition`'s existing non-sequence behavior. Sequence tracking added when needed alongside TS-26. | A reordered 0x02CE on a real network could apply a stale UiEffects value — item icon temporarily shows the wrong effect state, corrected on next update | `PublicUpdatePropertyInt` sequence byte (ACE GameMessagePublicUpdatePropertyInt) | | AP-46 | Health-meter gate approximation: retail shows the health meter for `IsPlayer() || pet_owner || ClientCombatSystem::ObjectIsAttackable()` (full PK/faction logic); acdream's `GameWindow.IsHealthBarTarget` uses the server PWD bits `BF_ATTACKABLE (0x10)` OR `BF_PLAYER (0x8)` | `src/AcDream.App/Rendering/GameWindow.cs` (`IsHealthBarTarget`) → `SelectedObjectController` | The PWD `BF_ATTACKABLE`/`BF_PLAYER` bits distinguish monsters + players (bar) from friendly/vendor NPCs (name-only) for the M1.5 dev loop; the pet case and the full ObjectIsAttackable PK/faction refinement (free-PK, PK-vs-PK, PKLite) are not ported | A PK/faction edge (e.g. a hostile-flagged player whose `BF_ATTACKABLE` is unset, or a pet) could show/hide the bar where retail differs — no impact on the non-PK PvE dev loop | `ClientCombatSystem::ObjectIsAttackable` acclient_2013_pseudo_c.txt:375385; `BF_ATTACKABLE` acclient.h:6437 | | AP-47 | Cursor drag ghost reuses the full composited `m_pIcon` (incl. type-default underlay) instead of retail's dedicated `m_pDragIcon` (base + custom-overlay, NO type-default underlay). Opacity now matches retail (full). | `src/AcDream.App/UI/UiRoot.cs` (`DrawDragGhost`/`GhostAlpha`) → `src/AcDream.App/UI/UiItemSlot.cs` (`GetDragGhost`) | Cosmetic only — the dragged item is still unambiguously identifiable; building the second underlay-less composite is deferred polish; the ghost is item-agnostic in UiRoot via `GetDragGhost()` | The ghost carries the opaque type-default underlay backing rather than retail's underlay-less copy — a subtle look difference while dragging, no functional effect. | `IconData::RenderIcons` acclient_2013_pseudo_c.txt:407594-407625 (m_pDragIcon path); deep-dive §3.2/§5.5 | -| AP-48 | 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` (decomp 0x0058f130). | `src/AcDream.App/UI/Layout/InventoryController.cs` (`RefreshBurden`) | The wire `EncumbranceVal` is not yet parsed from the server; the carried-Burden sum is the nearest client-side fallback and is correct for items in the simple pack-only case. Retire when B-Wire parses PropertyInt 5. | Drift from the server `EncumbranceVal` either way (untracked items read low; double-tracked or stale-but-present items read high) — e.g. items inside side bags that arrived before their parent (see TS-32) — until B-Wire lands. | `CACQualities::InqLoad` 0x0058f130; ACE PropertyInt.EncumbranceVal=5 | -| AP-49 | Carry-capacity augmentation (PropertyInt `0xE6`) not tracked → `bonus=0` → un-augmented `Str×150` capacity. | `src/AcDream.App/UI/Layout/InventoryController.cs` (`RefreshBurden`) caller of `BurdenMath.EncumbranceCapacity` | PropertyInt `0xE6` is not yet parsed from the wire; aug=0 is the correct no-aug default and matches the vast majority of characters. | Augmented characters read slightly low capacity — burden bar shows slightly higher fill than retail for augmented chars; a three-aug char's cap is 150×Str higher per aug, so up to 450 extra points per point of Str. | `EncumbranceSystem::EncumbranceCapacity` decomp 256393 (0x004fcc00); retail `0xE6` PropertyInt augmentation | +| AP-48 | Inventory burden reads the player's wire `EncumbranceVal` (PropertyInt 5) when present — delivered by B-Wire (login PD-bundle `UpsertProperties` + live `PrivateUpdatePropertyInt 0x02CD`); `ClientObjectTable.SumCarriedBurden` remains only as a defensive fallback for when the server omits it. | `src/AcDream.App/UI/Layout/InventoryController.cs` (`RefreshBurden`); `src/AcDream.Core.Net/ObjectTableWiring.cs` (player-int route) | B-Wire ports the retail read (`CACQualities::InqLoad` reads the server value); the client sum is now fallback-only, kept defensively. CONFIRM the server actually sends EncumbranceVal at the B-Wire visual gate, then DELETE this row. | If the server omits EncumbranceVal, the bar falls back to the client sum (the original drift) until the first 0x02CD — confirm at the gate. | `CACQualities::InqLoad` 0x0058f130; ACE PropertyInt.EncumbranceVal=5 | +| AP-49 | Carry-capacity augmentation (PropertyInt `0xE6`) read from the player's wire property bundle when present (B-Wire PD `UpsertProperties`); defaults to 0 (correct for un-augmented characters) when absent. | `src/AcDream.App/UI/Layout/InventoryController.cs` (`RefreshBurden`) caller of `BurdenMath.EncumbranceCapacity` | B-Wire delivers the player's full PropertyInt table (incl. `0xE6` when the server sets it) via `UpsertProperties`; aug=0 is the correct no-aug default. CONFIRM the server sends `0xE6` for an augmented char at the gate, then DELETE. | An augmented character whose server omits `0xE6` reads slightly low capacity (bar fills higher than retail); un-augmented chars are exact. | `EncumbranceSystem::EncumbranceCapacity` decomp 256393 (0x004fcc00); retail `0xE6` PropertyInt augmentation | | AP-50 | Burden meter orientation/direction set programmatically (`Vertical=true`, `FillFromBottom=true`) rather than reading retail's `m_eDirection` property from the LayoutDesc element (property id `0x6f`). | `src/AcDream.App/UI/Layout/InventoryController.cs`; `src/AcDream.App/UI/UiMeter.cs` (`Vertical`/`FillFromBottom`) | The `m_eDirection` property is not yet read by `ElementReader`; bottom-up fill is visually confirmed against retail's burden bar art. Retire when `0x6f` is wired through `ElementReader`→`DatWidgetFactory`. | Wrong fill direction (top-down instead of bottom-up, or horizontal) if a future meter whose art requires a different direction is forced through the same code path. | `UIElement_Meter::DrawChildren` @0x46fbd0; property `0x6f` (`m_eDirection`) in the LayoutDesc | | AP-51 | Main-pack `m_topContainer` cell (element `0x100001C9`) uses a placeholder/empty icon (`tex=0u`), not a weenie-driven backpack icon. | `src/AcDream.App/UI/Layout/InventoryController.cs` (`Populate`) | The player's own container entity has no item `IconId` on the client (it's a character, not an item); retail uses the equipped-pack's icon via the character's equipped-pack `CreateObject`. The equipped-pack DID path is deferred to Sub-phase C. | Main-pack cell renders blank (no icon) where retail shows the equipped backpack art — cosmetic gap visible when F12 is open. | `gmBackpackUI::PostInit` @0x4a8520 (m_topContainer bind); `CreateObject` weenie icon path | From 0fbb76b2e73afac334baf263c61e57a60d8b3983 Mon Sep 17 00:00:00 2001 From: Erik Date: Sun, 21 Jun 2026 20:07:10 +0200 Subject: [PATCH 071/133] docs(D.2b): inventory window finish (Stage 1) spec MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Stage 1 of the "full retail inventory" arc (Stage 2 = paperdoll, separate spec). Grounded in the dat dumps: contents grid 0x100001C6 is 192x96 (6x3) + scrollbar 0x100001C7 (16x96); backdrop 0x100001D0 is full-window 300x362 (so the "torn" look is the unclipped grid overflowing below the frame — fixed by clipping); side-bag column 0x100001CA is 36x252 (7 slots). Components: (A) UiItemList clip+scroll reusing UiScrollable + bind the gutter scrollbar like ChatWindowController; (B) backdrop coverage — primary fix is A, residual gated on the post-A screenshot; (C) side-bag column pads empty slots up to capacity at 36px pitch. Paperdoll + B-Drag out of scope. Co-Authored-By: Claude Opus 4.8 (1M context) --- ...6-21-d2b-inventory-window-finish-design.md | 172 ++++++++++++++++++ 1 file changed, 172 insertions(+) create mode 100644 docs/superpowers/specs/2026-06-21-d2b-inventory-window-finish-design.md diff --git a/docs/superpowers/specs/2026-06-21-d2b-inventory-window-finish-design.md b/docs/superpowers/specs/2026-06-21-d2b-inventory-window-finish-design.md new file mode 100644 index 00000000..db9aa024 --- /dev/null +++ b/docs/superpowers/specs/2026-06-21-d2b-inventory-window-finish-design.md @@ -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. From 7aba6d235ca41a8886fcf2dc814ad80940775de0 Mon Sep 17 00:00:00 2001 From: Erik Date: Sun, 21 Jun 2026 20:15:48 +0200 Subject: [PATCH 072/133] docs(D.2b): inventory window finish (Stage 1) implementation plan MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 6 tasks: (1) UiItemList clip+scroll via the shared UiScrollable + whole-row clip; (2) mouse-wheel; (3) InventoryController binds the gutter scrollbar 0x100001C7 like ChatWindowController; (4) side-bag column 36px pitch + empty- slot padding to capacity; (5) backdrop coverage — screenshot-gated, primary fix is the clip; (6) verify + bookkeeping. TDD on the pure/controller logic (internals test-visible); backdrop is the visual gate. Grounded in the dat dumps + the existing scroll/scrollbar machinery. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../2026-06-21-d2b-inventory-window-finish.md | 535 ++++++++++++++++++ 1 file changed, 535 insertions(+) create mode 100644 docs/superpowers/plans/2026-06-21-d2b-inventory-window-finish.md diff --git a/docs/superpowers/plans/2026-06-21-d2b-inventory-window-finish.md b/docs/superpowers/plans/2026-06-21-d2b-inventory-window-finish.md new file mode 100644 index 00000000..1640f928 --- /dev/null +++ b/docs/superpowers/plans/2026-06-21-d2b-inventory-window-finish.md @@ -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) ` + +--- + +## 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 + /// Vertical scroll model for grid mode (clip+scroll). Bound to the gutter + /// UiScrollbar by the panel controller. Inert in fill mode (single toolbar cell). + public UiScrollable Scroll { get; } = new(); +``` + +(b) add the row-count helper (next to `CellOffset`): + +```csharp + /// Whole rows needed for cells in a grid of + /// columns. + 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) +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) +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) +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) +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 + + + +Co-Authored-By: Claude Opus 4.8 (1M context) +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) +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. From 366af0c34f15c0528f141e87daaa68ea076d0186 Mon Sep 17 00:00:00 2001 From: Erik Date: Sun, 21 Jun 2026 20:25:11 +0200 Subject: [PATCH 073/133] =?UTF-8?q?feat(ui):=20D.2b=20inventory=20finish?= =?UTF-8?q?=20=E2=80=94=20UiItemList=20clip+scroll=20via=20UiScrollable?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. LayoutCells made internal for tests; RowCount helper added. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/AcDream.App/UI/UiItemList.cs | 40 ++++++++++++-- .../UI/UiItemListScrollTests.cs | 54 +++++++++++++++++++ 2 files changed, 89 insertions(+), 5 deletions(-) create mode 100644 tests/AcDream.App.Tests/UI/UiItemListScrollTests.cs diff --git a/src/AcDream.App/UI/UiItemList.cs b/src/AcDream.App/UI/UiItemList.cs index f550e53c..8cfef904 100644 --- a/src/AcDream.App/UI/UiItemList.cs +++ b/src/AcDream.App/UI/UiItemList.cs @@ -13,6 +13,10 @@ public sealed class UiItemList : UiElement { private readonly List _cells = new(); + /// Vertical scroll model for grid mode (clip+scroll). Bound to the gutter + /// UiScrollbar by the panel controller. Inert in fill mode (single toolbar cell). + public UiScrollable Scroll { get; } = new(); + public UiItemList(Func? spriteResolve = null) { SpriteResolve = spriteResolve; @@ -63,6 +67,14 @@ public sealed class UiItemList : UiElement /// Fixed cell height in grid mode (pairs with CellWidth). public float CellHeight { get; set; } + /// Whole rows needed for cells in a grid of + /// columns. + public static int RowCount(int cellCount, int columns) + { + int cols = columns < 1 ? 1 : columns; + return (cellCount + cols - 1) / cols; + } + /// Row-major pixel offset of cell in a grid of /// columns at the given cell pitch. internal static (float x, float y) CellOffset(int index, int columns, float cellW, float cellH) @@ -72,24 +84,42 @@ public sealed class UiItemList : UiElement } /// 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. - private void LayoutCells() + /// cell to the list; grid (CellWidth>0) tiles cells row-major, offset by the scroll position + /// with whole-row vertical clipping (cells fully outside the view are hidden). + 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.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 the new max + float scrollY = Scroll.ScrollY; + for (int i = 0; i < _cells.Count; i++) { - var (x, y) = CellOffset(i, cols, CellWidth, CellHeight); + var (x, baseY) = CellOffset(i, cols, CellWidth, CellHeight); + float top = baseY - scrollY; var cell = _cells[i]; - cell.Left = x; cell.Top = y; cell.Width = CellWidth; cell.Height = CellHeight; + cell.Left = x; 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; } } diff --git a/tests/AcDream.App.Tests/UI/UiItemListScrollTests.cs b/tests/AcDream.App.Tests/UI/UiItemListScrollTests.cs new file mode 100644 index 00000000..48af8e79 --- /dev/null +++ b/tests/AcDream.App.Tests/UI/UiItemListScrollTests.cs @@ -0,0 +1,54 @@ +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 + } +} From 0d3117596c6cd1c4b3ca157cf48be0924e65be63 Mon Sep 17 00:00:00 2001 From: Erik Date: Sun, 21 Jun 2026 20:26:57 +0200 Subject: [PATCH 074/133] =?UTF-8?q?feat(ui):=20D.2b=20inventory=20finish?= =?UTF-8?q?=20=E2=80=94=20UiItemList=20mouse-wheel=20scroll?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit OnEvent handles the Scroll wheel in grid mode (mirrors UiText's sign), driving the shared UiScrollable. The bound gutter scrollbar (next task) is the primary scroll affordance; the wheel is the convenience path. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/AcDream.App/UI/UiItemList.cs | 11 +++++++++++ tests/AcDream.App.Tests/UI/UiItemListScrollTests.cs | 11 +++++++++++ 2 files changed, 22 insertions(+) diff --git a/src/AcDream.App/UI/UiItemList.cs b/src/AcDream.App/UI/UiItemList.cs index 8cfef904..b25e45cb 100644 --- a/src/AcDream.App/UI/UiItemList.cs +++ b/src/AcDream.App/UI/UiItemList.cs @@ -129,6 +129,17 @@ public sealed class UiItemList : UiElement _cells.Clear(); } + 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(-e.Data0); + return true; + } + return base.OnEvent(e); + } + protected override void OnDraw(UiRenderContext ctx) { // The factory sets Width/Height AFTER construction, so re-layout each frame: diff --git a/tests/AcDream.App.Tests/UI/UiItemListScrollTests.cs b/tests/AcDream.App.Tests/UI/UiItemListScrollTests.cs index 48af8e79..d89c8be7 100644 --- a/tests/AcDream.App.Tests/UI/UiItemListScrollTests.cs +++ b/tests/AcDream.App.Tests/UI/UiItemListScrollTests.cs @@ -51,4 +51,15 @@ public sealed class UiItemListScrollTests Assert.Equal(0f, list.GetItem(6)!.Top); // row 1 now at the view top Assert.True(list.GetItem(18)!.Visible); // row 3 now visible } + + [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(0u, null, UiEventType.Scroll, Data0: -1); + bool handled = list.OnEvent(e); + Assert.True(handled); + Assert.Equal(32, list.Scroll.ScrollY); // one line (32px) down + } } From fad807587dc7b1bc9d7547c28f39f27ef2202003 Mon Sep 17 00:00:00 2001 From: Erik Date: Sun, 21 Jun 2026 20:29:53 +0200 Subject: [PATCH 075/133] =?UTF-8?q?feat(ui):=20D.2b=20inventory=20finish?= =?UTF-8?q?=20=E2=80=94=20bind=20contents-grid=20gutter=20scrollbar?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .../UI/Layout/InventoryController.cs | 25 +++++++++++++++++++ .../UI/Layout/InventoryControllerTests.cs | 15 +++++++++-- 2 files changed, 38 insertions(+), 2 deletions(-) diff --git a/src/AcDream.App/UI/Layout/InventoryController.cs b/src/AcDream.App/UI/Layout/InventoryController.cs index 41fd325a..9aa6ac45 100644 --- a/src/AcDream.App/UI/Layout/InventoryController.cs +++ b/src/AcDream.App/UI/Layout/InventoryController.cs @@ -20,6 +20,16 @@ public sealed class InventoryController public const uint BurdenTextId = 0x100001D8u; // gmBackpackUI m_burdenText ("%d%%") public const uint BurdenCaptionId = 0x100001D7u; // "Burden" public const uint ContentsCaptionId = 0x100001C5u; // "Contents of Backpack" + public const uint ContentsScrollbarId = 0x100001C7u; // gm3DItemsUI gutter scrollbar (right of the grid) + + // Scrollbar chrome from base layout 0x2100003E (shared with the chat scrollbar; see + // ChatWindowController). Both inventory + chat scrollbars inherit this base. + private const uint ScrollTrackSprite = 0x06004C5Fu; + private const uint ScrollThumbSprite = 0x06004C63u; // 3-slice middle tile + private const uint ScrollThumbTop = 0x06004C60u; // 3-slice top cap + private const uint ScrollThumbBot = 0x06004C66u; // 3-slice bottom cap + private const uint ScrollUpSprite = 0x06004C6Cu; // up arrow (top button) + private const uint ScrollDownSprite = 0x06004C69u; // down arrow (bottom button) // 3D-items grid: 192x96 → 6 cols x 3 rows of the 32x32 UIItem cell (template 0x21000037). private const int ContentsColumns = 6; @@ -66,6 +76,21 @@ public sealed class InventoryController _contentsGrid.CellWidth = CellPx; _contentsGrid.CellHeight = CellPx; } + + // 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; + } if (_containerList is not null) { _containerList.Columns = 1; diff --git a/tests/AcDream.App.Tests/UI/Layout/InventoryControllerTests.cs b/tests/AcDream.App.Tests/UI/Layout/InventoryControllerTests.cs index d1fb4746..53844cd5 100644 --- a/tests/AcDream.App.Tests/UI/Layout/InventoryControllerTests.cs +++ b/tests/AcDream.App.Tests/UI/Layout/InventoryControllerTests.cs @@ -22,6 +22,7 @@ public class InventoryControllerTests private const uint BurdenText = 0x100001D8u; private const uint BurdenCaption = 0x100001D7u; private const uint ContentsCaption = 0x100001C5u; + private const uint ContentsScrollbar = 0x100001C7u; private static (ImportedLayout layout, UiItemList grid, UiItemList containers, UiItemList top, UiMeter meter, UiElement burdenText, @@ -34,15 +35,16 @@ public class InventoryControllerTests 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 scrollbar = new UiScrollbar { Width = 16, Height = 96 }; 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); + root.AddChild(contentsCap); root.AddChild(scrollbar); var byId = new Dictionary { [ContentsGrid] = grid, [ContainerList] = containers, [TopContainer] = top, [BurdenMeter] = meter, [BurdenText] = burdenText, [BurdenCaption] = burdenCap, - [ContentsCaption] = contentsCap, + [ContentsCaption] = contentsCap, [ContentsScrollbar] = scrollbar, }; return (new ImportedLayout(root, byId), grid, containers, top, meter, burdenText, burdenCap, contentsCap); @@ -192,6 +194,15 @@ public class InventoryControllerTests Assert.Equal(0.2f, meter.Fill() ?? -1f, 3); // 9000/15000/3 } + [Fact] + public void Contents_grid_scrollbar_binds_to_the_grid_scroll_model() + { + var (layout, grid, _, _, _, _, _, _) = BuildLayout(); + Bind(layout, new ClientObjectTable()); + var bar = (UiScrollbar)layout.FindElement(ContentsScrollbar)!; + Assert.Same(grid.Scroll, bar.Model); // the bar drives the grid's scroll + } + // Reads the text of the UiText caption child attached by the controller. private static string CaptionText(UiElement host) { From 4112a536835c934b76de0424cc2b77f219bed258 Mon Sep 17 00:00:00 2001 From: Erik Date: Sun, 21 Jun 2026 20:33:03 +0200 Subject: [PATCH 076/133] =?UTF-8?q?feat(ui):=20D.2b=20inventory=20finish?= =?UTF-8?q?=20=E2=80=94=20side-bag=20column=20slots=20(36px=20pitch=20+=20?= =?UTF-8?q?empties)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. Two existing tests updated for the now-padded count (divergence AP-52: 7-slot fallback when capacity is absent — register row added in the wrap-up commit). Co-Authored-By: Claude Opus 4.8 (1M context) --- .../UI/Layout/InventoryController.cs | 27 +++++++++++++++---- .../UI/Layout/InventoryControllerTests.cs | 22 +++++++++++++-- 2 files changed, 42 insertions(+), 7 deletions(-) diff --git a/src/AcDream.App/UI/Layout/InventoryController.cs b/src/AcDream.App/UI/Layout/InventoryController.cs index 9aa6ac45..01de0aea 100644 --- a/src/AcDream.App/UI/Layout/InventoryController.cs +++ b/src/AcDream.App/UI/Layout/InventoryController.cs @@ -33,7 +33,9 @@ public sealed class InventoryController // 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 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 private readonly ClientObjectTable _objects; private readonly Func _playerGuid; @@ -73,8 +75,8 @@ public sealed class InventoryController if (_contentsGrid is not null) { _contentsGrid.Columns = ContentsColumns; - _contentsGrid.CellWidth = CellPx; - _contentsGrid.CellHeight = CellPx; + _contentsGrid.CellWidth = ContentsCellPx; + _contentsGrid.CellHeight = ContentsCellPx; } // Bind the gutter scrollbar to the contents grid's scroll model (the factory built @@ -94,8 +96,8 @@ public sealed class InventoryController if (_containerList is not null) { _containerList.Columns = 1; - _containerList.CellWidth = CellPx; - _containerList.CellHeight = CellPx; + _containerList.CellWidth = BackpackCellPx; + _containerList.CellHeight = BackpackCellPx; } // Burden meter: vertical 11×58 bar (gmBackpackUI m_burdenMeter, retail direction 4). @@ -177,6 +179,21 @@ public sealed class InventoryController list.AddItem(cell); } + // Side-bag column: pad with empty slot frames up to the player's container capacity + // (clamped to the 7-slot column) so it reads like retail (bags on top, empty frames + // below) rather than one lone cell. Falls back to the full 7 slots when capacity is + // unknown (divergence 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) + _containerList.AddItem(new UiItemSlot { SpriteResolve = _containerList.SpriteResolve }); + } + // 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. diff --git a/tests/AcDream.App.Tests/UI/Layout/InventoryControllerTests.cs b/tests/AcDream.App.Tests/UI/Layout/InventoryControllerTests.cs index 53844cd5..0fa5d270 100644 --- a/tests/AcDream.App.Tests/UI/Layout/InventoryControllerTests.cs +++ b/tests/AcDream.App.Tests/UI/Layout/InventoryControllerTests.cs @@ -83,8 +83,9 @@ public class InventoryControllerTests 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(7, containers.GetNumUIItems()); // 1 side bag + 6 empty (no capacity → 7-slot column) Assert.Equal(0xCu, containers.GetItem(0)!.ItemId); + Assert.Equal(0u, containers.GetItem(1)!.ItemId); // padded empty frame } [Fact] @@ -102,7 +103,8 @@ public class InventoryControllerTests Assert.Equal(1, grid.GetNumUIItems()); // only the loose item Assert.Equal(0xAu, grid.GetItem(0)!.ItemId); - Assert.Equal(0, containers.GetNumUIItems()); // equipped item is not a side bag either + Assert.Equal(7, containers.GetNumUIItems()); // 7 empty slots (no bags; the equipped item isn't here) + Assert.Equal(0u, containers.GetItem(0)!.ItemId); } [Fact] @@ -203,6 +205,22 @@ public class InventoryControllerTests Assert.Same(grid.Scroll, bar.Model); // the bar drives the grid's scroll } + [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 + } + // Reads the text of the UiText caption child attached by the controller. private static string CaptionText(UiElement host) { From 14ea938c7f4a0f22f887045000e27951cd38ca5e Mon Sep 17 00:00:00 2001 From: Erik Date: Sun, 21 Jun 2026 20:51:08 +0200 Subject: [PATCH 077/133] =?UTF-8?q?fix(ui):=20D.2b=20inventory=20finish=20?= =?UTF-8?q?=E2=80=94=20grid=20cells=20exempt=20from=20the=20anchor=20pass?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Root cause of the "grid escapes the window when scrolled" bug: UiElement. DrawSelfAndChildren runs ApplyAnchor on every child AFTER OnDraw, which captured each cell's scroll-0 position and reset Top to it every frame, fighting LayoutCells' scroll offset. Cells are laid out procedurally by the list, so they must be exempt — AddItem now sets cell.Anchors = None, making LayoutCells the sole authority. Regression test reproduces the anchor-pass interaction (the unit tests missed it by calling LayoutCells in isolation, never the draw traversal). Co-Authored-By: Claude Opus 4.8 (1M context) --- src/AcDream.App/UI/UiItemList.cs | 6 ++++++ .../UI/UiItemListScrollTests.cs | 18 ++++++++++++++++++ 2 files changed, 24 insertions(+) diff --git a/src/AcDream.App/UI/UiItemList.cs b/src/AcDream.App/UI/UiItemList.cs index b25e45cb..61c85d66 100644 --- a/src/AcDream.App/UI/UiItemList.cs +++ b/src/AcDream.App/UI/UiItemList.cs @@ -53,6 +53,12 @@ public sealed class UiItemList : UiElement public void AddItem(UiItemSlot cell) { cell.SpriteResolve ??= SpriteResolve; + // The list lays cells out procedurally (grid offset + scroll clip), so cells must be + // EXEMPT from the parent anchor pass — UiElement.DrawSelfAndChildren runs ApplyAnchor + // on every child after OnDraw, which would otherwise reset the scroll offset to each + // cell's captured base position (the escaping-grid bug). Anchors=None makes LayoutCells + // the sole authority over cell rects. + cell.Anchors = AnchorEdges.None; _cells.Add(cell); AddChild(cell); LayoutCells(); diff --git a/tests/AcDream.App.Tests/UI/UiItemListScrollTests.cs b/tests/AcDream.App.Tests/UI/UiItemListScrollTests.cs index d89c8be7..e8fa7917 100644 --- a/tests/AcDream.App.Tests/UI/UiItemListScrollTests.cs +++ b/tests/AcDream.App.Tests/UI/UiItemListScrollTests.cs @@ -52,6 +52,24 @@ public sealed class UiItemListScrollTests Assert.True(list.GetItem(18)!.Visible); // row 3 now visible } + [Fact] + public void Scroll_survives_the_per_frame_anchor_pass() + { + // Regression (the escaping-grid bug): UiElement.DrawSelfAndChildren runs ApplyAnchor + // on every child AFTER OnDraw/LayoutCells. Grid cells must be exempt (Anchors=None) so + // the anchor pass can't reset the scroll offset LayoutCells applied — otherwise the + // grid translates out of the view when scrolled. + var list = Grid(30); // cells added (Anchors=None) + laid out + for (int i = 0; i < list.GetNumUIItems(); i++) // first anchor pass (would capture base) + list.GetItem(i)!.ApplyAnchor(list.Width, list.Height); + list.Scroll.SetScrollY(32); // scroll one row + list.LayoutCells(); + for (int i = 0; i < list.GetNumUIItems(); i++) // second anchor pass (the draw traversal) + list.GetItem(i)!.ApplyAnchor(list.Width, list.Height); + Assert.Equal(0f, list.GetItem(6)!.Top); // row 1 stays at the view top + Assert.False(list.GetItem(0)!.Visible); // row 0 stays clipped above + } + [Fact] public void Wheel_down_scrolls_toward_the_bottom() { From 06c42882821488e0b19c93b8fa58d7f8d5f10cb9 Mon Sep 17 00:00:00 2001 From: Erik Date: Sun, 21 Jun 2026 20:55:50 +0200 Subject: [PATCH 078/133] =?UTF-8?q?feat(ui):=20D.2b=20inventory=20finish?= =?UTF-8?q?=20=E2=80=94=20window=20chrome=20frame=20(UiNineSlicePanel)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wrap the gmInventoryUI content in the universal 8-piece beveled chrome (same UiNineSlicePanel as vitals/chat/toolbar), so the inventory has a proper window frame like every other window. The frame is the draggable window + the F12- toggled registered window; the dat content sits inside it offset by the border. Reused as the base for the vertical-resize step next. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/AcDream.App/Rendering/GameWindow.cs | 31 ++++++++++++++++++++----- 1 file changed, 25 insertions(+), 6 deletions(-) diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs index a2832cc3..7f8063ff 100644 --- a/src/AcDream.App/Rendering/GameWindow.cs +++ b/src/AcDream.App/Rendering/GameWindow.cs @@ -2150,12 +2150,31 @@ public sealed class GameWindow : IDisposable _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); + var inventoryRoot = invLayout.Root; + // Wrap the dat content in the universal 8-piece beveled window chrome — the + // SAME UiNineSlicePanel the vitals/chat/toolbar windows use. The gmInventoryUI + // LayoutDesc (0x21000023) is 300×362; the frame adds one border thickness on + // every side. The frame is the draggable window; the dat content sits inside it. + const int invBorder = AcDream.App.UI.RetailChromeSprites.Border; + float invContentW = inventoryRoot.Width, invContentH = inventoryRoot.Height; + var inventoryFrame = new AcDream.App.UI.UiNineSlicePanel(ResolveChrome) + { + Left = inventoryRoot.Left, Top = inventoryRoot.Top, // keep the dat window position + Width = invContentW + 2 * invBorder, + Height = invContentH + 2 * invBorder, + Opacity = 1.0f, + Visible = false, // F12 toggles it + Anchors = AcDream.App.UI.AnchorEdges.None, // user-positioned + Draggable = true, + }; + // Content offset by the border so it sits inside the chrome. + inventoryRoot.Left = invBorder; inventoryRoot.Top = invBorder; + inventoryRoot.Width = invContentW; inventoryRoot.Height = invContentH; + inventoryRoot.Anchors = AcDream.App.UI.AnchorEdges.Left | AcDream.App.UI.AnchorEdges.Top; + inventoryRoot.Draggable = false; + inventoryFrame.AddChild(inventoryRoot); + _uiHost.Root.AddChild(inventoryFrame); + _uiHost.RegisterWindow(AcDream.App.UI.WindowNames.Inventory, inventoryFrame); // Phase D.2b-B — populate the inventory from ClientObjectTable: the // "Contents of Backpack" grid + the pack-selector strip + the burden meter + From a1e7041ba8edcc9540aa0d38a12eb2e5fed8cff6 Mon Sep 17 00:00:00 2001 From: Erik Date: Sun, 21 Jun 2026 21:17:28 +0200 Subject: [PATCH 079/133] =?UTF-8?q?feat(ui):=20D.2b=20inventory=20finish?= =?UTF-8?q?=20=E2=80=94=20vertical-only=20window=20resize?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Drag the bottom edge to expand the inventory and see more of the pack (ResizeX=false + ResizableEdges=Bottom blocks horizontal resize). The contents grid + its gm3DItemsUI sub-window + scrollbar + the backdrop stretch vertically (Left|Top|Bottom); the paperdoll + side-bag column stay pinned at the top (Left|Top). The grid's ViewHeight grows → more rows + the scrollbar thumb ratio (view/content) reflects how much is left to scroll. Min = dat default (expand only for now); max = 560. Visually confirmed: scroll + resize work. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/AcDream.App/Rendering/GameWindow.cs | 37 ++++++++++++++++++++++++- 1 file changed, 36 insertions(+), 1 deletion(-) diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs index 7f8063ff..56f7fdc5 100644 --- a/src/AcDream.App/Rendering/GameWindow.cs +++ b/src/AcDream.App/Rendering/GameWindow.cs @@ -2170,9 +2170,44 @@ public sealed class GameWindow : IDisposable // Content offset by the border so it sits inside the chrome. inventoryRoot.Left = invBorder; inventoryRoot.Top = invBorder; inventoryRoot.Width = invContentW; inventoryRoot.Height = invContentH; - inventoryRoot.Anchors = AcDream.App.UI.AnchorEdges.Left | AcDream.App.UI.AnchorEdges.Top; + // Content fills the frame vertically (Left|Top|Bottom = fixed width + height tracks + // the frame) so a vertical resize grows the inner content. + inventoryRoot.Anchors = AcDream.App.UI.AnchorEdges.Left | AcDream.App.UI.AnchorEdges.Top + | AcDream.App.UI.AnchorEdges.Bottom; inventoryRoot.Draggable = false; inventoryFrame.AddChild(inventoryRoot); + + // Vertical-only resize: drag the BOTTOM edge to expand and see more of the pack. + // ResizeX=false + ResizableEdges=Bottom blocks horizontal resize (user request). + // MinHeight = the dat default (no shrink below it); MaxHeight = toward full screen. + inventoryFrame.Resizable = true; + inventoryFrame.ResizeX = false; + inventoryFrame.ResizableEdges = AcDream.App.UI.ResizeEdges.Bottom; + inventoryFrame.MinHeight = invContentH + 2 * invBorder; + inventoryFrame.MaxHeight = 560f; + + // Stretch the contents region with the window: the gm3DItemsUI sub-window + its grid + // + scrollbar + the full-window backdrop grow vertically (Left|Top|Bottom); the + // paperdoll + side-bag column stay pinned at the top (Left|Top). The grid's ViewHeight + // grows → more rows + the scrollbar thumb ratio (view/content) reflects it. + void StretchV(uint id) + { + if (invLayout!.FindElement(id) is { } el) + el.Anchors = AcDream.App.UI.AnchorEdges.Left | AcDream.App.UI.AnchorEdges.Top + | AcDream.App.UI.AnchorEdges.Bottom; + } + void PinTopLeft(uint id) + { + if (invLayout!.FindElement(id) is { } el) + el.Anchors = AcDream.App.UI.AnchorEdges.Left | AcDream.App.UI.AnchorEdges.Top; + } + StretchV(0x100001D0u); // full-window backdrop + StretchV(0x100001CFu); // gm3DItemsUI sub-window (contents-grid container) + StretchV(0x100001C6u); // contents grid (UiItemList) + StretchV(0x100001C7u); // contents scrollbar + PinTopLeft(0x100001CDu); // paperdoll (fixed at top) + PinTopLeft(0x100001CEu); // side-bag column (fixed at top) + _uiHost.Root.AddChild(inventoryFrame); _uiHost.RegisterWindow(AcDream.App.UI.WindowNames.Inventory, inventoryFrame); From 1be7e65fad75a516e618ec8fdef3a29eb177730f Mon Sep 17 00:00:00 2001 From: Erik Date: Sun, 21 Jun 2026 21:21:20 +0200 Subject: [PATCH 080/133] =?UTF-8?q?feat(ui):=20D.2b=20inventory=20finish?= =?UTF-8?q?=20=E2=80=94=20contents=20grid=20shows=20full=20main-pack=20cap?= =?UTF-8?q?acity?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The contents grid now pads empty slot frames up to the main-pack capacity (player ItemsCapacity, default 102 per retail "up to 102 items"), so the pack reads like retail's fixed 102-slot grid you scroll through — not just the loose items. Mirrors the side-bag column padding. Three existing grid tests updated for the now-padded count; new test covers the 102-pad. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../UI/Layout/InventoryController.cs | 13 +++++++++ .../UI/Layout/InventoryControllerTests.cs | 27 ++++++++++++++++--- 2 files changed, 36 insertions(+), 4 deletions(-) diff --git a/src/AcDream.App/UI/Layout/InventoryController.cs b/src/AcDream.App/UI/Layout/InventoryController.cs index 01de0aea..a96537b3 100644 --- a/src/AcDream.App/UI/Layout/InventoryController.cs +++ b/src/AcDream.App/UI/Layout/InventoryController.cs @@ -36,6 +36,7 @@ public sealed class InventoryController 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 + private const int MainPackSlots = 102; // retail main-pack capacity (Wiki: "up to 102 items") private readonly ClientObjectTable _objects; private readonly Func _playerGuid; @@ -179,6 +180,18 @@ public sealed class InventoryController list.AddItem(cell); } + // Contents grid: show the FULL main-pack capacity (default 102) — occupied slots + empty + // frames — so the pack reads like retail (a fixed 102-slot grid you scroll), not just the + // loose items. The grid shows the SELECTED pack (default = the main pack / player); side-pack + // contents are a container-switch (later). Capacity = the player's ItemsCapacity, else 102. + if (_contentsGrid is not null) + { + int cap = _objects.Get(p)?.ItemsCapacity ?? 0; + int slots = cap > 0 ? cap : MainPackSlots; + while (_contentsGrid.GetNumUIItems() < slots) + _contentsGrid.AddItem(new UiItemSlot { SpriteResolve = _contentsGrid.SpriteResolve }); + } + // Side-bag column: pad with empty slot frames up to the player's container capacity // (clamped to the 7-slot column) so it reads like retail (bags on top, empty frames // below) rather than one lone cell. Falls back to the full 7 slots when capacity is diff --git a/tests/AcDream.App.Tests/UI/Layout/InventoryControllerTests.cs b/tests/AcDream.App.Tests/UI/Layout/InventoryControllerTests.cs index 0fa5d270..b44f0cd2 100644 --- a/tests/AcDream.App.Tests/UI/Layout/InventoryControllerTests.cs +++ b/tests/AcDream.App.Tests/UI/Layout/InventoryControllerTests.cs @@ -80,9 +80,10 @@ public class InventoryControllerTests Bind(layout, objects); - Assert.Equal(2, grid.GetNumUIItems()); // 2 loose + Assert.Equal(102, grid.GetNumUIItems()); // 2 loose + 100 empty (main-pack capacity) Assert.Equal(0xAu, grid.GetItem(0)!.ItemId); Assert.Equal(0xBu, grid.GetItem(1)!.ItemId); + Assert.Equal(0u, grid.GetItem(2)!.ItemId); // padded empty Assert.Equal(7, containers.GetNumUIItems()); // 1 side bag + 6 empty (no capacity → 7-slot column) Assert.Equal(0xCu, containers.GetItem(0)!.ItemId); Assert.Equal(0u, containers.GetItem(1)!.ItemId); // padded empty frame @@ -101,7 +102,7 @@ public class InventoryControllerTests Bind(layout, objects); - Assert.Equal(1, grid.GetNumUIItems()); // only the loose item + Assert.Equal(102, grid.GetNumUIItems()); // 1 loose + 101 empty (main-pack capacity) Assert.Equal(0xAu, grid.GetItem(0)!.ItemId); Assert.Equal(7, containers.GetNumUIItems()); // 7 empty slots (no bags; the equipped item isn't here) Assert.Equal(0u, containers.GetItem(0)!.ItemId); @@ -123,12 +124,13 @@ public class InventoryControllerTests var (layout, grid, _, _, _, _, _, _) = BuildLayout(); var objects = new ClientObjectTable(); Bind(layout, objects); - Assert.Equal(0, grid.GetNumUIItems()); + Assert.Equal(102, grid.GetNumUIItems()); // empty grid still shows the full 102-slot pack + Assert.Equal(0u, grid.GetItem(0)!.ItemId); // slot 0 empty before the add 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(102, grid.GetNumUIItems()); // still 102; the item fills slot 0 Assert.Equal(0xAu, grid.GetItem(0)!.ItemId); } @@ -205,6 +207,23 @@ public class InventoryControllerTests Assert.Same(grid.Scroll, bar.Model); // the bar drives the grid's scroll } + [Fact] + public void Contents_grid_pads_empty_slots_to_main_pack_capacity() + { + // The grid shows the full main-pack capacity (default 102) — occupied + empty — so the + // pack reads like retail's fixed 102-slot grid you scroll, not just the loose items. + var (layout, grid, _, _, _, _, _, _) = BuildLayout(); + var objects = new ClientObjectTable(); + objects.AddOrUpdate(new ClientObject { ObjectId = Player, ItemsCapacity = 102 }); + SeedContained(objects, 0xA, Player, slot: 0, burden: 5); // one loose item + + Bind(layout, objects); + + Assert.Equal(102, grid.GetNumUIItems()); // 1 item + 101 empty frames = 102 + Assert.Equal(0xAu, grid.GetItem(0)!.ItemId); + Assert.Equal(0u, grid.GetItem(50)!.ItemId); // a padded empty slot + } + [Fact] public void Side_bag_column_pads_empty_slots_up_to_capacity() { From e6c8b65f77a0dd59fc517636703c981dff5fdda7 Mon Sep 17 00:00:00 2001 From: Erik Date: Sun, 21 Jun 2026 21:31:24 +0200 Subject: [PATCH 081/133] =?UTF-8?q?docs(D.2b):=20inventory=20finish=20Stag?= =?UTF-8?q?e=201=20=E2=80=94=20handoff=20+=20divergence=20rows=20+=20ISSUE?= =?UTF-8?q?S?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Handoff doc for the fresh session (scroll/frame/resize/102-slots shipped + visually confirmed; next = wrong empty-slot art, then main-pack icon, then Sub-phase C paperdoll). Divergence AP-52 (side-bag 7 fallback), AP-53 (main-pack 102 fallback), AP-54 (resize approximation). ISSUES: closed the contents-grid overflow; filed the wrong empty-slot-background issue; Stage 1 SHIPPED entry. (Memory digest project_d2b_retail_ui.md + MEMORY.md + the don't-kill-clients feedback updated out-of-tree.) Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/ISSUES.md | 34 ++++- .../retail-divergence-register.md | 3 + ...2026-06-21-d2b-inventory-finish-handoff.md | 130 ++++++++++++++++++ 3 files changed, 166 insertions(+), 1 deletion(-) create mode 100644 docs/research/2026-06-21-d2b-inventory-finish-handoff.md diff --git a/docs/ISSUES.md b/docs/ISSUES.md index 20b08888..473c1e68 100644 --- a/docs/ISSUES.md +++ b/docs/ISSUES.md @@ -5344,6 +5344,20 @@ outdoors at the angle that previously erased it. # Recently closed +## D.2b — Inventory window finish (Stage 1): scroll + frame + resize + 102 slots SHIPPED + +**Closed:** 2026-06-21 +**Component:** ui — D.2b inventory window (UiItemList scroll, UiNineSlicePanel frame, vertical resize) + +The inventory window now matches retail's 2D presentation (minus the 3D paperdoll doll = Stage 2). Shipped `366af0c`→`1be7e65` (build + full suite green: Core.Net 334 / App 543 / UI 425 / Core 1530; spec/plan `docs/superpowers/{specs,plans}/2026-06-21-d2b-inventory-window-finish*.md`): +- **Scroll** — `UiItemList` clip+scroll via the shared `UiScrollable` (whole-row clip; cells exempted from the per-frame anchor pass — that was the "grid escapes the window" root cause); gutter scrollbar `0x100001C7` bound like ChatWindowController; mouse-wheel. +- **Frame** — wrapped in the 8-piece bevel chrome (`UiNineSlicePanel`) like vitals/chat/toolbar. +- **Vertical resize** — bottom-edge drag, horizontal blocked (`ResizeX=false`); grid/sub-window/scrollbar/backdrop stretch (`Left|Top|Bottom`), paperdoll + side-bags pinned; scrollbar thumb reflects view/content. Expand-only Min=default..Max=560 (AP-54). +- **102-slot grid** — contents grid pads empty frames to the main-pack capacity (default 102, AP-53); side-bag column pads to 7 (AP-52). +**Visually confirmed** (scroll, frame, resize, 102 slots). **Remaining (handoff `docs/research/2026-06-21-d2b-inventory-finish-handoff.md`):** wrong empty-slot background art (inventory + equip slots — the OPEN issue above) + main-pack backpack icon (AP-51); Stage 2 = paperdoll `UiViewport` doll + per-slot equip silhouettes. + +--- + ## D.2b-B — Inventory wire layer (B-Wire): player-property delivery + builders/parsers SHIPPED **Closed:** 2026-06-21 @@ -5366,13 +5380,31 @@ The burden bar now reads the server's wire `EncumbranceVal` (PropertyInt 5) inst ## Inventory "Contents of Backpack" grid overflows (no scroll) -**Status:** OPEN — LOW (B-Controller polish) +**Status:** DONE (2026-06-21 · D.2b inventory finish Stage 1, `366af0c`→`1be7e65`) — the grid clips to its panel + scrolls via the gutter scrollbar `0x100001C7` (`UiScrollable` + whole-row clip; cells exempted from the anchor pass), pads to the full 102-slot main-pack capacity, and the window is vertically resizable (bottom edge). Visually confirmed. **Component:** ui — D.2b inventory (gm3DItemsUI grid `0x100001C6`) **Description:** `InventoryController` populates the 6-col contents grid with ALL of the player's loose pack items via `UiItemList` grid mode, so a pack with >18 items (6 cols × 3 visible rows in the 192×96 panel) overflows BELOW the panel/frame ("part of the inventory hanging off the window"). Retail scrolls the grid via the side gutter scrollbar (`0x100001C7`). Fix: wire the gutter `UiScrollbar` to the grid + clip the grid to the panel (scroll the overflow). Spec listed scrolling as out-of-scope for B-Controller (`docs/superpowers/specs/2026-06-21-d2b-inventory-controller-design.md` §10), so this is a deliberate follow-up, not a regression. --- +## Inventory + equipment slots show the wrong empty-slot background art + +**Status:** OPEN — MEDIUM (D.2b inventory polish; the immediate next task) +**Filed:** 2026-06-21 +**Component:** ui — D.2b inventory (UiItemSlot empty sprite + paperdoll equip slots) + +**Description:** User-flagged at the Stage-1 visual gate: the empty cells in the contents grid + side-bag column use `UiItemSlot.EmptySprite = 0x060074CF` (the TOOLBAR empty-slot border) — the wrong art for inventory pack slots. The paperdoll equipment slots render a generic blue `UiDatElement` border, not per-slot equip silhouettes. Both look wrong vs retail. + +**Root cause / status:** `UiItemSlot.EmptySprite` is a single hardcoded default (`0x060074CF`) shared by all empty cells. The inventory/side-bag pack slots need the gm3DItemsUI/gmBackpackUI cell-template (`0x21000037`) empty-state background instead. The equip slots need the `0x10000032` `UIElement_UIItem` registration + per-slot silhouette art (Sub-phase C). + +**Files:** `src/AcDream.App/UI/UiItemSlot.cs` (`EmptySprite`); `src/AcDream.App/UI/Layout/InventoryController.cs` (set the inventory pack-slot empty sprite on the cells it creates); paperdoll equip slots → Sub-phase C. + +**Research:** `.layout-dumps/uiitem-0x21000037.txt` (cell template states); `docs/research/2026-06-16-equipment-paperdoll-deep-dive.md`. Also pending: the main-pack backpack icon (AP-51). + +**Acceptance:** empty inventory + side-bag cells show the retail pack-slot frame; equip slots show per-slot silhouettes. + +--- + ## #113 — Phantom staircase: REOPENED 2026-06-11, folded into the HOLISTIC BUILDING-RENDER PORT **Status:** REOPENED — root cause #2 found (drawing-BSP-orphaned no-draw polys: diff --git a/docs/architecture/retail-divergence-register.md b/docs/architecture/retail-divergence-register.md index 55dde69b..76aa4865 100644 --- a/docs/architecture/retail-divergence-register.md +++ b/docs/architecture/retail-divergence-register.md @@ -151,6 +151,9 @@ accepted-divergence entries (#96, #49, #50). | AP-49 | Carry-capacity augmentation (PropertyInt `0xE6`) read from the player's wire property bundle when present (B-Wire PD `UpsertProperties`); defaults to 0 (correct for un-augmented characters) when absent. | `src/AcDream.App/UI/Layout/InventoryController.cs` (`RefreshBurden`) caller of `BurdenMath.EncumbranceCapacity` | B-Wire delivers the player's full PropertyInt table (incl. `0xE6` when the server sets it) via `UpsertProperties`; aug=0 is the correct no-aug default. CONFIRM the server sends `0xE6` for an augmented char at the gate, then DELETE. | An augmented character whose server omits `0xE6` reads slightly low capacity (bar fills higher than retail); un-augmented chars are exact. | `EncumbranceSystem::EncumbranceCapacity` decomp 256393 (0x004fcc00); retail `0xE6` PropertyInt augmentation | | AP-50 | Burden meter orientation/direction set programmatically (`Vertical=true`, `FillFromBottom=true`) rather than reading retail's `m_eDirection` property from the LayoutDesc element (property id `0x6f`). | `src/AcDream.App/UI/Layout/InventoryController.cs`; `src/AcDream.App/UI/UiMeter.cs` (`Vertical`/`FillFromBottom`) | The `m_eDirection` property is not yet read by `ElementReader`; bottom-up fill is visually confirmed against retail's burden bar art. Retire when `0x6f` is wired through `ElementReader`→`DatWidgetFactory`. | Wrong fill direction (top-down instead of bottom-up, or horizontal) if a future meter whose art requires a different direction is forced through the same code path. | `UIElement_Meter::DrawChildren` @0x46fbd0; property `0x6f` (`m_eDirection`) in the LayoutDesc | | AP-51 | Main-pack `m_topContainer` cell (element `0x100001C9`) uses a placeholder/empty icon (`tex=0u`), not a weenie-driven backpack icon. | `src/AcDream.App/UI/Layout/InventoryController.cs` (`Populate`) | The player's own container entity has no item `IconId` on the client (it's a character, not an item); retail uses the equipped-pack's icon via the character's equipped-pack `CreateObject`. The equipped-pack DID path is deferred to Sub-phase C. | Main-pack cell renders blank (no icon) where retail shows the equipped backpack art — cosmetic gap visible when F12 is open. | `gmBackpackUI::PostInit` @0x4a8520 (m_topContainer bind); `CreateObject` weenie icon path | +| AP-52 | Side-bag column slot count defaults to 7 (the dat column height) when the player's `ContainersCapacity` is absent/0. | `src/AcDream.App/UI/Layout/InventoryController.cs` (`Populate`) | `ContainersCapacity` is not reliably on the player ClientObject yet; 7 is the standard side-pack cap + the dat column (`0x100001CA`, 36×252) holds exactly 7. | An 8th-pack (Shadow of the Seventh Mule aug) character shows one too few side-bag slots — cosmetic. | retail gmBackpackUI side-pack column; ACE `ContainersCapacity` | +| AP-53 | Contents grid slot count defaults to 102 when the player's `ItemsCapacity` is absent/0; the grid always shows the main pack (container-switch to a side pack's 24 not wired). | `src/AcDream.App/UI/Layout/InventoryController.cs` (`Populate`) | `ItemsCapacity` is not reliably on the player ClientObject yet; 102 is the retail main-pack standard. ViewContents-driven container switching is a later sub-phase. | A non-102 pack shows the wrong empty-slot count; selecting a side pack doesn't show its 24-slot contents yet. | retail main-pack capacity 102; ACE `ItemsCapacity`; `ViewContents 0x0196` (deferred) | +| AP-54 | Inventory window vertical resize is a toolkit approximation: bottom-edge drag, expand-only (Min = dat default 372 px, Max = 560 px constant), contents grid/sub-window/scrollbar/backdrop stretched via overridden `Left\|Top\|Bottom` anchors. | `src/AcDream.App/Rendering/GameWindow.cs` (inventory frame setup) | retail's gmInventoryUI resize lives in keystone.dll (no decomp); the anchor-stretch + 372..560 range matches the observed retail behavior for the dev loop. | The expand range / which elements reflow could differ from retail (e.g. retail may allow shrink-below-default); shrink is intentionally disabled for now. | retail gmInventoryUI resize (keystone.dll, no decomp); `UiNineSlicePanel` resize | --- diff --git a/docs/research/2026-06-21-d2b-inventory-finish-handoff.md b/docs/research/2026-06-21-d2b-inventory-finish-handoff.md new file mode 100644 index 00000000..fbd03774 --- /dev/null +++ b/docs/research/2026-06-21-d2b-inventory-finish-handoff.md @@ -0,0 +1,130 @@ +# Handoff — D.2b inventory window finish (Stage 1) shipped + visually confirmed; slot-art + paperdoll next + +**Date:** 2026-06-21 (after B-Wire) +**From:** the session that shipped **B-Wire** (inventory wire layer) **and Stage 1 of the +inventory window finish** (scroll + frame + vertical resize + 102-slot grid), both visually +confirmed at the live gate. +**Branch:** `claude/hopeful-maxwell-214a12`, tip **`1be7e65`** (+ this docs commit). `main` is a +clean ff ancestor — ff `main` + branch fresh, or continue this branch. +**Line numbers drift — grep the symbol.** + +--- + +## 0. What shipped this session (all committed, build + full suite green, VISUALLY CONFIRMED) + +Full suite green (`--no-build`, with the client running): **Core.Net 334 / App 543 / UI 425 / +Core 1530**, 0 failures. + +### B-Wire — inventory wire layer (`b56087b`→`7c006d1`) +The burden bar now reads the server's wire `EncumbranceVal` (PropertyInt 5). Root cause was +**delivery, not binding**: login PD dropped the player's int table, live `0x02CD` was unparsed, and +`ObjectTableWiring` gated non-UiEffects ints out. Plus the full inventory wire pass (DropItem 0x001B +/ GetAndWieldItem 0x001A / NoLongerViewingContents 0x0195 builders; ViewContents 0x0196 / +SetStackSize 0x0197 / InventoryRemoveObject 0x0024 parsers; 0x0022 + 0x00A0 field fixes). Spec/plan +`docs/superpowers/{specs,plans}/2026-06-21-d2b-inventory-wire*.md`. Detail: the B-Wire SHIPPED entry +in `claude-memory/project_d2b_retail_ui.md`. + +### Inventory window finish Stage 1 (`366af0c`→`1be7e65`) +Spec/plan `docs/superpowers/{specs,plans}/2026-06-21-d2b-inventory-window-finish*.md`. +- **Scroll** — `UiItemList` clip+scroll via the shared `UiScrollable`; gutter scrollbar `0x100001C7` + bound like `ChatWindowController`; mouse-wheel. +- **Frame** — wrapped in the 8-piece bevel chrome (`UiNineSlicePanel`), like vitals/chat/toolbar. +- **Vertical resize** — bottom-edge only (horizontal blocked); the grid + sub-window + scrollbar + + backdrop stretch, paperdoll + side-bags pinned; scrollbar thumb reflects view/content. +- **102-slot grid** — contents grid pads empty frames to the main-pack capacity (default 102); + side-bag column pads to 7. + +--- + +## 1. Read first + +- This doc. +- `claude-memory/project_d2b_retail_ui.md` — the **Stage 1 SHIPPED** entry + DO-NOT-RETRY (updated this session). +- `docs/ISSUES.md` — the **OPEN** "Inventory + equipment slots show the wrong empty-slot background art" issue (the immediate next task) + the two **SHIPPED** entries. +- `docs/architecture/retail-divergence-register.md` — AP-52 (side-bag 7 fallback), AP-53 (main-pack 102 fallback), AP-54 (resize approximation), AP-51 (main-pack icon, still open). +- `docs/research/2026-06-16-equipment-paperdoll-deep-dive.md` — for Stage 2 (paperdoll). +- `.layout-dumps/uiitem-0x21000037.txt` — the UIItem cell template states (for the correct empty-slot sprite). + +--- + +## 2. Key discoveries (durable, non-obvious — DO-NOT-RETRY) + +1. **Grid cells must be `Anchors = None`.** `UiElement.DrawSelfAndChildren` runs `ApplyAnchor` on + every child AFTER `OnDraw`; with default `Left|Top` anchors it captured each cell's scroll-0 + position and reset `Top` to it every frame, fighting `UiItemList.LayoutCells`' scroll offset → + the grid "escaped the window" when scrolled. `AddItem` now sets `cell.Anchors = None` so + `LayoutCells` is the sole authority. **The unit tests missed it by calling `LayoutCells` in + isolation, never the draw traversal** — the regression test now drives the `ApplyAnchor` + interaction. (Fix `14ea938`.) +2. **Scroll reuses `UiScrollable` + a whole-row logical clip (no GL scissor)** — mirrors + `UiText.cs:198` (`cell.Visible = top >= 0 && top + h <= Height`). The gutter scrollbar `0x100001C7` + is a factory Type-11 `UiScrollbar` inheriting base `0x2100003E`; bind its `Model` to `grid.Scroll` + + the same scrollbar sprite ids the chat scrollbar uses. +3. **Vertical-resize cascade via anchors.** Frame: `Resizable=true; ResizeX=false; + ResizableEdges=Bottom`. The stretch elements (grid `0x100001C6`, its sub-window `0x100001CF`, + scrollbar `0x100001C7`, backdrop `0x100001D0`, + the content root) get `Anchors=Left|Top|Bottom` + (fixed width, height tracks parent); paperdoll `0x100001CD` + side-bag `0x100001CE` pinned + `Left|Top`. `ComputeAnchoredRect`: Top|Bottom ⇒ `h = parentH − mB − mT`. +4. **Capacity-padding pattern.** The contents grid + side-bag column pad empty `UiItemSlot`s up to + the pack capacity (player `ItemsCapacity` default 102; `ContainersCapacity` default 7) after the + loose-item loop in `InventoryController.Populate`. Padding changed several existing tests' counts + (intended — update, don't revert). +5. **DO-NOT auto-kill the client.** The user manages client lifecycle: launch with a **plain + `dotnet run --no-build`** (no `Get-Process … CloseMainWindow/Stop-Process` preamble). If a rebuild + is blocked by a running client (`MSB3021`/`MSB3027` "locked by AcDream.App"), **ASK the user to + close it** — do not kill it. (`feedback_dont_kill_clients_before_launch`.) This overrides the + CLAUDE.md graceful-close snippet. + +--- + +## 3. Current visual state (F12, `ACDREAM_RETAIL_UI=1`) + +Renders correctly + confirmed: 8-piece bevel frame; vertical bottom-edge resize (expand-only, +372..560 px); contents grid clips to its panel, scrolls (wheel + gutter scrollbar), shows the full +**102-slot** main pack (items + empty frames); side-bag column with bags + empty slots; vertical +burden bar; backdrop covers the window. **Known-wrong (next task):** +- **Empty-slot background art is wrong** — inventory + side-bag empty cells use the TOOLBAR empty + sprite (`UiItemSlot.EmptySprite = 0x060074CF`); paperdoll equip slots show a generic blue + `UiDatElement` border. (User-flagged.) +- **Main-pack cell has no backpack icon** (AP-51, placeholder `tex=0`). + +--- + +## 4. What's next (build order) + +**(a) FIX the empty-slot background art — RECOMMENDED NEXT (the OPEN ISSUE).** Inventory + side-bag +empty cells should use the retail pack-slot frame, not the toolbar's `0x060074CF`. Find the correct +sprite from the UIItem cell template `0x21000037` (`.layout-dumps/uiitem-0x21000037.txt`) empty +state; set it on the cells `InventoryController` creates (an `EmptySprite` override per slot kind, or +a controller-set default distinct from the toolbar's). The paperdoll equip slots' per-slot +silhouettes are Sub-phase C (below). Small, self-contained; brainstorm → spec → plan as usual. + +**(b) Main-pack backpack icon (AP-51).** The main-pack cell (`m_topContainer 0x100001C9`) should show +the equipped-backpack art. Pin the backpack RenderSurface DID (or the equipped-pack `CreateObject` +icon path). + +**(c) Sub-phase C — paperdoll.** The 3D character doll via a Core→App `IUiViewportRenderer` seam +(`UiViewport`, Type `0xD`) + the `0x10000032` `UiItemSlot` registration so equip slots draw per-slot +silhouettes. Research: `2026-06-16-equipment-paperdoll-deep-dive.md`. The heaviest piece. + +**(d) Deferred polish:** container-switching (select a side pack → show its 24-slot contents via the +already-parsed `ViewContents 0x0196`); the side-bag column scrollbar (`0x100001CB`, inert — 7 fit); +shrink-below-default resize (currently expand-only); the selected-target bar + stack split slider +(per the wiki notes the user pasted). B-Drag (drag items between slots) also remains. + +Each gets the full brainstorm → spec → plan → execute flow. + +--- + +## 5. New-session prompt (paste into a fresh session) + +> Continue acdream's D.2b retail-UI inventory arc. **Read +> `docs/research/2026-06-21-d2b-inventory-finish-handoff.md` first.** B-Wire + the inventory window +> finish Stage 1 (scroll, 8-piece frame, vertical resize, 102-slot grid) shipped + visually confirmed +> on `claude/hopeful-maxwell-214a12` (tip `1be7e65`; `main` is a clean ff ancestor). **Next: fix the +> wrong empty-slot background art** — inventory + side-bag empty cells use the toolbar empty sprite +> `0x060074CF`; they need the retail pack-slot frame from the UIItem cell template `0x21000037` (see +> `.layout-dumps/uiitem-0x21000037.txt`). Then the main-pack backpack icon (AP-51), then Sub-phase C +> (paperdoll `UiViewport` doll + `0x10000032` per-slot equip silhouettes). Use the full brainstorm → +> spec → plan → subagent/inline flow. **DO NOT auto-kill the running client** — launch with plain +> `dotnet run --no-build`; if a rebuild is locked by a running client, ask the user to close it. From 660bcc2fcfa72fff170aec9678de09551dd16616 Mon Sep 17 00:00:00 2001 From: Erik Date: Mon, 22 Jun 2026 10:31:38 +0200 Subject: [PATCH 082/133] =?UTF-8?q?docs(D.2b):=20design=20=E2=80=94=20fait?= =?UTF-8?q?hful=20inventory=20empty-slot=20art=20via=20cell-template=20res?= =?UTF-8?q?olution?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Brainstorm + decomp investigation for the OPEN issue "Inventory + equipment slots show the wrong empty-slot background art". Verified against the named decomp that retail's UIElement_ItemList::InternalCreateItem (004e3570) sources each list's empty-cell sprite from attribute 0x1000000e on the list's own ElementDesc -> catalog LayoutDesc 0x21000037 -> the prototype's ItemSlot_Empty (0x1000001c) media. acdream hardcodes 0x060074CF (the generic toolbar square), bypassing the per-list cell-template inheritance entirely. Design ports the resolver: a new ItemListCellTemplate.ResolveEmptySprite helper (mirrors the existing GameWindow 0x21000037 digit-array read), a UiItemList.CellEmptySprite property, and InventoryController wiring for the contents grid / side-bag / main-pack lists. Toolbar untouched (its 0x060074CF is correct); paperdoll silhouettes stay Sub-phase C. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../2026-06-22-d2b-empty-slot-art-design.md | 145 ++++++++++++++++++ 1 file changed, 145 insertions(+) create mode 100644 docs/superpowers/specs/2026-06-22-d2b-empty-slot-art-design.md diff --git a/docs/superpowers/specs/2026-06-22-d2b-empty-slot-art-design.md b/docs/superpowers/specs/2026-06-22-d2b-empty-slot-art-design.md new file mode 100644 index 00000000..c8bedd75 --- /dev/null +++ b/docs/superpowers/specs/2026-06-22-d2b-empty-slot-art-design.md @@ -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; +/// 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. +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(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(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.* From 69ad2cf12de88e81d4643b005e9e9f56c3caa19a Mon Sep 17 00:00:00 2001 From: Erik Date: Mon, 22 Jun 2026 10:40:51 +0200 Subject: [PATCH 083/133] =?UTF-8?q?docs(D.2b):=20implementation=20plan=20?= =?UTF-8?q?=E2=80=94=20inventory=20empty-slot=20art=20via=20cell-template?= =?UTF-8?q?=20resolution?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 5-task TDD plan for the OPEN empty-slot-art issue: ItemListCellTemplate resolver (ports UIElement_ItemList::InternalCreateItem's 0x1000000e -> 0x21000037 lookup), UiItemList.CellEmptySprite, InventoryController + GameWindow wiring, divergence rows AP-55/AP-56, ISSUES close + visual gate. Each task is test-first with exact code; Task 1 pins the exact retail sprites from the live dat. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../plans/2026-06-22-d2b-empty-slot-art.md | 556 ++++++++++++++++++ 1 file changed, 556 insertions(+) create mode 100644 docs/superpowers/plans/2026-06-22-d2b-empty-slot-art.md diff --git a/docs/superpowers/plans/2026-06-22-d2b-empty-slot-art.md b/docs/superpowers/plans/2026-06-22-d2b-empty-slot-art.md new file mode 100644 index 00000000..37c13797 --- /dev/null +++ b/docs/superpowers/plans/2026-06-22-d2b-empty-slot-art.md @@ -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; + +/// +/// Real-dat test for : 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). +/// +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; + +/// +/// 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. +/// +public static class ItemListCellTemplate +{ + /// 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). + 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 + + /// + /// Resolve the empty-slot sprite (a 0x06xxxxxx RenderSurface id) for the cells of item-list + /// element in LayoutDesc . + /// Returns 0 when the layout/element/attribute/prototype/media is absent (the caller keeps + /// the default). + /// + public static uint ResolveEmptySprite(DatCollection dats, uint listLayoutId, uint listElementId) + { + var listLd = dats.Get(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(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) " +``` + +--- + +### 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; + /// Empty-slot sprite for THIS list's cells, resolved from the dat cell template + /// (retail attribute 0x1000000e -> catalog 0x21000037 prototype's ItemSlot_Empty; see + /// ). 0 = leave the + /// default (0x060074CF). Applied to every existing + /// AND future cell. + 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) " +``` + +--- + +### 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 playerGuid, + Func iconIds, + Func 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) " +``` + +--- + +### 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) " +``` + +--- + +### 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 . 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) " +``` + +- [ ] **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. From b8626401add6aa1e0b74d570f8dd1e4732f1aa3c Mon Sep 17 00:00:00 2001 From: Erik Date: Mon, 22 Jun 2026 11:00:36 +0200 Subject: [PATCH 084/133] =?UTF-8?q?feat(ui):=20D.2b=20empty-slot=20art=20?= =?UTF-8?q?=E2=80=94=20ItemListCellTemplate=20resolver=20(0x1000000e=20->?= =?UTF-8?q?=200x21000037)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ports UIElement_ItemList::InternalCreateItem (0x004e3570) attribute lookup to a testable static helper. The 0x1000000e property is an EnumBaseProperty (not DataIdBaseProperty as the spec anticipated) — discovered by runtime reflection and confirmed against the live dat. Pinned sprite ids from real-dat test: contents (0x100001C6): 0x06004D20 side-bag (0x100001CA): 0x06005D9C main-pack (0x100001C9): 0x06005D9C Co-Authored-By: Claude Opus 4.8 (1M context) --- .../UI/Layout/ItemListCellTemplate.cs | 138 ++++++++++++++++++ .../UI/Layout/ItemListCellTemplateTests.cs | 59 ++++++++ 2 files changed, 197 insertions(+) create mode 100644 src/AcDream.App/UI/Layout/ItemListCellTemplate.cs create mode 100644 tests/AcDream.App.Tests/UI/Layout/ItemListCellTemplateTests.cs diff --git a/src/AcDream.App/UI/Layout/ItemListCellTemplate.cs b/src/AcDream.App/UI/Layout/ItemListCellTemplate.cs new file mode 100644 index 00000000..2e03db1f --- /dev/null +++ b/src/AcDream.App/UI/Layout/ItemListCellTemplate.cs @@ -0,0 +1,138 @@ +using DatReaderWriter; +using DatReaderWriter.DBObjs; +using DatReaderWriter.Types; + +namespace AcDream.App.UI.Layout; + +/// +/// 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. +/// +public static class ItemListCellTemplate +{ + /// 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). + 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 + + /// + /// Resolve the empty-slot sprite (a 0x06xxxxxx RenderSurface id) for the cells of item-list + /// element in LayoutDesc . + /// Returns 0 when the layout/element/attribute/prototype/media is absent (the caller keeps + /// the default). + /// + public static uint ResolveEmptySprite(DatCollection dats, uint listLayoutId, uint listElementId) + { + var listLd = dats.Get(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(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: stored as EnumBaseProperty in the dat (Value is the prototype id) ── + // Note: the spec anticipated DataIdBaseProperty/ArrayBaseProperty based on the font-DID pattern, + // but the live dat uses EnumBaseProperty.Value (uint) — confirmed by runtime reflection. + 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; + if (raw is EnumBaseProperty ep) return ep.Value; // 0x1000000e is EnumBaseProperty in the live dat + 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; + } +} diff --git a/tests/AcDream.App.Tests/UI/Layout/ItemListCellTemplateTests.cs b/tests/AcDream.App.Tests/UI/Layout/ItemListCellTemplateTests.cs new file mode 100644 index 00000000..898f9aac --- /dev/null +++ b/tests/AcDream.App.Tests/UI/Layout/ItemListCellTemplateTests.cs @@ -0,0 +1,59 @@ +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; + +/// +/// Real-dat test for : 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). +/// +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"); + } + } +} From 4785232523b8b0b62e3223eb29a0b5c37a866c0b Mon Sep 17 00:00:00 2001 From: Erik Date: Mon, 22 Jun 2026 11:03:19 +0200 Subject: [PATCH 085/133] =?UTF-8?q?feat(ui):=20D.2b=20empty-slot=20art=20?= =?UTF-8?q?=E2=80=94=20UiItemList.CellEmptySprite=20stamps=20cells?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 (1M context) --- src/AcDream.App/UI/UiItemList.cs | 18 ++++++++++++++++ tests/AcDream.App.Tests/UI/UiItemListTests.cs | 21 +++++++++++++++++++ 2 files changed, 39 insertions(+) diff --git a/src/AcDream.App/UI/UiItemList.cs b/src/AcDream.App/UI/UiItemList.cs index 61c85d66..eedb6ff3 100644 --- a/src/AcDream.App/UI/UiItemList.cs +++ b/src/AcDream.App/UI/UiItemList.cs @@ -28,6 +28,23 @@ public sealed class UiItemList : UiElement public Func? SpriteResolve { get; set; } + private uint _cellEmptySprite; + /// Empty-slot sprite for THIS list's cells, resolved from the dat cell template + /// (retail attribute 0x1000000e -> catalog 0x21000037 prototype's ItemSlot_Empty; see + /// ). 0 = leave the + /// default (0x060074CF). Applied to every existing + /// AND future cell. + public uint CellEmptySprite + { + get => _cellEmptySprite; + set + { + _cellEmptySprite = value; + if (value != 0) + foreach (var c in _cells) c.EmptySprite = value; + } + } + /// The drag handler this list routes drops to (a panel controller). /// Null = the list accepts no drops. Retail: UIElement_ItemList::m_dragHandler. public IItemListDragHandler? DragHandler { get; private set; } @@ -53,6 +70,7 @@ public sealed class UiItemList : UiElement public void AddItem(UiItemSlot cell) { cell.SpriteResolve ??= SpriteResolve; + if (_cellEmptySprite != 0) cell.EmptySprite = _cellEmptySprite; // The list lays cells out procedurally (grid offset + scroll clip), so cells must be // EXEMPT from the parent anchor pass — UiElement.DrawSelfAndChildren runs ApplyAnchor // on every child after OnDraw, which would otherwise reset the scroll offset to each diff --git a/tests/AcDream.App.Tests/UI/UiItemListTests.cs b/tests/AcDream.App.Tests/UI/UiItemListTests.cs index 832a8507..b03e9ed6 100644 --- a/tests/AcDream.App.Tests/UI/UiItemListTests.cs +++ b/tests/AcDream.App.Tests/UI/UiItemListTests.cs @@ -22,4 +22,25 @@ public class UiItemListTests var list = new UiItemList(); Assert.Same(list.GetItem(0), list.Cell); } + + [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 + } } From 8c719cd3e9d12840e54ab25741bc661ffc98e976 Mon Sep 17 00:00:00 2001 From: Erik Date: Mon, 22 Jun 2026 11:05:12 +0200 Subject: [PATCH 086/133] =?UTF-8?q?feat(ui):=20D.2b=20empty-slot=20art=20?= =?UTF-8?q?=E2=80=94=20InventoryController=20applies=20per-list=20empty=20?= =?UTF-8?q?sprites?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 (1M context) --- .../UI/Layout/InventoryController.cs | 19 ++++++++++++++++--- .../UI/Layout/InventoryControllerTests.cs | 13 +++++++++++++ 2 files changed, 29 insertions(+), 3 deletions(-) diff --git a/src/AcDream.App/UI/Layout/InventoryController.cs b/src/AcDream.App/UI/Layout/InventoryController.cs index a96537b3..81007e89 100644 --- a/src/AcDream.App/UI/Layout/InventoryController.cs +++ b/src/AcDream.App/UI/Layout/InventoryController.cs @@ -62,7 +62,10 @@ public sealed class InventoryController Func playerGuid, Func iconIds, Func strength, - UiDatFont? datFont) + UiDatFont? datFont, + uint contentsEmptySprite, + uint sideBagEmptySprite, + uint mainPackEmptySprite) { _objects = objects; _playerGuid = playerGuid; @@ -101,6 +104,12 @@ public sealed class InventoryController _containerList.CellHeight = BackpackCellPx; } + // 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; + // Burden meter: vertical 11×58 bar (gmBackpackUI m_burdenMeter, retail direction 4). _burdenMeter = layout.FindElement(BurdenMeterId) as UiMeter; if (_burdenMeter is not null) @@ -132,8 +141,12 @@ public sealed class InventoryController Func playerGuid, Func iconIds, Func strength, - UiDatFont? datFont) - => new InventoryController(layout, objects, playerGuid, iconIds, strength, datFont); + UiDatFont? datFont, + uint contentsEmptySprite = 0u, + uint sideBagEmptySprite = 0u, + uint mainPackEmptySprite = 0u) + => new InventoryController(layout, objects, playerGuid, iconIds, strength, datFont, + contentsEmptySprite, sideBagEmptySprite, mainPackEmptySprite); private void OnObjectChanged(ClientObject o) { if (Concerns(o)) Populate(); } private void OnObjectMoved(ClientObject o, uint from, uint to) diff --git a/tests/AcDream.App.Tests/UI/Layout/InventoryControllerTests.cs b/tests/AcDream.App.Tests/UI/Layout/InventoryControllerTests.cs index b44f0cd2..fc2a5de2 100644 --- a/tests/AcDream.App.Tests/UI/Layout/InventoryControllerTests.cs +++ b/tests/AcDream.App.Tests/UI/Layout/InventoryControllerTests.cs @@ -240,6 +240,19 @@ public class InventoryControllerTests Assert.Equal(0u, containers.GetItem(2)!.ItemId); // empty frame } + [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 + } + // Reads the text of the UiText caption child attached by the controller. private static string CaptionText(UiElement host) { From edbe5bafd5f49880322f81faa63256cf5a4320cd Mon Sep 17 00:00:00 2001 From: Erik Date: Mon, 22 Jun 2026 11:07:58 +0200 Subject: [PATCH 087/133] =?UTF-8?q?feat(ui):=20D.2b=20empty-slot=20art=20?= =?UTF-8?q?=E2=80=94=20GameWindow=20resolves=20+=20wires=20per-list=20empt?= =?UTF-8?q?y=20sprites?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .../retail-divergence-register.md | 2 ++ src/AcDream.App/Rendering/GameWindow.cs | 19 ++++++++++++++++++- 2 files changed, 20 insertions(+), 1 deletion(-) diff --git a/docs/architecture/retail-divergence-register.md b/docs/architecture/retail-divergence-register.md index 76aa4865..49af5d36 100644 --- a/docs/architecture/retail-divergence-register.md +++ b/docs/architecture/retail-divergence-register.md @@ -154,6 +154,8 @@ accepted-divergence entries (#96, #49, #50). | AP-52 | Side-bag column slot count defaults to 7 (the dat column height) when the player's `ContainersCapacity` is absent/0. | `src/AcDream.App/UI/Layout/InventoryController.cs` (`Populate`) | `ContainersCapacity` is not reliably on the player ClientObject yet; 7 is the standard side-pack cap + the dat column (`0x100001CA`, 36×252) holds exactly 7. | An 8th-pack (Shadow of the Seventh Mule aug) character shows one too few side-bag slots — cosmetic. | retail gmBackpackUI side-pack column; ACE `ContainersCapacity` | | AP-53 | Contents grid slot count defaults to 102 when the player's `ItemsCapacity` is absent/0; the grid always shows the main pack (container-switch to a side pack's 24 not wired). | `src/AcDream.App/UI/Layout/InventoryController.cs` (`Populate`) | `ItemsCapacity` is not reliably on the player ClientObject yet; 102 is the retail main-pack standard. ViewContents-driven container switching is a later sub-phase. | A non-102 pack shows the wrong empty-slot count; selecting a side pack doesn't show its 24-slot contents yet. | retail main-pack capacity 102; ACE `ItemsCapacity`; `ViewContents 0x0196` (deferred) | | AP-54 | Inventory window vertical resize is a toolkit approximation: bottom-edge drag, expand-only (Min = dat default 372 px, Max = 560 px constant), contents grid/sub-window/scrollbar/backdrop stretched via overridden `Left\|Top\|Bottom` anchors. | `src/AcDream.App/Rendering/GameWindow.cs` (inventory frame setup) | retail's gmInventoryUI resize lives in keystone.dll (no decomp); the anchor-stretch + 372..560 range matches the observed retail behavior for the dev loop. | The expand range / which elements reflow could differ from retail (e.g. retail may allow shrink-below-default); shrink is intentionally disabled for now. | retail gmInventoryUI resize (keystone.dll, no decomp); `UiNineSlicePanel` resize | +| 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` | --- diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs index 56f7fdc5..308739d2 100644 --- a/src/AcDream.App/Rendering/GameWindow.cs +++ b/src/AcDream.App/Rendering/GameWindow.cs @@ -2214,6 +2214,20 @@ public sealed class GameWindow : IDisposable // 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. + + // 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}"); + _inventoryController = AcDream.App.UI.Layout.InventoryController.Bind( invLayout, Objects, playerGuid: () => _playerServerGuid, @@ -2221,7 +2235,10 @@ public sealed class GameWindow : IDisposable strength: () => LocalPlayer.GetAttribute( AcDream.Core.Player.LocalPlayerState.AttributeKind.Strength) is { } sa ? (int?)sa.Current : null, - datFont: vitalsDatFont); + datFont: vitalsDatFont, + contentsEmptySprite: contentsEmpty, + sideBagEmptySprite: sideBagEmpty, + mainPackEmptySprite: mainPackEmpty); Console.WriteLine("[D.2b-B] retail inventory window from LayoutDesc importer (0x21000023)."); } From 499485a672ce13a04c312d6669cb525f55d5748f Mon Sep 17 00:00:00 2001 From: Erik Date: Mon, 22 Jun 2026 11:10:39 +0200 Subject: [PATCH 088/133] =?UTF-8?q?docs:=20D.2b=20empty-slot=20art=20?= =?UTF-8?q?=E2=80=94=20close=20inventory=20portion=20of=20the=20empty-slot?= =?UTF-8?q?-art=20issue?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Inventory contents grid / side-bag / main-pack empty cells now dat-resolve their art via ItemListCellTemplate (0x1000000e -> 0x21000037). Paperdoll equip silhouettes + main-pack icon remain open (Sub-phase C / AP-51). Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/ISSUES.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/docs/ISSUES.md b/docs/ISSUES.md index 473c1e68..78f3fa13 100644 --- a/docs/ISSUES.md +++ b/docs/ISSUES.md @@ -5389,7 +5389,7 @@ The burden bar now reads the server's wire `EncumbranceVal` (PropertyInt 5) inst ## Inventory + equipment slots show the wrong empty-slot background art -**Status:** OPEN — MEDIUM (D.2b inventory polish; the immediate next task) +**Status:** INVENTORY PORTION FIXED 2026-06-22 (pending visual gate) — contents grid + side-bag + main-pack empty art now dat-resolved (`ItemListCellTemplate`, commits `b862640`→`edbe5ba`). STILL OPEN: paperdoll equip-slot silhouettes (Sub-phase C) + main-pack backpack icon (AP-51). **Filed:** 2026-06-21 **Component:** ui — D.2b inventory (UiItemSlot empty sprite + paperdoll equip slots) @@ -5397,6 +5397,8 @@ The burden bar now reads the server's wire `EncumbranceVal` (PropertyInt 5) inst **Root cause / status:** `UiItemSlot.EmptySprite` is a single hardcoded default (`0x060074CF`) shared by all empty cells. The inventory/side-bag pack slots need the gm3DItemsUI/gmBackpackUI cell-template (`0x21000037`) empty-state background instead. The equip slots need the `0x10000032` `UIElement_UIItem` registration + per-slot silhouette art (Sub-phase C). +**Resolution (inventory portion, 2026-06-22):** Ported retail's `UIElement_ItemList::InternalCreateItem` (`0x004e3570`) resolver into `ItemListCellTemplate.ResolveEmptySprite` — reads attribute `0x1000000e` off each list element → catalog `0x21000037` prototype's `ItemSlot_Empty`. **Correction to the original diagnosis:** `0x060074CF` is the *generic shared* item-slot empty (the `ItemSlot_Empty` of many catalog prototypes), not "the toolbar's"; `0x21000037` is a catalog of ~50 per-slot-kind empties — so it was never a one-constant swap. Pinned per-list (real-dat test): contents `0x100001C6` → `0x06004D20`; side-bag `0x100001CA` + main-pack `0x100001C9` → `0x06005D9C` (the 36×36 frame). `UiItemList.CellEmptySprite` carries it; `InventoryController` + `GameWindow` wire it. Divergence AP-55 (toolbar still hardcoded) + AP-56 (flat single-sprite cell vs retail's layered prototype). Spec/plan `docs/superpowers/{specs,plans}/2026-06-22-d2b-empty-slot-art*.md`. + **Files:** `src/AcDream.App/UI/UiItemSlot.cs` (`EmptySprite`); `src/AcDream.App/UI/Layout/InventoryController.cs` (set the inventory pack-slot empty sprite on the cells it creates); paperdoll equip slots → Sub-phase C. **Research:** `.layout-dumps/uiitem-0x21000037.txt` (cell template states); `docs/research/2026-06-16-equipment-paperdoll-deep-dive.md`. Also pending: the main-pack backpack icon (AP-51). From 22d92315e574e48e6bcc7c1483fb3cc9535781e3 Mon Sep 17 00:00:00 2001 From: Erik Date: Mon, 22 Jun 2026 12:45:36 +0200 Subject: [PATCH 089/133] =?UTF-8?q?fix(ui):=20D.2b=20empty-slot=20art=20?= =?UTF-8?q?=E2=80=94=20container=20cells=20use=20inner=20slot=20bg,=20not?= =?UTF-8?q?=20the=20selected-container=20triangle?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Visual gate (2026-06-22) showed the side-bag/main-pack empty cells drawing yellow triangles. Root cause: the frame-first heuristic grabbed the 36x36 container prototype's DirectState child 0x06005D9C — which is the open/SELECTED-container triangle indicator, NOT a background frame — and stamped it onto every empty cell. Fix: drop frame-first; FindIconEmpty resolves the inner m_elem_Icon ItemSlot_Empty THROUGH BaseElement inheritance (0x1000033F -> 0x10000340 -> base 0x1000033E -> 0x1000033B -> 0x06000F6E), so containers get the dark slot background matching the inventory. Pin test now asserts exact ids (0x06004D20 contents / 0x06000F6E containers) — the old structural-only assertion let the wrong-but-valid 0x06005D9C pass (Opus review Minor 2, now vindicated). The triangle + green/yellow selection square is deferred to container-switching (AP-56 reworded). Full suite green. Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/ISSUES.md | 2 +- .../retail-divergence-register.md | 2 +- .../UI/Layout/ItemListCellTemplate.cs | 49 +++++++++++++------ .../UI/Layout/ItemListCellTemplateTests.cs | 16 ++++++ 4 files changed, 53 insertions(+), 16 deletions(-) diff --git a/docs/ISSUES.md b/docs/ISSUES.md index 78f3fa13..311fab2a 100644 --- a/docs/ISSUES.md +++ b/docs/ISSUES.md @@ -5397,7 +5397,7 @@ The burden bar now reads the server's wire `EncumbranceVal` (PropertyInt 5) inst **Root cause / status:** `UiItemSlot.EmptySprite` is a single hardcoded default (`0x060074CF`) shared by all empty cells. The inventory/side-bag pack slots need the gm3DItemsUI/gmBackpackUI cell-template (`0x21000037`) empty-state background instead. The equip slots need the `0x10000032` `UIElement_UIItem` registration + per-slot silhouette art (Sub-phase C). -**Resolution (inventory portion, 2026-06-22):** Ported retail's `UIElement_ItemList::InternalCreateItem` (`0x004e3570`) resolver into `ItemListCellTemplate.ResolveEmptySprite` — reads attribute `0x1000000e` off each list element → catalog `0x21000037` prototype's `ItemSlot_Empty`. **Correction to the original diagnosis:** `0x060074CF` is the *generic shared* item-slot empty (the `ItemSlot_Empty` of many catalog prototypes), not "the toolbar's"; `0x21000037` is a catalog of ~50 per-slot-kind empties — so it was never a one-constant swap. Pinned per-list (real-dat test): contents `0x100001C6` → `0x06004D20`; side-bag `0x100001CA` + main-pack `0x100001C9` → `0x06005D9C` (the 36×36 frame). `UiItemList.CellEmptySprite` carries it; `InventoryController` + `GameWindow` wire it. Divergence AP-55 (toolbar still hardcoded) + AP-56 (flat single-sprite cell vs retail's layered prototype). Spec/plan `docs/superpowers/{specs,plans}/2026-06-22-d2b-empty-slot-art*.md`. +**Resolution (inventory portion, 2026-06-22):** Ported retail's `UIElement_ItemList::InternalCreateItem` (`0x004e3570`) resolver into `ItemListCellTemplate.ResolveEmptySprite` — reads attribute `0x1000000e` off each list element → catalog `0x21000037` prototype's `ItemSlot_Empty`. **Correction to the original diagnosis:** `0x060074CF` is the *generic shared* item-slot empty (the `ItemSlot_Empty` of many catalog prototypes), not "the toolbar's"; `0x21000037` is a catalog of ~50 per-slot-kind empties — so it was never a one-constant swap. Pinned per-list (real-dat test): contents `0x100001C6` → `0x06004D20`; side-bag `0x100001CA` + main-pack `0x100001C9` → `0x06000F6E` (the inner dark slot, resolved through `BaseElement` inheritance). **Visual-gate correction (2026-06-22):** the first cut's frame-first heuristic grabbed the container prototype's DirectState child `0x06005D9C` — which is the open/**selected**-container TRIANGLE indicator, not a background — and stamped it onto every empty container cell. Fixed: resolve the inner `ItemSlot_Empty` through inheritance (`FindIconEmpty`); the triangle + green/yellow selection square is deferred to the container-switching sub-phase (AP-56). `UiItemList.CellEmptySprite` carries it; `InventoryController` + `GameWindow` wire it. Divergence AP-55 (toolbar still hardcoded) + AP-56 (selected-container indicator deferred). Spec/plan `docs/superpowers/{specs,plans}/2026-06-22-d2b-empty-slot-art*.md`. **Files:** `src/AcDream.App/UI/UiItemSlot.cs` (`EmptySprite`); `src/AcDream.App/UI/Layout/InventoryController.cs` (set the inventory pack-slot empty sprite on the cells it creates); paperdoll equip slots → Sub-phase C. diff --git a/docs/architecture/retail-divergence-register.md b/docs/architecture/retail-divergence-register.md index 49af5d36..6c787c9e 100644 --- a/docs/architecture/retail-divergence-register.md +++ b/docs/architecture/retail-divergence-register.md @@ -155,7 +155,7 @@ accepted-divergence entries (#96, #49, #50). | AP-53 | Contents grid slot count defaults to 102 when the player's `ItemsCapacity` is absent/0; the grid always shows the main pack (container-switch to a side pack's 24 not wired). | `src/AcDream.App/UI/Layout/InventoryController.cs` (`Populate`) | `ItemsCapacity` is not reliably on the player ClientObject yet; 102 is the retail main-pack standard. ViewContents-driven container switching is a later sub-phase. | A non-102 pack shows the wrong empty-slot count; selecting a side pack doesn't show its 24-slot contents yet. | retail main-pack capacity 102; ACE `ItemsCapacity`; `ViewContents 0x0196` (deferred) | | AP-54 | Inventory window vertical resize is a toolkit approximation: bottom-edge drag, expand-only (Min = dat default 372 px, Max = 560 px constant), contents grid/sub-window/scrollbar/backdrop stretched via overridden `Left\|Top\|Bottom` anchors. | `src/AcDream.App/Rendering/GameWindow.cs` (inventory frame setup) | retail's gmInventoryUI resize lives in keystone.dll (no decomp); the anchor-stretch + 372..560 range matches the observed retail behavior for the dev loop. | The expand range / which elements reflow could differ from retail (e.g. retail may allow shrink-below-default); shrink is intentionally disabled for now. | retail gmInventoryUI resize (keystone.dll, no decomp); `UiNineSlicePanel` resize | | 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` | +| AP-56 | The container/main-pack empty cells render only the empty-slot BACKGROUND (the inner `ItemSlot_Empty` `0x06000F6E`, resolved through `BaseElement` inheritance), not the open/selected-container indicator overlay — the triangle `0x06005D9C` + a green/yellow selection square that retail draws on the SELECTED container only. The indicator is deferred to the container-switching sub-phase. | `src/AcDream.App/UI/Layout/ItemListCellTemplate.cs` (`FindIconEmpty`); `src/AcDream.App/UI/Layout/InventoryController.cs` | acdream has no selected-container state yet; the first cut wrongly stamped the `0x06005D9C` triangle onto EVERY empty cell (frame-first heuristic) — omitting it entirely is correct until selection state exists. Retire when container-switching draws the indicator on the selected pack. | Empty container cells show only the dark slot background; the selected container lacks its triangle + selection square until container-switching ships. | `UIElement_ItemList::InternalCreateItem` 0x004e3570; prototype `0x1000033F` child `0x10000450` (`0x06005D9C`) | --- diff --git a/src/AcDream.App/UI/Layout/ItemListCellTemplate.cs b/src/AcDream.App/UI/Layout/ItemListCellTemplate.cs index 2e03db1f..29b33f3a 100644 --- a/src/AcDream.App/UI/Layout/ItemListCellTemplate.cs +++ b/src/AcDream.App/UI/Layout/ItemListCellTemplate.cs @@ -1,3 +1,4 @@ +using System.Collections.Generic; using DatReaderWriter; using DatReaderWriter.DBObjs; using DatReaderWriter.Types; @@ -44,11 +45,15 @@ public static class ItemListCellTemplate 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); + // The empty-slot BACKGROUND is the m_elem_Icon's ItemSlot_Empty media, resolved THROUGH + // BaseElement inheritance: the 36x36 container prototype (0x1000033F) nests its icon behind + // an inner wrapper (0x10000340 -> base 0x1000033E -> 0x1000033B -> 0x06000F6E), so a raw + // child search alone returns nothing for containers. We deliberately do NOT use the + // prototype's DirectState child overlay: on the container prototype that child is the + // open/selected-container TRIANGLE indicator (0x06005D9C), which retail draws ONLY on the + // selected container (a deferred container-selection feature) — never as empty-cell art. + // (Live visual gate 2026-06-22: frame-first stamped the triangle onto every empty cell.) + return FindIconEmpty(catalog, proto, new HashSet()); } // ── attribute 0x1000000e: stored as EnumBaseProperty in the dat (Value is the prototype id) ── @@ -80,28 +85,44 @@ public static class ItemListCellTemplate return 0; } - // ── prototype media ── - private static uint FirstChildDirectStateImage(ElementDesc proto) + // ── prototype media: the m_elem_Icon (0x1000033B) ItemSlot_Empty, resolved through inheritance ── + // Find the icon child's empty media within `element`'s subtree, following BaseElement edges + // within the same catalog (cycle-guarded by `baseSeen`). The 32x32 contents prototype carries + // 0x1000033B as a direct child; the 36x36 container prototype reaches it only via an inherited + // base (0x10000340 -> 0x1000033E), so a raw child search alone returns nothing for containers. + private static uint FindIconEmpty(LayoutDesc catalog, ElementDesc element, HashSet baseSeen) { - foreach (var kv in proto.Children) + if (element.ElementId == IconChildId) { - uint f = DirectStateImage(kv.Value); - if (f != 0) return f; + uint e = IconEmptyState(element); + if (e != 0) return e; + } + foreach (var kv in element.Children) + { + uint e = FindIconEmpty(catalog, kv.Value, baseSeen); + if (e != 0) return e; + } + if (element.BaseElement != 0 && element.BaseLayoutId == CatalogLayoutId && baseSeen.Add(element.BaseElement)) + { + var baseDesc = FindDesc(catalog, element.BaseElement); + if (baseDesc is not null) + { + uint e = FindIconEmpty(catalog, baseDesc, baseSeen); + if (e != 0) return e; + } } return 0; } - private static uint IconChildEmptyImage(ElementDesc proto) + private static uint IconEmptyState(ElementDesc icon) { - 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 + return DirectStateImage(icon); // some icons carry empty media on the DirectState } private static uint DirectStateImage(ElementDesc d) diff --git a/tests/AcDream.App.Tests/UI/Layout/ItemListCellTemplateTests.cs b/tests/AcDream.App.Tests/UI/Layout/ItemListCellTemplateTests.cs index 898f9aac..dc837367 100644 --- a/tests/AcDream.App.Tests/UI/Layout/ItemListCellTemplateTests.cs +++ b/tests/AcDream.App.Tests/UI/Layout/ItemListCellTemplateTests.cs @@ -56,4 +56,20 @@ public class ItemListCellTemplateTests Assert.True((sprite & 0xFF000000u) == 0x06000000u, $"{name}: 0x{sprite:X8} is not a 0x06 RenderSurface id"); } } + + [Fact] + public void Inventory_lists_resolve_the_pinned_retail_sprites() + { + var datDir = DatDir(); + if (datDir is null) return; // CI: no live dat — skip + + using var dats = new DatCollection(datDir, DatAccessType.Read); + // Pinned from the live dat + the 2026-06-22 visual gate. Contents = the 32x32 prototype's + // ItemSlot_Empty; the 36x36 container/main-pack resolve THROUGH inheritance to their inner + // ItemSlot_Empty (NOT the 0x06005D9C open-container triangle the first cut grabbed — a + // structural-only assertion let that wrong-but-valid 0x06 sprite pass, so we pin exacts). + Assert.Equal(0x06004D20u, ItemListCellTemplate.ResolveEmptySprite(dats, ContentsLayout, ContentsGrid)); + Assert.Equal(0x06000F6Eu, ItemListCellTemplate.ResolveEmptySprite(dats, BackpackLayout, SideBag)); + Assert.Equal(0x06000F6Eu, ItemListCellTemplate.ResolveEmptySprite(dats, BackpackLayout, MainPack)); + } } From 1f8dd7a93fa54f906ff9707fb477ab152a8a1263 Mon Sep 17 00:00:00 2001 From: Erik Date: Mon, 22 Jun 2026 12:49:29 +0200 Subject: [PATCH 090/133] =?UTF-8?q?docs:=20D.2b=20empty-slot=20art=20?= =?UTF-8?q?=E2=80=94=20inventory=20portion=20visually=20confirmed?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit User confirmed the container column reads correct after the inheritance-resolved fix (22d9231). Status: inventory empty-slot art FIXED + VISUALLY CONFIRMED. Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/ISSUES.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/ISSUES.md b/docs/ISSUES.md index 311fab2a..3b7fcf5b 100644 --- a/docs/ISSUES.md +++ b/docs/ISSUES.md @@ -5389,7 +5389,7 @@ The burden bar now reads the server's wire `EncumbranceVal` (PropertyInt 5) inst ## Inventory + equipment slots show the wrong empty-slot background art -**Status:** INVENTORY PORTION FIXED 2026-06-22 (pending visual gate) — contents grid + side-bag + main-pack empty art now dat-resolved (`ItemListCellTemplate`, commits `b862640`→`edbe5ba`). STILL OPEN: paperdoll equip-slot silhouettes (Sub-phase C) + main-pack backpack icon (AP-51). +**Status:** INVENTORY PORTION FIXED + VISUALLY CONFIRMED 2026-06-22 — contents grid + side-bag + main-pack empty art dat-resolved (`ItemListCellTemplate`, commits `b862640`→`22d9231`; user-confirmed the container column reads correct). STILL OPEN: paperdoll equip-slot silhouettes (Sub-phase C) + main-pack backpack icon (AP-51) + the selected-container triangle/selection-square (container-switching sub-phase, AP-56). **Filed:** 2026-06-21 **Component:** ui — D.2b inventory (UiItemSlot empty sprite + paperdoll equip slots) From ad27d1395ada12ae0a00874e4eba69267036b58e Mon Sep 17 00:00:00 2001 From: Erik Date: Mon, 22 Jun 2026 13:17:55 +0200 Subject: [PATCH 091/133] docs(D.2b): container-switching handoff + flag stale M1.5 banner MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Banks the next clean inventory win (container-switching: Use 0x0036 -> ViewContents 0x0196 full-replace + the selected-container indicator that retires AP-56) as a handoff for a fresh session, per the late-session-handoff lesson. Wire layer already exists; design sketch + open questions captured. Also flags the CLAUDE.md "Current state" banner as stale — it still tracks M1.5 (indoor world, 2026-06-14) while the active stream has been the D.2b retail-UI track for weeks. Left the milestone reframing for a fresh reconciliation against the milestones doc rather than a tired guess. Co-Authored-By: Claude Opus 4.8 (1M context) --- CLAUDE.md | 7 +++ .../2026-06-22-container-switching-handoff.md | 54 +++++++++++++++++++ 2 files changed, 61 insertions(+) create mode 100644 docs/research/2026-06-22-container-switching-handoff.md diff --git a/CLAUDE.md b/CLAUDE.md index 508e28d5..8cca4160 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -121,6 +121,13 @@ M2 (CombatMath) deferred. Detail in ISSUES (#135–#138) + the render/physics di Recent closes (2026-06-14): #135, #136. Keep this paragraph ≤6 lines + pointers — detail in the docs below, NOT here. +**⚠ Reconcile (banner stale as of 2026-06-22):** the active recent work stream has been the +parallel **D.2b retail-UI track** (inventory window / panels — the inventory empty-slot art +shipped + was visually confirmed this session; next is container-switching), NOT the M1.5 +dungeon items above. See [`claude-memory/project_d2b_retail_ui.md`] + the handoff +`docs/research/2026-06-22-container-switching-handoff.md`. Decide in the milestones doc whether +M1.5 is paused or D.2b retail-UI is its own milestone, then rewrite this banner. + For canonical state, read in this order: - [`docs/plans/2026-05-12-milestones.md`](docs/plans/2026-05-12-milestones.md) — milestone targets + freeze list per milestone - [`docs/plans/2026-04-11-roadmap.md`](docs/plans/2026-04-11-roadmap.md) — what's shipped, what's in flight, what's next diff --git a/docs/research/2026-06-22-container-switching-handoff.md b/docs/research/2026-06-22-container-switching-handoff.md new file mode 100644 index 00000000..168e6709 --- /dev/null +++ b/docs/research/2026-06-22-container-switching-handoff.md @@ -0,0 +1,54 @@ +# Handoff — D.2b inventory container-switching (next clean win) + +**Date:** 2026-06-22 (banked at the end of the empty-slot-art session, per [[feedback_session_handoff]]) +**Branch:** `claude/hopeful-maxwell-214a12`, tip `1f8dd7a` (+ this docs commit). `main` is a clean ff ancestor (68 commits behind). +**Status:** SCOPED, not started. Brainstorm explored the wire + controller; this captures the design sketch so a fresh session executes it with the live-wire round-trip getting proper attention. Run the full brainstorm → spec → plan → execute flow. +**Line numbers drift — grep the symbol.** + +--- + +## Why this is the next pick + +After the empty-slot-art arc (inventory contents/side-bag/main-pack empty cells now dat-resolve their art — see [[project_d2b_retail_ui]] + the empty-slot handoff), the side-bag column is **rendered but inert**: clicking a bag does nothing. Container-switching makes it functional AND closes the **AP-56** deferral (the selected-container triangle + green/yellow selection square live here). The user picked this over the paperdoll (Sub-phase C) because the paperdoll's empty equip slots are entangled with the heavy 3D doll viewport (retail empties show the doll), whereas container-switching is self-contained. + +This is the **first inventory feature with live two-way wire interaction** (a `Use` → `ViewContents` round-trip), a step up from the read-only display work so far — hence executing it fresh rather than tired. + +## Read first + +- This doc. +- `claude-memory/project_d2b_retail_ui.md` — the D.2b SSOT + the empty-slot-art SHIPPED entry (the resolver + AP-55/AP-56). +- `claude-memory/reference_retail_inventory_paperdoll.md` — the player-facing spec: the "preferred pack" auto-fit rules + the 7-side-bag / 102-main-pack capacities. +- `docs/architecture/retail-divergence-register.md` — **AP-56** (the selected-container indicator deferred — this phase retires it) + AP-53 (container-switch noted as deferred). + +## What's already in place (the wire layer is DONE) + +- **`InteractRequests.BuildUse` (`0x0036`)** — open/use a container by guid (`src/AcDream.Core.Net/Messages/InteractRequests.cs:38`). This is how retail opens a container. +- **`GameEvents.ParseViewContents` (`0x0196`)** — the server's full contents list `(ContainerGuid, [guid, containerType]×N)` (`src/AcDream.Core.Net/Messages/GameEvents.cs:372`). Handler at `GameEventWiring.cs:260`. +- **`WorldSession.SendNoLongerViewingContents` (`0x0195`)** — close a container view (`WorldSession.cs:1212`). +- **`ClientObjectTable.GetContents(containerId)`** — the per-container item index (`src/AcDream.Core/Items/ClientObjectTable.cs:313`). +- **The standing TODO** at `GameEventWiring.cs:256`: `ViewContents` currently does an **additive merge**; container-open must treat it as a **full REPLACE** (flush the container's prior membership first) so server-side removals don't linger. **This phase consumes that TODO.** + +## Design sketch (to be confirmed in the brainstorm) + +1. **Open-container state in `InventoryController`.** Add an `_openContainer` field (default = the player guid / main pack). `Populate` shows `GetContents(_openContainer)` instead of always the player. Repaint when the open container's membership changes (extend `Concerns`). +2. **Click a side-bag cell → open it.** The side-bag `UiItemSlot`s already carry the bag guid (bound in `Populate`). On click: set `_openContainer = bagGuid`; `SendUse(bagGuid)` (opens it server-side); when the previous container was a side bag, `SendNoLongerViewingContents(prevGuid)`. Click the main-pack cell (`0x100001C9`) → reset `_openContainer` to the player. +3. **`ViewContents 0x0196` → full REPLACE.** Flush the container's membership in `ClientObjectTable` before recording the entries (the TODO). Then `InventoryController` repaints the grid for `_openContainer`. +4. **Selected-container indicator (retires AP-56).** Draw the open-container triangle `0x06005D9C` + the green/yellow selection square on the selected side-bag cell — a new `UiItemSlot` "selected" overlay (model it on the existing `DragAccept` overlay in `UiItemSlot.OnDraw`; guard `id != 0` per [[feedback_ui_resolve_zero_magenta]]). +5. **Caption.** "Contents of Backpack" → the open container's name when a side bag is selected (the caption host is already driven by `InventoryController.AttachCaption`). + +## Open questions for the brainstorm + +- **The green/yellow selection-square sprite id** — the triangle is `0x06005D9C` (from the container prototype `0x1000033F` child `0x10000450`); the selection square's id is NOT yet found. Dump `0x21000037` / the gmBackpackUI layout (`0x21000022`) for the selected-container state media, or cdb a live retail client with a side bag open. +- **Are a side bag's contents pre-known, or do they arrive only on open?** Likely the latter (retail sends contents on open via `ViewContents`, not upfront) — so the `Use` round-trip is required, and `GetContents(bagGuid)` is empty until the reply lands. Confirm against a live capture (WireMCP on `127.0.0.1:9000`, or watch the `ViewContents` handler at the live gate). +- **Preferred-pack semantics** (from `reference_retail_inventory_paperdoll`): opening a pack makes it the "preferred" pack for auto-fit. Out of scope for the first cut (display-only switching), but note it. +- **Empty-container display** — a side bag with no contents shows an empty 24-slot grid (its `ItemsCapacity`, default 24 per AP-52/the wiki). Pad like the main pack does. + +## Acceptance / scope + +- First cut: click a side bag → its contents show in the grid; the selected bag shows the triangle + selection square; click the main pack → back. `ViewContents` is a full replace. Build + full suite green; **visual gate** (F12, the user clicks bags and confirms the grid switches + the selected indicator). +- Retires **AP-56** (selected-container indicator) in the same commit that draws it. +- Out of scope: preferred-pack auto-fit, drag-INTO a side bag (that's B-Drag), the side-bag column scrollbar (`0x100001CB`, inert — 7 fit). + +## New-session prompt + +> Continue acdream's D.2b retail-UI inventory arc. **Read `docs/research/2026-06-22-container-switching-handoff.md` first.** The empty-slot art shipped + was visually confirmed (`claude/hopeful-maxwell-214a12`, tip `1f8dd7a`). **Next: inventory container-switching** — click a side bag → show its contents in the grid (via `Use 0x0036` → `ViewContents 0x0196` full-replace), draw the selected-container triangle `0x06005D9C` + green/yellow selection square (retires AP-56), click the main pack to go back. The wire builders/parsers all exist (`BuildUse`, `ParseViewContents`, `SendNoLongerViewingContents`); consume the `GameEventWiring.cs:256` full-REPLACE TODO. Run the full brainstorm → spec → plan → subagent-driven → visual-gate flow. **DO NOT auto-kill the running client** — launch with plain `dotnet run`; if a rebuild is locked, ask the user to close it. From bfdab5b9e00eb73a1da9bd05efb7b698c284d22a Mon Sep 17 00:00:00 2001 From: Erik Date: Mon, 22 Jun 2026 14:15:42 +0200 Subject: [PATCH 092/133] docs(D.2b): container-switching design spec MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Brainstorm output for inventory container-switching. Key decision from the brainstorm: the handoff conflated two orthogonal retail mechanisms — the open-container TRIANGLE (0x06005D9C, UpdateOpenContainerIndicator) and the selected-item SQUARE (0x06004D21, ItemList_SetSelectedItem). User confirmed both are real (a bag is just an item, so it gets the square too) and chose to ship both this phase, the square uniform across grid+bags and visual-only (no selected-object-bar wiring yet). Pins the ViewContents full-REPLACE (ACE writes entries OrderBy PlacementPosition, no slot field -> ContainerSlot=index), the Use 0x0036 -> ViewContents 0x0196 round-trip, and the 6 touched files. Co-Authored-By: Claude Opus 4.8 (1M context) --- ...26-06-22-d2b-container-switching-design.md | 151 ++++++++++++++++++ 1 file changed, 151 insertions(+) create mode 100644 docs/superpowers/specs/2026-06-22-d2b-container-switching-design.md diff --git a/docs/superpowers/specs/2026-06-22-d2b-container-switching-design.md b/docs/superpowers/specs/2026-06-22-d2b-container-switching-design.md new file mode 100644 index 00000000..9d32c455 --- /dev/null +++ b/docs/superpowers/specs/2026-06-22-d2b-container-switching-design.md @@ -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 "). + +**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 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. From 24a0b841626a5e1a6915c5e0525c1c257267ed90 Mon Sep 17 00:00:00 2001 From: Erik Date: Mon, 22 Jun 2026 14:22:39 +0200 Subject: [PATCH 093/133] docs(D.2b): container-switching implementation plan 8-task TDD plan: ReplaceContents (Core) -> ViewContents full-replace wiring (Core.Net) -> SendUse wrapper -> UiItemSlot triangle/square overlays -> InventoryController _openContainer/_selectedItem + click roles + indicators -> GameWindow callback wiring -> divergence register -> full-suite + visual gate. Exact code per step. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../2026-06-22-d2b-container-switching.md | 819 ++++++++++++++++++ 1 file changed, 819 insertions(+) create mode 100644 docs/superpowers/plans/2026-06-22-d2b-container-switching.md diff --git a/docs/superpowers/plans/2026-06-22-d2b-container-switching.md b/docs/superpowers/plans/2026-06-22-d2b-container-switching.md new file mode 100644 index 00000000..a959700c --- /dev/null +++ b/docs/superpowers/plans/2026-06-22-d2b-container-switching.md @@ -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 + /// + /// Replace a container's entire membership with (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. + /// + public void ReplaceContents(uint containerId, IReadOnlyList guids) + { + ArgumentNullException.ThrowIfNull(guids); + if (containerId == 0) return; + + var keep = new HashSet(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) " +``` + +--- + +## 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) " +``` + +--- + +## 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 + /// 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. + 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) " +``` + +--- + +## 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 + /// 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. + public bool IsOpenContainer { get; set; } + /// Open-container triangle sprite (element 0x10000450 on the container prototype + /// 0x1000033F). Configurable; guard id != 0 before resolving. + public uint OpenContainerSprite { get; set; } = 0x06005D9Cu; + + /// 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. + public bool Selected { get; set; } + /// 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). + 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) " +``` + +--- + +## 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? uses = null, List? 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(); + 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(); + var closes = new List(); + 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(); + var closes = new List(); + 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()); + + 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(); + var closes = new List(); + 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? _sendUse; + private readonly Action? _sendNoLongerViewing; +``` + +(c) Add ctor parameters (append to the existing private ctor parameter list, after `mainPackEmptySprite`): +```csharp + uint mainPackEmptySprite, + Action? sendUse, + Action? 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? sendUse = null, + Action? 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(); + } + + /// 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. + private uint EffectiveOpen() => _openContainer != 0 ? _openContainer : _playerGuid(); + + /// Add a populated cell wired to its click role: container cell → open+select, + /// item cell → select-only. + 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 }); + + /// Select an item (panel-wide green square) without changing the open container or + /// touching the wire. Retail: UIElement_ItemList::ItemList_SetSelectedItem (0x004e2fe0). + private void SelectItem(uint guid) + { + if (guid == 0) return; + _selectedItem = guid; + ApplyIndicators(); + } + + /// 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. + 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 + } + + /// Stamp the open-container triangle + selected-item square onto every cell per the + /// retail keying (item.itemID == openContainerId / selectedItemId). + 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 + /// The open container's display name for the contents caption. + 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) " +``` + +--- + +## 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) " +``` + +--- + +## 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) " +``` + +--- + +## 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)` consistent T1↔T2. `SendUse(uint)` T3↔T6. `IsOpenContainer`/`OpenContainerSprite`/`Selected`/`SelectedSprite` consistent T4↔T5. `Bind(..., Action? sendUse, Action? 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. From 48bf752cf1cf8553c9257def1acdb61fea40e42f Mon Sep 17 00:00:00 2001 From: Erik Date: Mon, 22 Jun 2026 14:27:13 +0200 Subject: [PATCH 094/133] =?UTF-8?q?feat(D.2b):=20ClientObjectTable.Replace?= =?UTF-8?q?Contents=20=E2=80=94=20authoritative=20full-replace=20for=20Vie?= =?UTF-8?q?wContents?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 (1M context) --- src/AcDream.Core/Items/ClientObjectTable.cs | 46 +++++++++++++++++++ .../Items/ClientObjectTableTests.cs | 39 ++++++++++++++++ 2 files changed, 85 insertions(+) diff --git a/src/AcDream.Core/Items/ClientObjectTable.cs b/src/AcDream.Core/Items/ClientObjectTable.cs index b789c11c..558d12f4 100644 --- a/src/AcDream.Core/Items/ClientObjectTable.cs +++ b/src/AcDream.Core/Items/ClientObjectTable.cs @@ -314,6 +314,52 @@ public sealed class ClientObjectTable _containerIndex.TryGetValue(containerId, out var l) ? l.ToArray() : System.Array.Empty(); + /// + /// Replace a container's entire membership with (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. + /// + public void ReplaceContents(uint containerId, IReadOnlyList guids) + { + ArgumentNullException.ThrowIfNull(guids); + if (containerId == 0) return; + + var keep = new HashSet(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); + } + } + /// /// Σ Burden over every object carried by : items /// whose container chain roots at the owner (pack + side-bag contents, retail diff --git a/tests/AcDream.Core.Tests/Items/ClientObjectTableTests.cs b/tests/AcDream.Core.Tests/Items/ClientObjectTableTests.cs index 3d873cca..d7d58232 100644 --- a/tests/AcDream.Core.Tests/Items/ClientObjectTableTests.cs +++ b/tests/AcDream.Core.Tests/Items/ClientObjectTableTests.cs @@ -319,6 +319,45 @@ public sealed class ClientObjectTableTests Assert.True((o.Type & ItemType.Creature) != 0); // LiveItemType(guid) & Creature drives creature targeting } + [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)); + } + [Fact] public void ContainerIndex_SlotChange_ResortsInPlace() // guards the Reindex same-container early-out { From ad9d2651c1990ac16e49555c1459a4840b9734b2 Mon Sep 17 00:00:00 2001 From: Erik Date: Mon, 22 Jun 2026 14:28:08 +0200 Subject: [PATCH 095/133] feat(D.2b): ViewContents wiring is a full REPLACE (consumes the GameEventWiring TODO) Co-Authored-By: Claude Opus 4.8 (1M context) --- src/AcDream.Core.Net/GameEventWiring.cs | 17 +++++------ .../GameEventWiringTests.cs | 29 +++++++++++++++++++ 2 files changed, 37 insertions(+), 9 deletions(-) diff --git a/src/AcDream.Core.Net/GameEventWiring.cs b/src/AcDream.Core.Net/GameEventWiring.cs index c9aec8a5..fc425532 100644 --- a/src/AcDream.Core.Net/GameEventWiring.cs +++ b/src/AcDream.Core.Net/GameEventWiring.cs @@ -250,19 +250,18 @@ public static class GameEventWiring newSlot: (int)p.Value.Placement); }); - // B-Wire: ViewContents (0x0196) — the server's full contents list for a - // container you opened. Record membership for each entry so the table is - // correct before the container-open UI mounts the second item list. - // TODO(container-open): ViewContents is the AUTHORITATIVE full snapshot. When the - // container-open phase consumes it, treat it as a full REPLACE (flush the container's - // prior membership first), not the additive merge below — otherwise items removed - // server-side since the last open would linger. + // 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; - foreach (var entry in p.Value.Items) - items.RecordMembership(entry.Guid, containerId: p.Value.ContainerGuid); + 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); }); // B-Wire: InventoryPutObjectIn3D (0x019A) — server confirms an item dropped diff --git a/tests/AcDream.Core.Net.Tests/GameEventWiringTests.cs b/tests/AcDream.Core.Net.Tests/GameEventWiringTests.cs index 96ae5a54..24d868f3 100644 --- a/tests/AcDream.Core.Net.Tests/GameEventWiringTests.cs +++ b/tests/AcDream.Core.Net.Tests/GameEventWiringTests.cs @@ -556,6 +556,35 @@ public sealed class GameEventWiringTests Assert.Equal(0x500000C9u, items.Get(0x50000A02u)!.ContainerId); } + [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); + } + [Fact] public void WireAll_InventoryPutObjectIn3D_UnparentsFromContainer() { From 3c5399f4b40d908891167e1fbc6f2bb332aa4e17 Mon Sep 17 00:00:00 2001 From: Erik Date: Mon, 22 Jun 2026 14:28:40 +0200 Subject: [PATCH 096/133] =?UTF-8?q?feat(D.2b):=20WorldSession.SendUse=20?= =?UTF-8?q?=E2=80=94=20thin=20Use=200x0036=20wire=20wrapper=20for=20contai?= =?UTF-8?q?ner-open?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 (1M context) --- src/AcDream.Core.Net/WorldSession.cs | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/src/AcDream.Core.Net/WorldSession.cs b/src/AcDream.Core.Net/WorldSession.cs index a904140c..47785496 100644 --- a/src/AcDream.Core.Net/WorldSession.cs +++ b/src/AcDream.Core.Net/WorldSession.cs @@ -1215,6 +1215,15 @@ public sealed class WorldSession : IDisposable SendGameAction(InventoryActions.BuildNoLongerViewingContents(seq, containerGuid)); } + /// 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. + public void SendUse(uint targetGuid) + { + uint seq = NextGameActionSequence(); + SendGameAction(InteractRequests.BuildUse(seq, targetGuid)); + } + /// Send retail QueryHealth (0x01BF). Server replies UpdateHealth (0x01C0). /// /// Retail anchor: CM_Combat::Event_QueryHealth / gmToolbarUI::HandleSelectionChanged:198635 From 842462e375e711d2d1f4f4e0dbb1149ae5990884 Mon Sep 17 00:00:00 2001 From: Erik Date: Mon, 22 Jun 2026 14:29:39 +0200 Subject: [PATCH 097/133] feat(D.2b): UiItemSlot open-container triangle + selected-item square overlays Co-Authored-By: Claude Opus 4.8 (1M context) --- src/AcDream.App/UI/UiItemSlot.cs | 34 +++++++++++++++++++ tests/AcDream.App.Tests/UI/UiItemSlotTests.cs | 16 +++++++++ 2 files changed, 50 insertions(+) diff --git a/src/AcDream.App/UI/UiItemSlot.cs b/src/AcDream.App/UI/UiItemSlot.cs index 35973c09..a3c95895 100644 --- a/src/AcDream.App/UI/UiItemSlot.cs +++ b/src/AcDream.App/UI/UiItemSlot.cs @@ -38,6 +38,23 @@ public sealed class UiItemSlot : UiElement /// state id 0x10000040). public uint DragRejectSprite { get; set; } = 0x060011F8u; + /// 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. + public bool IsOpenContainer { get; set; } + /// Open-container triangle sprite (element 0x10000450 on the container prototype + /// 0x1000033F). Configurable; guard id != 0 before resolving. + public uint OpenContainerSprite { get; set; } = 0x06005D9Cu; + + /// 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. + public bool Selected { get; set; } + /// 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). + public uint SelectedSprite { get; set; } = 0x06004D21u; + /// Accept/reject overlay state while a drag hovers this cell. public enum DragAcceptState { None, Accept, Reject } private DragAcceptState _dragAccept = DragAcceptState.None; @@ -220,6 +237,23 @@ public sealed class UiItemSlot : UiElement } } + // 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); + } + // 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). diff --git a/tests/AcDream.App.Tests/UI/UiItemSlotTests.cs b/tests/AcDream.App.Tests/UI/UiItemSlotTests.cs index f99e9dc5..c0e91753 100644 --- a/tests/AcDream.App.Tests/UI/UiItemSlotTests.cs +++ b/tests/AcDream.App.Tests/UI/UiItemSlotTests.cs @@ -141,4 +141,20 @@ public class UiItemSlotTests s.SetShortcutNum(0, peace: true); Assert.Null(s.ActiveDigitArray()); } + + // ── 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); } From 7407a71d68c9d162d5c8ecf71006c6efb647ecdc Mon Sep 17 00:00:00 2001 From: Erik Date: Mon, 22 Jun 2026 14:32:31 +0200 Subject: [PATCH 098/133] feat(D.2b): InventoryController container-switching + open/selected indicators Co-Authored-By: Claude Opus 4.8 (1M context) --- .../UI/Layout/InventoryController.cs | 166 +++++++++++++----- .../UI/Layout/InventoryControllerTests.cs | 112 +++++++++++- 2 files changed, 234 insertions(+), 44 deletions(-) diff --git a/src/AcDream.App/UI/Layout/InventoryController.cs b/src/AcDream.App/UI/Layout/InventoryController.cs index 81007e89..26a71386 100644 --- a/src/AcDream.App/UI/Layout/InventoryController.cs +++ b/src/AcDream.App/UI/Layout/InventoryController.cs @@ -37,6 +37,7 @@ public sealed class InventoryController private const float BackpackCellPx = 36f; // gmBackpackUI column cells (0x100001C9/CA = 36px) private const int SideBagSlots = 7; // 0x100001CA is 36x252 = 7 slots private const int MainPackSlots = 102; // retail main-pack capacity (Wiki: "up to 102 items") + private const int SidePackSlots = 24; // standard side-pack capacity (reference_retail_inventory_paperdoll) private readonly ClientObjectTable _objects; private readonly Func _playerGuid; @@ -52,6 +53,11 @@ public sealed class InventoryController private int _burdenPercent; private static readonly Vector4 CaptionColor = new(1f, 1f, 1f, 1f); + 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? _sendUse; + private readonly Action? _sendNoLongerViewing; + // 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 @@ -65,12 +71,16 @@ public sealed class InventoryController UiDatFont? datFont, uint contentsEmptySprite, uint sideBagEmptySprite, - uint mainPackEmptySprite) + uint mainPackEmptySprite, + Action? sendUse, + Action? sendNoLongerViewing) { _objects = objects; _playerGuid = playerGuid; _iconIds = iconIds; _strength = strength; + _sendUse = sendUse; + _sendNoLongerViewing = sendNoLongerViewing; _contentsGrid = layout.FindElement(ContentsGridId) as UiItemList; _containerList = layout.FindElement(ContainerListId) as UiItemList; @@ -123,7 +133,7 @@ public sealed class InventoryController // elements resolve to UiText). "Contents of Backpack" + "%d%%" are procedural in retail // (gm3DItemsUI/gmBackpackUI PostInit/SetLoadLevel); "Burden" is the dat label. (Spec §5.) AttachCaption(layout.FindElement(BurdenCaptionId), () => "Burden", datFont); - AttachCaption(layout.FindElement(ContentsCaptionId), () => "Contents of Backpack", datFont); + AttachCaption(layout.FindElement(ContentsCaptionId), () => "Contents of " + OpenContainerName(), datFont); AttachCaption(layout.FindElement(BurdenTextId), () => _burdenPercent + "%", datFont); // Rebuild on any change to the player's possessions. @@ -144,9 +154,12 @@ public sealed class InventoryController UiDatFont? datFont, uint contentsEmptySprite = 0u, uint sideBagEmptySprite = 0u, - uint mainPackEmptySprite = 0u) + uint mainPackEmptySprite = 0u, + Action? sendUse = null, + Action? sendNoLongerViewing = null) => new InventoryController(layout, objects, playerGuid, iconIds, strength, datFont, - contentsEmptySprite, sideBagEmptySprite, mainPackEmptySprite); + contentsEmptySprite, sideBagEmptySprite, mainPackEmptySprite, + sendUse, sendNoLongerViewing); private void OnObjectChanged(ClientObject o) { if (Concerns(o)) Populate(); } private void OnObjectMoved(ClientObject o, uint from, uint to) @@ -156,59 +169,49 @@ public sealed class InventoryController private bool Concerns(ClientObject o) { uint p = _playerGuid(); - return o.ObjectId == p // B-Wire: the player object IS the burden source + 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 } - /// 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). The - /// container index () IS retail's per-container - /// item list — items enter it via the CreateObject (Ingest) / server-move (MoveItem) flows - /// that call Reindex, slot-sorted. public void Populate() { uint p = _playerGuid(); - var contents = _objects.GetContents(p); + uint open = EffectiveOpen(); _contentsGrid?.Flush(); _containerList?.Flush(); - foreach (var guid in contents) + // 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) continue; - // Equipped items belong on the paperdoll (Sub-phase C), never in the pack grid or - // the side-bag list. A mid-session self-wield routes an item through - // MoveItem(item, WielderGuid=player), so it transiently lands in GetContents(player); - // retail's gm3DItemsUI shows pack contents only, gated on the wielded location. - if (item.CurrentlyEquippedLocation != EquipMask.None) continue; + if (item is null || item.CurrentlyEquippedLocation != EquipMask.None) 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); + if (isBag) AddCell(_containerList, guid, isContainer: true); } - // Contents grid: show the FULL main-pack capacity (default 102) — occupied slots + empty - // frames — so the pack reads like retail (a fixed 102-slot grid you scroll), not just the - // loose items. The grid shows the SELECTED pack (default = the main pack / player); side-pack - // contents are a container-switch (later). Capacity = the player's ItemsCapacity, else 102. + // 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(p)?.ItemsCapacity ?? 0; - int slots = cap > 0 ? cap : MainPackSlots; - while (_contentsGrid.GetNumUIItems() < slots) - _contentsGrid.AddItem(new UiItemSlot { SpriteResolve = _contentsGrid.SpriteResolve }); + int cap = _objects.Get(open)?.ItemsCapacity ?? 0; + int slots = cap > 0 ? cap : (open == p ? MainPackSlots : SidePackSlots); + while (_contentsGrid.GetNumUIItems() < slots) AddEmptyCell(_contentsGrid); } - // Side-bag column: pad with empty slot frames up to the player's container capacity - // (clamped to the 7-slot column) so it reads like retail (bags on top, empty frames - // below) rather than one lone cell. Falls back to the full 7 slots when capacity is - // unknown (divergence AP-52). + // 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; @@ -216,24 +219,103 @@ public sealed class InventoryController int slots = capacity > 0 ? capacity : SideBagSlots; slots = Math.Max(slots, bags); slots = Math.Min(slots, SideBagSlots); - while (_containerList.GetNumUIItems() < slots) - _containerList.AddItem(new UiItemSlot { SpriteResolve = _containerList.SpriteResolve }); + while (_containerList.GetNumUIItems() < slots) AddEmptyCell(_containerList); } - // 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. + // 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(); } + /// 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. + private uint EffectiveOpen() => _openContainer != 0 ? _openContainer : _playerGuid(); + + /// Add a populated cell wired to its click role: container cell → open+select, + /// item cell → select-only. + 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 }); + + /// Select an item (panel-wide green square) without changing the open container or + /// touching the wire. Retail: UIElement_ItemList::ItemList_SetSelectedItem (0x004e2fe0). + private void SelectItem(uint guid) + { + if (guid == 0) return; + _selectedItem = guid; + ApplyIndicators(); + } + + /// 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. + 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 + } + + /// Stamp the open-container triangle + selected-item square onto every cell per the + /// retail keying (item.itemID == openContainerId / selectedItemId). + 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; + } + } + + /// The open container's display name for the contents caption. + private string OpenContainerName() + { + uint open = EffectiveOpen(); + return open == _playerGuid() ? "Backpack" : (_objects.Get(open)?.Name ?? "Backpack"); + } + private void AttachCaption(UiElement? host, Func text, UiDatFont? datFont) { if (host is null) return; diff --git a/tests/AcDream.App.Tests/UI/Layout/InventoryControllerTests.cs b/tests/AcDream.App.Tests/UI/Layout/InventoryControllerTests.cs index fc2a5de2..f4fd5526 100644 --- a/tests/AcDream.App.Tests/UI/Layout/InventoryControllerTests.cs +++ b/tests/AcDream.App.Tests/UI/Layout/InventoryControllerTests.cs @@ -51,10 +51,19 @@ public class InventoryControllerTests } private static InventoryController Bind(ImportedLayout layout, ClientObjectTable objects, - int? strength = 100) + int? strength = 100, List? uses = null, List? closes = null) => InventoryController.Bind(layout, objects, () => Player, iconIds: (_, _, _, _, _) => 0u, // no real GL upload in tests - strength: () => strength, datFont: null); + 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); + } // Place an object in a container at a slot via the faithful production path: // AddOrUpdate registers it, MoveItem sets ContainerId+ContainerSlot and calls Reindex @@ -253,6 +262,105 @@ public class InventoryControllerTests Assert.Equal(0x06005D9Cu, top.GetItem(0)!.EmptySprite); // the main-pack cell } + [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(); + 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(); + var closes = new List(); + 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(); + var closes = new List(); + 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()); + + 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(); + var closes = new List(); + 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 + } + // Reads the text of the UiText caption child attached by the controller. private static string CaptionText(UiElement host) { From ba5f974f56f0db59366b943b279958d69324532c Mon Sep 17 00:00:00 2001 From: Erik Date: Mon, 22 Jun 2026 14:32:56 +0200 Subject: [PATCH 099/133] feat(D.2b): wire InventoryController container-open callbacks to the live session Co-Authored-By: Claude Opus 4.8 (1M context) --- src/AcDream.App/Rendering/GameWindow.cs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs index 308739d2..a52bd3f8 100644 --- a/src/AcDream.App/Rendering/GameWindow.cs +++ b/src/AcDream.App/Rendering/GameWindow.cs @@ -2238,7 +2238,9 @@ public sealed class GameWindow : IDisposable datFont: vitalsDatFont, contentsEmptySprite: contentsEmpty, sideBagEmptySprite: sideBagEmpty, - mainPackEmptySprite: mainPackEmpty); + mainPackEmptySprite: mainPackEmpty, + sendUse: g => _liveSession?.SendUse(g), + sendNoLongerViewing: g => _liveSession?.SendNoLongerViewingContents(g)); Console.WriteLine("[D.2b-B] retail inventory window from LayoutDesc importer (0x21000023)."); } From 895d8eed804dd0918b668af15bf86aefdd395c59 Mon Sep 17 00:00:00 2001 From: Erik Date: Mon, 22 Jun 2026 14:33:53 +0200 Subject: [PATCH 100/133] =?UTF-8?q?docs(D.2b):=20divergence=20register=20?= =?UTF-8?q?=E2=80=94=20retire=20AP-56,=20reword=20AP-53,=20add=20overlay/s?= =?UTF-8?q?election=20rows?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/architecture/retail-divergence-register.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/docs/architecture/retail-divergence-register.md b/docs/architecture/retail-divergence-register.md index 6c787c9e..243c83fc 100644 --- a/docs/architecture/retail-divergence-register.md +++ b/docs/architecture/retail-divergence-register.md @@ -152,10 +152,11 @@ accepted-divergence entries (#96, #49, #50). | AP-50 | Burden meter orientation/direction set programmatically (`Vertical=true`, `FillFromBottom=true`) rather than reading retail's `m_eDirection` property from the LayoutDesc element (property id `0x6f`). | `src/AcDream.App/UI/Layout/InventoryController.cs`; `src/AcDream.App/UI/UiMeter.cs` (`Vertical`/`FillFromBottom`) | The `m_eDirection` property is not yet read by `ElementReader`; bottom-up fill is visually confirmed against retail's burden bar art. Retire when `0x6f` is wired through `ElementReader`→`DatWidgetFactory`. | Wrong fill direction (top-down instead of bottom-up, or horizontal) if a future meter whose art requires a different direction is forced through the same code path. | `UIElement_Meter::DrawChildren` @0x46fbd0; property `0x6f` (`m_eDirection`) in the LayoutDesc | | AP-51 | Main-pack `m_topContainer` cell (element `0x100001C9`) uses a placeholder/empty icon (`tex=0u`), not a weenie-driven backpack icon. | `src/AcDream.App/UI/Layout/InventoryController.cs` (`Populate`) | The player's own container entity has no item `IconId` on the client (it's a character, not an item); retail uses the equipped-pack's icon via the character's equipped-pack `CreateObject`. The equipped-pack DID path is deferred to Sub-phase C. | Main-pack cell renders blank (no icon) where retail shows the equipped backpack art — cosmetic gap visible when F12 is open. | `gmBackpackUI::PostInit` @0x4a8520 (m_topContainer bind); `CreateObject` weenie icon path | | AP-52 | Side-bag column slot count defaults to 7 (the dat column height) when the player's `ContainersCapacity` is absent/0. | `src/AcDream.App/UI/Layout/InventoryController.cs` (`Populate`) | `ContainersCapacity` is not reliably on the player ClientObject yet; 7 is the standard side-pack cap + the dat column (`0x100001CA`, 36×252) holds exactly 7. | An 8th-pack (Shadow of the Seventh Mule aug) character shows one too few side-bag slots — cosmetic. | retail gmBackpackUI side-pack column; ACE `ContainersCapacity` | -| AP-53 | Contents grid slot count defaults to 102 when the player's `ItemsCapacity` is absent/0; the grid always shows the main pack (container-switch to a side pack's 24 not wired). | `src/AcDream.App/UI/Layout/InventoryController.cs` (`Populate`) | `ItemsCapacity` is not reliably on the player ClientObject yet; 102 is the retail main-pack standard. ViewContents-driven container switching is a later sub-phase. | A non-102 pack shows the wrong empty-slot count; selecting a side pack doesn't show its 24-slot contents yet. | retail main-pack capacity 102; ACE `ItemsCapacity`; `ViewContents 0x0196` (deferred) | +| AP-53 | Contents grid slot count defaults to 102 (main pack) or 24 (side bag) when the open container's `ItemsCapacity` is absent/0. Container-switching is now wired (D.2b): clicking a side bag sends `Use 0x0036` and the grid repopulates from `ViewContents 0x0196`. This row covers only the capacity *default* when the server omits `ItemsCapacity`. | `src/AcDream.App/UI/Layout/InventoryController.cs` (`Populate`) | `ItemsCapacity` is not reliably on every ClientObject yet; 102/24 are the retail standard capacities. Retire when `ItemsCapacity` is confirmed delivered for all containers. | A non-102/24 pack shows the wrong empty-slot count — cosmetic only. | retail main-pack capacity 102; side-pack 24; ACE `ItemsCapacity`; `ViewContents 0x0196` | | AP-54 | Inventory window vertical resize is a toolkit approximation: bottom-edge drag, expand-only (Min = dat default 372 px, Max = 560 px constant), contents grid/sub-window/scrollbar/backdrop stretched via overridden `Left\|Top\|Bottom` anchors. | `src/AcDream.App/Rendering/GameWindow.cs` (inventory frame setup) | retail's gmInventoryUI resize lives in keystone.dll (no decomp); the anchor-stretch + 372..560 range matches the observed retail behavior for the dev loop. | The expand range / which elements reflow could differ from retail (e.g. retail may allow shrink-below-default); shrink is intentionally disabled for now. | retail gmInventoryUI resize (keystone.dll, no decomp); `UiNineSlicePanel` resize | | 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 | The container/main-pack empty cells render only the empty-slot BACKGROUND (the inner `ItemSlot_Empty` `0x06000F6E`, resolved through `BaseElement` inheritance), not the open/selected-container indicator overlay — the triangle `0x06005D9C` + a green/yellow selection square that retail draws on the SELECTED container only. The indicator is deferred to the container-switching sub-phase. | `src/AcDream.App/UI/Layout/ItemListCellTemplate.cs` (`FindIconEmpty`); `src/AcDream.App/UI/Layout/InventoryController.cs` | acdream has no selected-container state yet; the first cut wrongly stamped the `0x06005D9C` triangle onto EVERY empty cell (frame-first heuristic) — omitting it entirely is correct until selection state exists. Retire when container-switching draws the indicator on the selected pack. | Empty container cells show only the dark slot background; the selected container lacks its triangle + selection square until container-switching ships. | `UIElement_ItemList::InternalCreateItem` 0x004e3570; prototype `0x1000033F` child `0x10000450` (`0x06005D9C`) | +| AP-57 | 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. | `src/AcDream.App/UI/UiItemSlot.cs`; `src/AcDream.App/UI/Layout/InventoryController.cs` | The procedural overlay produces the correct visual result; the 36×36 container prototype (`0x1000033F`) lacks the square child, so the procedural overlay is the only way to show the square on a selected bag — the dat-state path retail uses would require the prototype to be richer than it is. | 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 for the open/selected indicator cases. | `UIElement_ItemList::UpdateOpenContainerIndicator` 0x004e3070; `ItemList_SetSelectedItem` 0x004e2fe0; `SetOpenContainerState` 0x004e1200; `SetSelectedState` 0x004e1240 | +| AP-58 | Inventory item **selection is panel-local + visual-only** — clicking a grid item moves the green square but does not wire to the selected-object bar (`SelectedObjectController`) or to global 3D-world selection. | `src/AcDream.App/UI/Layout/InventoryController.cs` (`SelectItem`) | The selected-object bar + 3D-world selection wiring is deferred to the selection follow-up; the panel-local square is the correct first step. | Selecting an inventory item doesn't populate the selected-object strip as retail does — functional gap deferred. | `gmInventoryUI`/`gmBackpackUI` item-select → `ClientUISystem::SetSelectedObject`; `SelectedObjectController` | --- From 9dc44c3a3d8f2a82cfd0e38e8980c4771b7ed72f Mon Sep 17 00:00:00 2001 From: Erik Date: Mon, 22 Jun 2026 14:44:44 +0200 Subject: [PATCH 101/133] =?UTF-8?q?docs(D.2b):=20address=20Opus=20review?= =?UTF-8?q?=20nits=20=E2=80=94=20update=20stale=20class=20summary=20+=20Ef?= =?UTF-8?q?fectiveOpen=20note?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Comment-only. (1) the InventoryController summary said "Read-only: no container switching" — now live. (2) note the _openContainer 0-vs-playerGuid sentinel equivalence so the dual main-pack path isn't mistaken for dead code. No behavior change. Code-quality review verdict: APPROVED (ship to gate). Co-Authored-By: Claude Opus 4.8 (1M context) --- src/AcDream.App/UI/Layout/InventoryController.cs | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/AcDream.App/UI/Layout/InventoryController.cs b/src/AcDream.App/UI/Layout/InventoryController.cs index 26a71386..cdb4dc48 100644 --- a/src/AcDream.App/UI/Layout/InventoryController.cs +++ b/src/AcDream.App/UI/Layout/InventoryController.cs @@ -8,7 +8,8 @@ namespace AcDream.App.UI.Layout; /// Binds the imported gmInventoryUI tree (LayoutDesc 0x21000023) and populates it from /// . 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). +/// Container-switching is live (click a side bag → Use 0x0036 → ViewContents 0x0196 full-replace); +/// drag-into-bag / wield-drop wire are later sub-phases. /// public sealed class InventoryController { @@ -237,7 +238,9 @@ public sealed class InventoryController } /// 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. + /// Resolved live (not cached at ctor) so a late-arriving player guid is handled. The default + /// sentinel is 0; once the main pack is explicitly opened, _openContainer holds the player + /// guid instead — both resolve here to the same main-pack container, so the paths are equivalent. private uint EffectiveOpen() => _openContainer != 0 ? _openContainer : _playerGuid(); /// Add a populated cell wired to its click role: container cell → open+select, From c71b32f73dae0bff5df6589891dba159d431bd51 Mon Sep 17 00:00:00 2001 From: Erik Date: Mon, 22 Jun 2026 15:04:08 +0200 Subject: [PATCH 102/133] feat(D.2b): main-pack cell draws the constant backpack icon (AP-51) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The m_topContainer cell (0x100001C9) rendered blank (tex=0). Retail's IconData::RenderIcons (0x0058d1ee) has an IsThePlayer() branch that draws a CONSTANT backpack — m_idIcon = GetDIDByEnum(0x10000004, 7) = 0x060011F4, m_itemType = TYPE_CONTAINER — NOT the player's body icon (the original AP-51 "equipped-pack weenie icon" premise was wrong). Compose that base over the Container type-underlay via the existing _iconIds delegate. Verified vs decomp (407546-407549) + IconComposer.GetIcon (base=arg2, type drives underlay) + a live dat dump (map 0x25000008 index 7 = 0x060011F4). Test locks type+literal. AP-51 reworded to the residual hardcoded-vs-runtime-resolve nuance (cf. AP-55). Co-Authored-By: Claude Opus 4.8 (1M context) --- .../architecture/retail-divergence-register.md | 2 +- .../UI/Layout/InventoryController.cs | 8 +++++++- .../UI/Layout/InventoryControllerTests.cs | 18 ++++++++++++++++++ 3 files changed, 26 insertions(+), 2 deletions(-) diff --git a/docs/architecture/retail-divergence-register.md b/docs/architecture/retail-divergence-register.md index 243c83fc..d578fef3 100644 --- a/docs/architecture/retail-divergence-register.md +++ b/docs/architecture/retail-divergence-register.md @@ -150,7 +150,7 @@ accepted-divergence entries (#96, #49, #50). | AP-48 | Inventory burden reads the player's wire `EncumbranceVal` (PropertyInt 5) when present — delivered by B-Wire (login PD-bundle `UpsertProperties` + live `PrivateUpdatePropertyInt 0x02CD`); `ClientObjectTable.SumCarriedBurden` remains only as a defensive fallback for when the server omits it. | `src/AcDream.App/UI/Layout/InventoryController.cs` (`RefreshBurden`); `src/AcDream.Core.Net/ObjectTableWiring.cs` (player-int route) | B-Wire ports the retail read (`CACQualities::InqLoad` reads the server value); the client sum is now fallback-only, kept defensively. CONFIRM the server actually sends EncumbranceVal at the B-Wire visual gate, then DELETE this row. | If the server omits EncumbranceVal, the bar falls back to the client sum (the original drift) until the first 0x02CD — confirm at the gate. | `CACQualities::InqLoad` 0x0058f130; ACE PropertyInt.EncumbranceVal=5 | | AP-49 | Carry-capacity augmentation (PropertyInt `0xE6`) read from the player's wire property bundle when present (B-Wire PD `UpsertProperties`); defaults to 0 (correct for un-augmented characters) when absent. | `src/AcDream.App/UI/Layout/InventoryController.cs` (`RefreshBurden`) caller of `BurdenMath.EncumbranceCapacity` | B-Wire delivers the player's full PropertyInt table (incl. `0xE6` when the server sets it) via `UpsertProperties`; aug=0 is the correct no-aug default. CONFIRM the server sends `0xE6` for an augmented char at the gate, then DELETE. | An augmented character whose server omits `0xE6` reads slightly low capacity (bar fills higher than retail); un-augmented chars are exact. | `EncumbranceSystem::EncumbranceCapacity` decomp 256393 (0x004fcc00); retail `0xE6` PropertyInt augmentation | | AP-50 | Burden meter orientation/direction set programmatically (`Vertical=true`, `FillFromBottom=true`) rather than reading retail's `m_eDirection` property from the LayoutDesc element (property id `0x6f`). | `src/AcDream.App/UI/Layout/InventoryController.cs`; `src/AcDream.App/UI/UiMeter.cs` (`Vertical`/`FillFromBottom`) | The `m_eDirection` property is not yet read by `ElementReader`; bottom-up fill is visually confirmed against retail's burden bar art. Retire when `0x6f` is wired through `ElementReader`→`DatWidgetFactory`. | Wrong fill direction (top-down instead of bottom-up, or horizontal) if a future meter whose art requires a different direction is forced through the same code path. | `UIElement_Meter::DrawChildren` @0x46fbd0; property `0x6f` (`m_eDirection`) in the LayoutDesc | -| AP-51 | Main-pack `m_topContainer` cell (element `0x100001C9`) uses a placeholder/empty icon (`tex=0u`), not a weenie-driven backpack icon. | `src/AcDream.App/UI/Layout/InventoryController.cs` (`Populate`) | The player's own container entity has no item `IconId` on the client (it's a character, not an item); retail uses the equipped-pack's icon via the character's equipped-pack `CreateObject`. The equipped-pack DID path is deferred to Sub-phase C. | Main-pack cell renders blank (no icon) where retail shows the equipped backpack art — cosmetic gap visible when F12 is open. | `gmBackpackUI::PostInit` @0x4a8520 (m_topContainer bind); `CreateObject` weenie icon path | +| AP-51 | Main-pack `m_topContainer` cell (element `0x100001C9`) draws the constant backpack icon via a hardcoded base-icon literal `0x060011F4` + `ItemType.Container`, rather than resolving it at runtime through `GetDIDByEnum(0x10000004, 7)`. Correct in outcome (0x060011F4 IS that map entry, dat-verified) but not dat-resolved. | `src/AcDream.App/UI/Layout/InventoryController.cs` (`Populate`, main-pack cell) | Retail's `IconData::RenderIcons` IsThePlayer branch sets `m_idIcon = GetDIDByEnum(0x10000004,7)` + `m_itemType = TYPE_CONTAINER` (a CONSTANT — the original AP-51 "equipped-pack weenie icon" premise was wrong). We pin the resolved value as a literal (cf. AP-55's toolbar empty sprite) to avoid threading a fixed-index map resolve through the icon delegate. Retire when an index-N resolve is exposed on `IconComposer`. | If the portal dat's underlay map index 7 ever changed, the hardcoded icon would go stale where a runtime resolve would track it. | `IconData::RenderIcons` IsThePlayer branch 0x0058d1ee (decomp 407546-407549); EnumIDMap 0x10000004→0x25000008 index 7 | | AP-52 | Side-bag column slot count defaults to 7 (the dat column height) when the player's `ContainersCapacity` is absent/0. | `src/AcDream.App/UI/Layout/InventoryController.cs` (`Populate`) | `ContainersCapacity` is not reliably on the player ClientObject yet; 7 is the standard side-pack cap + the dat column (`0x100001CA`, 36×252) holds exactly 7. | An 8th-pack (Shadow of the Seventh Mule aug) character shows one too few side-bag slots — cosmetic. | retail gmBackpackUI side-pack column; ACE `ContainersCapacity` | | AP-53 | Contents grid slot count defaults to 102 (main pack) or 24 (side bag) when the open container's `ItemsCapacity` is absent/0. Container-switching is now wired (D.2b): clicking a side bag sends `Use 0x0036` and the grid repopulates from `ViewContents 0x0196`. This row covers only the capacity *default* when the server omits `ItemsCapacity`. | `src/AcDream.App/UI/Layout/InventoryController.cs` (`Populate`) | `ItemsCapacity` is not reliably on every ClientObject yet; 102/24 are the retail standard capacities. Retire when `ItemsCapacity` is confirmed delivered for all containers. | A non-102/24 pack shows the wrong empty-slot count — cosmetic only. | retail main-pack capacity 102; side-pack 24; ACE `ItemsCapacity`; `ViewContents 0x0196` | | AP-54 | Inventory window vertical resize is a toolkit approximation: bottom-edge drag, expand-only (Min = dat default 372 px, Max = 560 px constant), contents grid/sub-window/scrollbar/backdrop stretched via overridden `Left\|Top\|Bottom` anchors. | `src/AcDream.App/Rendering/GameWindow.cs` (inventory frame setup) | retail's gmInventoryUI resize lives in keystone.dll (no decomp); the anchor-stretch + 372..560 range matches the observed retail behavior for the dev loop. | The expand range / which elements reflow could differ from retail (e.g. retail may allow shrink-below-default); shrink is intentionally disabled for now. | retail gmInventoryUI resize (keystone.dll, no decomp); `UiNineSlicePanel` resize | diff --git a/src/AcDream.App/UI/Layout/InventoryController.cs b/src/AcDream.App/UI/Layout/InventoryController.cs index cdb4dc48..1d86e1f1 100644 --- a/src/AcDream.App/UI/Layout/InventoryController.cs +++ b/src/AcDream.App/UI/Layout/InventoryController.cs @@ -224,11 +224,17 @@ public sealed class InventoryController } // Main-pack cell: the player's own container — clicking it opens/selects the main pack. + // Retail draws a CONSTANT backpack icon here, NOT the player's body icon: IconData::RenderIcons + // (0x0058d1ee) has an IsThePlayer() branch that overrides m_idIcon with + // GetDIDByEnum(0x10000004, 7) = 0x060011F4 and m_itemType = TYPE_CONTAINER. Compose that + // backpack base over the Container type-underlay (the player object's own IconId is the + // character body, which would render wrong here). Retires AP-51. if (_topContainer is not null) { + const uint PlayerPackBaseIcon = 0x060011F4u; // GetDIDByEnum(0x10000004, 7) _topContainer.Flush(); var main = new UiItemSlot { SpriteResolve = _topContainer.SpriteResolve }; - main.SetItem(p, 0u); + main.SetItem(p, _iconIds(ItemType.Container, PlayerPackBaseIcon, 0u, 0u, 0u)); main.Clicked = () => OpenContainer(p); _topContainer.AddItem(main); } diff --git a/tests/AcDream.App.Tests/UI/Layout/InventoryControllerTests.cs b/tests/AcDream.App.Tests/UI/Layout/InventoryControllerTests.cs index f4fd5526..77f4271b 100644 --- a/tests/AcDream.App.Tests/UI/Layout/InventoryControllerTests.cs +++ b/tests/AcDream.App.Tests/UI/Layout/InventoryControllerTests.cs @@ -361,6 +361,24 @@ public class InventoryControllerTests Assert.True(top.GetItem(0)!.IsOpenContainer); // default open container = main pack } + [Fact] + public void MainPackCell_requestsConstantBackpackIcon_notPlayerBodyIcon() // AP-51 retire + { + var (layout, _, _, _, _, _, _, _) = BuildLayout(); + var objects = new ClientObjectTable(); + // Give the player a (wrong) body icon to prove the main-pack cell does NOT use it. + objects.AddOrUpdate(new ClientObject { ObjectId = Player, IconId = 0x06001234u }); + (ItemType type, uint icon)? mainPackCall = null; + InventoryController.Bind(layout, objects, () => Player, + iconIds: (t, icon, _, _, _) => { if (icon == 0x060011F4u) mainPackCall = (t, icon); return 0u; }, + strength: () => 100, datFont: null); + + // Retail IconData::RenderIcons IsThePlayer branch: GetDIDByEnum(0x10000004,7)=0x060011F4 + TYPE_CONTAINER. + Assert.NotNull(mainPackCall); + Assert.Equal(ItemType.Container, mainPackCall!.Value.type); + Assert.Equal(0x060011F4u, mainPackCall.Value.icon); + } + // Reads the text of the UiText caption child attached by the controller. private static string CaptionText(UiElement host) { From 077586a0f0b993e9bd0f7500a1a26db7de7e6ae2 Mon Sep 17 00:00:00 2001 From: Erik Date: Mon, 22 Jun 2026 15:33:58 +0200 Subject: [PATCH 103/133] fix(D.2b): main-pack backpack icon is 0x0600127E, not 0x060011F4 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The prior commit pinned 0x060011F4 from a research dat-dump of GetDIDByEnum(0x10000004,7) — it rendered as a GREEN TILE (green slot, no pack) at the visual gate. Dat-exported the candidates (AcDream.Cli dump-sprite-sheet / export-ui-sprite): 0x0600127E is the 32x32 brown backpack (user-hinted, PNG-confirmed). Swap the pinned literal; the test + AP-51 register row updated to the visually-verified id. Container type-underlay (green) + backpack base still composited via _iconIds. Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/architecture/retail-divergence-register.md | 2 +- src/AcDream.App/UI/Layout/InventoryController.cs | 12 +++++++----- .../UI/Layout/InventoryControllerTests.cs | 7 ++++--- 3 files changed, 12 insertions(+), 9 deletions(-) diff --git a/docs/architecture/retail-divergence-register.md b/docs/architecture/retail-divergence-register.md index d578fef3..d4ecf02f 100644 --- a/docs/architecture/retail-divergence-register.md +++ b/docs/architecture/retail-divergence-register.md @@ -150,7 +150,7 @@ accepted-divergence entries (#96, #49, #50). | AP-48 | Inventory burden reads the player's wire `EncumbranceVal` (PropertyInt 5) when present — delivered by B-Wire (login PD-bundle `UpsertProperties` + live `PrivateUpdatePropertyInt 0x02CD`); `ClientObjectTable.SumCarriedBurden` remains only as a defensive fallback for when the server omits it. | `src/AcDream.App/UI/Layout/InventoryController.cs` (`RefreshBurden`); `src/AcDream.Core.Net/ObjectTableWiring.cs` (player-int route) | B-Wire ports the retail read (`CACQualities::InqLoad` reads the server value); the client sum is now fallback-only, kept defensively. CONFIRM the server actually sends EncumbranceVal at the B-Wire visual gate, then DELETE this row. | If the server omits EncumbranceVal, the bar falls back to the client sum (the original drift) until the first 0x02CD — confirm at the gate. | `CACQualities::InqLoad` 0x0058f130; ACE PropertyInt.EncumbranceVal=5 | | AP-49 | Carry-capacity augmentation (PropertyInt `0xE6`) read from the player's wire property bundle when present (B-Wire PD `UpsertProperties`); defaults to 0 (correct for un-augmented characters) when absent. | `src/AcDream.App/UI/Layout/InventoryController.cs` (`RefreshBurden`) caller of `BurdenMath.EncumbranceCapacity` | B-Wire delivers the player's full PropertyInt table (incl. `0xE6` when the server sets it) via `UpsertProperties`; aug=0 is the correct no-aug default. CONFIRM the server sends `0xE6` for an augmented char at the gate, then DELETE. | An augmented character whose server omits `0xE6` reads slightly low capacity (bar fills higher than retail); un-augmented chars are exact. | `EncumbranceSystem::EncumbranceCapacity` decomp 256393 (0x004fcc00); retail `0xE6` PropertyInt augmentation | | AP-50 | Burden meter orientation/direction set programmatically (`Vertical=true`, `FillFromBottom=true`) rather than reading retail's `m_eDirection` property from the LayoutDesc element (property id `0x6f`). | `src/AcDream.App/UI/Layout/InventoryController.cs`; `src/AcDream.App/UI/UiMeter.cs` (`Vertical`/`FillFromBottom`) | The `m_eDirection` property is not yet read by `ElementReader`; bottom-up fill is visually confirmed against retail's burden bar art. Retire when `0x6f` is wired through `ElementReader`→`DatWidgetFactory`. | Wrong fill direction (top-down instead of bottom-up, or horizontal) if a future meter whose art requires a different direction is forced through the same code path. | `UIElement_Meter::DrawChildren` @0x46fbd0; property `0x6f` (`m_eDirection`) in the LayoutDesc | -| AP-51 | Main-pack `m_topContainer` cell (element `0x100001C9`) draws the constant backpack icon via a hardcoded base-icon literal `0x060011F4` + `ItemType.Container`, rather than resolving it at runtime through `GetDIDByEnum(0x10000004, 7)`. Correct in outcome (0x060011F4 IS that map entry, dat-verified) but not dat-resolved. | `src/AcDream.App/UI/Layout/InventoryController.cs` (`Populate`, main-pack cell) | Retail's `IconData::RenderIcons` IsThePlayer branch sets `m_idIcon = GetDIDByEnum(0x10000004,7)` + `m_itemType = TYPE_CONTAINER` (a CONSTANT — the original AP-51 "equipped-pack weenie icon" premise was wrong). We pin the resolved value as a literal (cf. AP-55's toolbar empty sprite) to avoid threading a fixed-index map resolve through the icon delegate. Retire when an index-N resolve is exposed on `IconComposer`. | If the portal dat's underlay map index 7 ever changed, the hardcoded icon would go stale where a runtime resolve would track it. | `IconData::RenderIcons` IsThePlayer branch 0x0058d1ee (decomp 407546-407549); EnumIDMap 0x10000004→0x25000008 index 7 | +| AP-51 | Main-pack `m_topContainer` cell (element `0x100001C9`) draws the constant backpack icon via a hardcoded base-icon literal `0x0600127E` + `ItemType.Container` (composited over the Container type-underlay), rather than resolving it at runtime the way retail does. Sprite VISUALLY CONFIRMED (2026-06-22 live gate). | `src/AcDream.App/UI/Layout/InventoryController.cs` (`Populate`, main-pack cell) | Retail's `IconData::RenderIcons` IsThePlayer branch draws a CONSTANT backpack with `m_itemType = TYPE_CONTAINER` (the original AP-51 "equipped-pack weenie icon" premise was wrong). The exact runtime resolution is unconfirmed: a research dat-dump misreported `GetDIDByEnum(0x10000004,7)` as the green tile `0x060011F4` (which rendered green-no-pack at the gate); the real backpack `0x0600127E` was identified by dat export + user confirmation. We pin the visually-verified literal (cf. AP-55). Retire when the runtime resolve is reproduced + confirmed. | If the pinned sprite diverges from what retail resolves at runtime, the main-pack icon goes stale. | `IconData::RenderIcons` IsThePlayer branch 0x0058d1ee (decomp 407546-407549); sprite `0x0600127E` dat-exported + visually confirmed | | AP-52 | Side-bag column slot count defaults to 7 (the dat column height) when the player's `ContainersCapacity` is absent/0. | `src/AcDream.App/UI/Layout/InventoryController.cs` (`Populate`) | `ContainersCapacity` is not reliably on the player ClientObject yet; 7 is the standard side-pack cap + the dat column (`0x100001CA`, 36×252) holds exactly 7. | An 8th-pack (Shadow of the Seventh Mule aug) character shows one too few side-bag slots — cosmetic. | retail gmBackpackUI side-pack column; ACE `ContainersCapacity` | | AP-53 | Contents grid slot count defaults to 102 (main pack) or 24 (side bag) when the open container's `ItemsCapacity` is absent/0. Container-switching is now wired (D.2b): clicking a side bag sends `Use 0x0036` and the grid repopulates from `ViewContents 0x0196`. This row covers only the capacity *default* when the server omits `ItemsCapacity`. | `src/AcDream.App/UI/Layout/InventoryController.cs` (`Populate`) | `ItemsCapacity` is not reliably on every ClientObject yet; 102/24 are the retail standard capacities. Retire when `ItemsCapacity` is confirmed delivered for all containers. | A non-102/24 pack shows the wrong empty-slot count — cosmetic only. | retail main-pack capacity 102; side-pack 24; ACE `ItemsCapacity`; `ViewContents 0x0196` | | AP-54 | Inventory window vertical resize is a toolkit approximation: bottom-edge drag, expand-only (Min = dat default 372 px, Max = 560 px constant), contents grid/sub-window/scrollbar/backdrop stretched via overridden `Left\|Top\|Bottom` anchors. | `src/AcDream.App/Rendering/GameWindow.cs` (inventory frame setup) | retail's gmInventoryUI resize lives in keystone.dll (no decomp); the anchor-stretch + 372..560 range matches the observed retail behavior for the dev loop. | The expand range / which elements reflow could differ from retail (e.g. retail may allow shrink-below-default); shrink is intentionally disabled for now. | retail gmInventoryUI resize (keystone.dll, no decomp); `UiNineSlicePanel` resize | diff --git a/src/AcDream.App/UI/Layout/InventoryController.cs b/src/AcDream.App/UI/Layout/InventoryController.cs index 1d86e1f1..6429dabd 100644 --- a/src/AcDream.App/UI/Layout/InventoryController.cs +++ b/src/AcDream.App/UI/Layout/InventoryController.cs @@ -225,13 +225,15 @@ public sealed class InventoryController // Main-pack cell: the player's own container — clicking it opens/selects the main pack. // Retail draws a CONSTANT backpack icon here, NOT the player's body icon: IconData::RenderIcons - // (0x0058d1ee) has an IsThePlayer() branch that overrides m_idIcon with - // GetDIDByEnum(0x10000004, 7) = 0x060011F4 and m_itemType = TYPE_CONTAINER. Compose that - // backpack base over the Container type-underlay (the player object's own IconId is the - // character body, which would render wrong here). Retires AP-51. + // (0x0058d1ee) has an IsThePlayer() branch that draws a fixed backpack with m_itemType = + // TYPE_CONTAINER. Compose that backpack base over the Container type-underlay (the player + // object's own IconId is the character body, which would render wrong here). The backpack + // RenderSurface is 0x0600127E, VISUALLY CONFIRMED at the live gate 2026-06-22 (the earlier + // 0x060011F4 from a research dat-dump of GetDIDByEnum(0x10000004,7) was a green tile, not the + // pack — the index value was misreported). Retires AP-51. if (_topContainer is not null) { - const uint PlayerPackBaseIcon = 0x060011F4u; // GetDIDByEnum(0x10000004, 7) + const uint PlayerPackBaseIcon = 0x0600127Eu; // constant main-pack backpack (visual gate) _topContainer.Flush(); var main = new UiItemSlot { SpriteResolve = _topContainer.SpriteResolve }; main.SetItem(p, _iconIds(ItemType.Container, PlayerPackBaseIcon, 0u, 0u, 0u)); diff --git a/tests/AcDream.App.Tests/UI/Layout/InventoryControllerTests.cs b/tests/AcDream.App.Tests/UI/Layout/InventoryControllerTests.cs index 77f4271b..366d1093 100644 --- a/tests/AcDream.App.Tests/UI/Layout/InventoryControllerTests.cs +++ b/tests/AcDream.App.Tests/UI/Layout/InventoryControllerTests.cs @@ -370,13 +370,14 @@ public class InventoryControllerTests objects.AddOrUpdate(new ClientObject { ObjectId = Player, IconId = 0x06001234u }); (ItemType type, uint icon)? mainPackCall = null; InventoryController.Bind(layout, objects, () => Player, - iconIds: (t, icon, _, _, _) => { if (icon == 0x060011F4u) mainPackCall = (t, icon); return 0u; }, + iconIds: (t, icon, _, _, _) => { if (icon == 0x0600127Eu) mainPackCall = (t, icon); return 0u; }, strength: () => 100, datFont: null); - // Retail IconData::RenderIcons IsThePlayer branch: GetDIDByEnum(0x10000004,7)=0x060011F4 + TYPE_CONTAINER. + // Retail draws a constant backpack over the Container type-underlay (IconData::RenderIcons + // IsThePlayer branch). The backpack RenderSurface 0x0600127E is visually confirmed (2026-06-22). Assert.NotNull(mainPackCall); Assert.Equal(ItemType.Container, mainPackCall!.Value.type); - Assert.Equal(0x060011F4u, mainPackCall.Value.icon); + Assert.Equal(0x0600127Eu, mainPackCall.Value.icon); } // Reads the text of the UiText caption child attached by the controller. From a45c421bd18fb02bac333d5a99f61c96b8c7b98d Mon Sep 17 00:00:00 2001 From: Erik Date: Mon, 22 Jun 2026 15:54:54 +0200 Subject: [PATCH 104/133] feat(D.2b): per-container capacity bar on inventory cells Faithful port of retail UIElement_UIItem::UpdateCapacityDisplay (0x004e16e0): each container cell (side bags + main pack) shows a vertical UIElement_Meter (element 0x10000347, back 0x06004D22 / fill 0x06004D23) filled to GetNumContainedItems / ItemsCapacity, clamped [0,1]; hidden for non-containers (CapacityFill=-1). Drawn procedurally on UiItemSlot like the triangle/square overlays (back full + front clipped bottom-up). Right-anchored flush to the cell edge (visual gate: the dat X=26 sat ~5px off the right edge). Visually confirmed 2026-06-22. Divergence AP-59; polish deferred to ISSUES #146 (exact rect/anchor, fill direction vs m_eDirection 0x6f, closed-bag lazy-load). Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/ISSUES.md | 26 +++++++++++++ .../retail-divergence-register.md | 1 + .../UI/Layout/InventoryController.cs | 16 +++++++- src/AcDream.App/UI/UiItemSlot.cs | 37 +++++++++++++++++++ .../UI/Layout/InventoryControllerTests.cs | 23 ++++++++++++ tests/AcDream.App.Tests/UI/UiItemSlotTests.cs | 10 +++++ 6 files changed, 112 insertions(+), 1 deletion(-) diff --git a/docs/ISSUES.md b/docs/ISSUES.md index 3b7fcf5b..10377769 100644 --- a/docs/ISSUES.md +++ b/docs/ISSUES.md @@ -46,6 +46,32 @@ Copy this block when adding a new issue: --- +## #146 — D.2b inventory capacity-bar visual polish + +**Status:** OPEN +**Severity:** LOW +**Filed:** 2026-06-22 + +The per-side-bag / main-pack container **capacity fill bar** (faithful port of retail +`UIElement_UIItem::UpdateCapacityDisplay 0x004e16e0`, element `0x10000347`) shipped + is +visually confirmed (`src/AcDream.App/UI/UiItemSlot.cs` `CapacityFill`; +`InventoryController.SetCapacityBar`). User: "looks good, we need to polish it a bit, but lets do +that later." Get the specific polish list from the user when picked up. Candidate points (confirm +against retail): + +- **Exact bar rect / anchor** — currently right-anchored flush (`X = Width − barW`); the dat element + rect is `X=26 Y=1 W=5 H=30` (a 5px right margin). Flush was the visual-gate call; reconcile with + the dat once the desired look is pinned. +- **Fill direction** — currently bottom-up (assumed); the dat `m_eDirection` (property `0x6f`) isn't + read (cf. AP-50). Confirm bottom-up vs retail. +- **Closed-bag fill** — a closed side bag reads empty until opened (contents aren't indexed until + `ViewContents`). If retail shows real fill for closed bags, the per-container item counts must be + pre-loaded at login (the lazy-load question — also the container-switching "open verification"). + +See divergence register **AP-59**. + +--- + ## #145 — Inventory window panels occluded by the full-window backdrop (importer ignores ZLevel) **Status:** DONE (2026-06-21 · `45a5cc5` + continuation `417b137`) — VISUALLY CONFIRMED. The first fix (`45a5cc5`) folded `ZLevel` into `ZOrder` and put the backdrop behind the **ZLevel-0 top-level** panels, which made the **paperdoll** (its base root is ZLevel 0) render — so it *looked* done. But the **mounted backpack/3D-items panels** inherited their sub-window root's **ZLevel 1000** (via `ElementReader.Merge`'s zero-wins-base rule), so the `ReadOrder − ZLevel·10000` fold sank them to ZOrder ≈ −10,000,000 — *behind* the backdrop (ZLevel 100 → ≈ −1,000,000). The Alphablend backdrop then **washed out** the backpack/3D-items captions + burden meter + item cells (only the bright paperdoll survived the wash). Surfaced once B-Controller populated those panels. Continuation fix `417b137`: the sub-window mount keeps each slot's **own** frame ZLevel (not the base root's 1000), so panels sit in front of the backdrop. Confirmed live (Burden 17% + vertical bar + "Contents of Backpack" + full item grid all render on the dark backdrop). Locked by `InventoryFrameImportProbe` (real-dat: each mounted panel's ZOrder > the backdrop's). DO-NOT-RETRY: a mounted sub-window slot must NOT inherit the base layout root's ZLevel. Per-slot paperdoll silhouettes (generic `UiDatElement` sprite — need `0x10000032` UiItemSlot + per-slot art) → Sub-phase C. diff --git a/docs/architecture/retail-divergence-register.md b/docs/architecture/retail-divergence-register.md index d4ecf02f..31fc055e 100644 --- a/docs/architecture/retail-divergence-register.md +++ b/docs/architecture/retail-divergence-register.md @@ -157,6 +157,7 @@ accepted-divergence entries (#96, #49, #50). | 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-57 | 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. | `src/AcDream.App/UI/UiItemSlot.cs`; `src/AcDream.App/UI/Layout/InventoryController.cs` | The procedural overlay produces the correct visual result; the 36×36 container prototype (`0x1000033F`) lacks the square child, so the procedural overlay is the only way to show the square on a selected bag — the dat-state path retail uses would require the prototype to be richer than it is. | 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 for the open/selected indicator cases. | `UIElement_ItemList::UpdateOpenContainerIndicator` 0x004e3070; `ItemList_SetSelectedItem` 0x004e2fe0; `SetOpenContainerState` 0x004e1200; `SetSelectedState` 0x004e1240 | | AP-58 | Inventory item **selection is panel-local + visual-only** — clicking a grid item moves the green square but does not wire to the selected-object bar (`SelectedObjectController`) or to global 3D-world selection. | `src/AcDream.App/UI/Layout/InventoryController.cs` (`SelectItem`) | The selected-object bar + 3D-world selection wiring is deferred to the selection follow-up; the panel-local square is the correct first step. | Selecting an inventory item doesn't populate the selected-object strip as retail does — functional gap deferred. | `gmInventoryUI`/`gmBackpackUI` item-select → `ClientUISystem::SetSelectedObject`; `SelectedObjectController` | +| AP-59 | The per-cell container **capacity bar** (retail `UIElement_UIItem::UpdateCapacityDisplay 0x004e16e0`, element `0x10000347`) is drawn as a **procedural `UiItemSlot` overlay** (track `0x06004D22` full + fill `0x06004D23` clipped bottom-up to `GetContents(guid).Count / ItemsCapacity`), not via a real dat `UIElement_Meter` child. **Right-anchored flush** (the dat rect X=26 in a 36px cell sat ~5px off the edge — flush per the visual gate) and **bottom-up fill assumed** (the dat `m_eDirection` 0x6f isn't read, cf. AP-50). A CLOSED side bag reads empty until opened (its contents aren't indexed until `ViewContents`) — faithful to retail's known-children count, divergent if retail pre-loads. | `src/AcDream.App/UI/UiItemSlot.cs` (`CapacityFill` draw); `src/AcDream.App/UI/Layout/InventoryController.cs` (`SetCapacityBar`) | UiItemSlot is a behavioral leaf that paints overlays procedurally (cf. AP-57); the meter sprites + fill formula are the faithful port. Right-anchor + bottom-up were visual-gate calls. Further visual polish is deferred — ISSUES #146. | Fill direction / exact bar rect could differ from retail's `m_eDirection` + dat X; closed-bag bars read empty if retail pre-loads container counts. | `UIElement_UIItem::UpdateCapacityDisplay` 0x004e16e0; element `0x10000347` (back `0x06004D22` / front `0x06004D23`); `GetNumContainedItems` | --- diff --git a/src/AcDream.App/UI/Layout/InventoryController.cs b/src/AcDream.App/UI/Layout/InventoryController.cs index 6429dabd..c0dbc8d6 100644 --- a/src/AcDream.App/UI/Layout/InventoryController.cs +++ b/src/AcDream.App/UI/Layout/InventoryController.cs @@ -238,6 +238,7 @@ public sealed class InventoryController var main = new UiItemSlot { SpriteResolve = _topContainer.SpriteResolve }; main.SetItem(p, _iconIds(ItemType.Container, PlayerPackBaseIcon, 0u, 0u, 0u)); main.Clicked = () => OpenContainer(p); + SetCapacityBar(main, p); // main-pack fullness (items / ItemsCapacity) _topContainer.AddItem(main); } @@ -261,7 +262,7 @@ public sealed class InventoryController : _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); + if (isContainer) { cell.Clicked = () => OpenContainer(guid); SetCapacityBar(cell, guid); } else cell.Clicked = () => SelectItem(guid); list.AddItem(cell); } @@ -269,6 +270,19 @@ public sealed class InventoryController private static void AddEmptyCell(UiItemList list) => list.AddItem(new UiItemSlot { SpriteResolve = list.SpriteResolve }); + /// Set the per-cell container capacity bar — retail UIElement_UIItem::UpdateCapacityDisplay + /// (0x004e16e0): visible only for a container with itemsCapacity > 0; fill = + /// GetNumContainedItems / itemsCapacity, clamped [0,1]. -1 hides the bar (non-container / unknown + /// capacity). For a CLOSED side bag the contents aren't indexed until it's opened (ViewContents), + /// so the bar reads empty until then — faithful to retail's known-children count. + private void SetCapacityBar(UiItemSlot cell, uint containerGuid) + { + int cap = _objects.Get(containerGuid)?.ItemsCapacity ?? 0; + if (cap <= 0) { cell.CapacityFill = -1f; return; } + int n = _objects.GetContents(containerGuid).Count; + cell.CapacityFill = Math.Clamp(n / (float)cap, 0f, 1f); + } + /// Select an item (panel-wide green square) without changing the open container or /// touching the wire. Retail: UIElement_ItemList::ItemList_SetSelectedItem (0x004e2fe0). private void SelectItem(uint guid) diff --git a/src/AcDream.App/UI/UiItemSlot.cs b/src/AcDream.App/UI/UiItemSlot.cs index a3c95895..10e1d7fe 100644 --- a/src/AcDream.App/UI/UiItemSlot.cs +++ b/src/AcDream.App/UI/UiItemSlot.cs @@ -55,6 +55,16 @@ public sealed class UiItemSlot : UiElement /// on the 36×36 container cell too (whose prototype lacks the square child). public uint SelectedSprite { get; set; } = 0x06004D21u; + /// Container fullness [0..1], or -1 = hidden (the cell is not a container, or its + /// itemsCapacity is unknown/0). Port of UIElement_UIItem::UpdateCapacityDisplay (0x004e16e0): a + /// per-cell vertical UIElement_Meter (element 0x10000347, 5×30 at x=26,y=1) shown only when + /// isContainer && itemsCapacity > 0, fill = numContainedItems / itemsCapacity (meter attr 0x69). + public float CapacityFill { get; set; } = -1f; + /// Capacity-bar track sprite (meter 0x10000347 DirectState). + public uint CapacityBackSprite { get; set; } = 0x06004D22u; + /// Capacity-bar fill sprite (meter 0x10000347 front child 0x00000002 DirectState). + public uint CapacityFrontSprite { get; set; } = 0x06004D23u; + /// Accept/reject overlay state while a drag hovers this cell. public enum DragAcceptState { None, Accept, Reject } private DragAcceptState _dragAccept = DragAcceptState.None; @@ -237,6 +247,33 @@ public sealed class UiItemSlot : UiElement } } + // Container capacity bar — UIElement_UIItem::UpdateCapacityDisplay (0x004e16e0): a vertical + // UIElement_Meter (element 0x10000347, 5×30 at x=26,y=1) shown only for container cells + // (CapacityFill >= 0). Track drawn full; fill clipped to the fraction from the BOTTOM (the + // "how full" direction). Procedural — UiItemSlot is a behavioral leaf. Guard id != 0 first. + if (CapacityFill >= 0f && SpriteResolve is not null) + { + const float by = 1f, bw = 5f, bh = 30f; // element 0x10000347 size (dat 5×30 at y=1) + float bx = Width - bw; // flush to the cell's right edge (visual gate: the dat X=26 sat ~5px off the edge) + if (CapacityBackSprite != 0) + { + var (bt, _, _) = SpriteResolve(CapacityBackSprite); + if (bt != 0) ctx.DrawSprite(bt, bx, by, bw, bh, 0f, 0f, 1f, 1f, Vector4.One); + } + float f = Math.Clamp(CapacityFill, 0f, 1f); + if (f > 0f && CapacityFrontSprite != 0) + { + var (ft, _, _) = SpriteResolve(CapacityFrontSprite); + if (ft != 0) + { + // Bottom-up fill: draw the bottom f-fraction of the bar, sampling the matching + // bottom slice of the front sprite (UV v from 1-f to 1). + float fh = bh * f; + ctx.DrawSprite(ft, bx, by + (bh - fh), bw, fh, 0f, 1f - f, 1f, 1f, Vector4.One); + } + } + } + // 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 diff --git a/tests/AcDream.App.Tests/UI/Layout/InventoryControllerTests.cs b/tests/AcDream.App.Tests/UI/Layout/InventoryControllerTests.cs index 366d1093..9772068f 100644 --- a/tests/AcDream.App.Tests/UI/Layout/InventoryControllerTests.cs +++ b/tests/AcDream.App.Tests/UI/Layout/InventoryControllerTests.cs @@ -380,6 +380,29 @@ public class InventoryControllerTests Assert.Equal(0x0600127Eu, mainPackCall.Value.icon); } + [Fact] + public void SideBagCell_capacityBar_reflectsContents() + { + var (layout, _, containers, _, _, _, _, _) = BuildLayout(); + var objects = new ClientObjectTable(); + SeedBag(objects, 0xC, slot: 0, itemsCapacity: 24); // a side bag (cap 24) + for (uint i = 0; i < 6; i++) SeedContained(objects, 0xB0u + i, 0xC, slot: (int)i); // 6 items inside + Bind(layout, objects); + + // Retail UpdateCapacityDisplay: fill = contained / itemsCapacity = 6/24 = 0.25 (exact in float). + Assert.Equal(0.25f, containers.GetItem(0)!.CapacityFill); + } + + [Fact] + public void LooseGridItem_hasNoCapacityBar() + { + var (layout, grid, _, _, _, _, _, _) = BuildLayout(); + var objects = new ClientObjectTable(); + SeedContained(objects, 0xA, Player, slot: 0); // a loose, non-container item + Bind(layout, objects); + Assert.Equal(-1f, grid.GetItem(0)!.CapacityFill); // hidden — not a container + } + // Reads the text of the UiText caption child attached by the controller. private static string CaptionText(UiElement host) { diff --git a/tests/AcDream.App.Tests/UI/UiItemSlotTests.cs b/tests/AcDream.App.Tests/UI/UiItemSlotTests.cs index c0e91753..773f3039 100644 --- a/tests/AcDream.App.Tests/UI/UiItemSlotTests.cs +++ b/tests/AcDream.App.Tests/UI/UiItemSlotTests.cs @@ -157,4 +157,14 @@ public class UiItemSlotTests [Fact] public void OpenContainerSprite_default_isTriangle() => Assert.Equal(0x06005D9Cu, new UiItemSlot().OpenContainerSprite); + + // ── Container capacity bar (decomp UpdateCapacityDisplay 0x004e16e0, element 0x10000347) ── + [Fact] + public void CapacityFill_defaultsHidden() => Assert.Equal(-1f, new UiItemSlot().CapacityFill); + + [Fact] + public void CapacityBackSprite_default() => Assert.Equal(0x06004D22u, new UiItemSlot().CapacityBackSprite); + + [Fact] + public void CapacityFrontSprite_default() => Assert.Equal(0x06004D23u, new UiItemSlot().CapacityFrontSprite); } From c937db11c7e64dda6e9dcca9180df75585330c9e Mon Sep 17 00:00:00 2001 From: Erik Date: Mon, 22 Jun 2026 19:16:25 +0200 Subject: [PATCH 105/133] docs: file ISSUES #147 (inventory scroll polish) + #148 (status-bar backpack toggles inventory) Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/ISSUES.md | 29 +++++++++++++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/docs/ISSUES.md b/docs/ISSUES.md index 10377769..b4ba10c8 100644 --- a/docs/ISSUES.md +++ b/docs/ISSUES.md @@ -46,6 +46,35 @@ Copy this block when adding a new issue: --- +## #148 — Status-bar backpack icon should toggle the inventory window (stateful open/closed) + +**Status:** OPEN +**Severity:** MEDIUM +**Filed:** 2026-06-22 + +The backpack icon in the status bar / toolbar should be **clickable to open/close the inventory +window** (toggle), and its art should reflect the inventory's state — a **closed** backpack when the +inventory is closed, an **open** backpack when it's open. Today the inventory opens via F12 only; +there's no clickable status-bar affordance and the icon isn't stateful. Wire the click → inventory +toggle (the window manager / `UiHost` RegisterWindow + the F12 toggle path) and swap the icon sprite +on the inventory window's visibility change. Investigate the retail status-bar backpack element + +its open/closed sprites when picked up. + +--- + +## #147 — Inventory item-grid scrolling polish + +**Status:** OPEN +**Severity:** LOW +**Filed:** 2026-06-22 + +The inventory contents-grid scrolling (gutter scrollbar `0x100001C7` bound to `UiItemList.Scroll`, +whole-row clip via `UiScrollable`) works but needs visual/UX polish — smoothness, thumb behavior, +wheel step, row-clip edges. Confirm the exact symptoms with the user when picked up. Filed from the +D.2b inventory visual gate. + +--- + ## #146 — D.2b inventory capacity-bar visual polish **Status:** OPEN From 3b66858893d69df8dd04a449af60f787dbe6a863 Mon Sep 17 00:00:00 2001 From: Erik Date: Mon, 22 Jun 2026 19:28:05 +0200 Subject: [PATCH 106/133] docs(D.2b): inventory drag-drop (item moving) design spec MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Brainstorm output for B-Drag. Drop an inventory item: empty grid slot -> first empty; on an item -> insert before; on a side-bag cell -> into that container. Green insert-arrow (valid) / red circle (full). Movement is OPTIMISTIC/instant per the user — local MoveItem on drop + repaint, server reconciles via 0x0022 echo, rolls back via 0x00A0 (the rollback the B-Wire note reserved). InventoryController : IItemListDragHandler; pending-move tracking in ClientObjectTable (Core, reachable from the Core.Net handlers); SendPutItemInContainer wraps BuildPickUp 0x0019. Retail anchors: InqDropIconInfo 0x004e26f0 / ItemList_InsertItem / HandleDropRelease / ServerSaysMoveItem. Co-Authored-By: Claude Opus 4.8 (1M context) --- ...26-06-22-d2b-inventory-drag-drop-design.md | 121 ++++++++++++++++++ 1 file changed, 121 insertions(+) create mode 100644 docs/superpowers/specs/2026-06-22-d2b-inventory-drag-drop-design.md diff --git a/docs/superpowers/specs/2026-06-22-d2b-inventory-drag-drop-design.md b/docs/superpowers/specs/2026-06-22-d2b-inventory-drag-drop-design.md new file mode 100644 index 00000000..c183e0e7 --- /dev/null +++ b/docs/superpowers/specs/2026-06-22-d2b-inventory-drag-drop-design.md @@ -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. From b657c82df5101f103c2489fe997ef86e99f1fca5 Mon Sep 17 00:00:00 2001 From: Erik Date: Mon, 22 Jun 2026 19:45:47 +0200 Subject: [PATCH 107/133] docs(D.2b): inventory drag-drop implementation plan 7-task TDD plan: ClientObjectTable optimistic move + confirm/rollback -> SendPutItemInContainer 0x0019 -> ConfirmMove(0x0022)/RollbackMove(0x00A0) wiring -> InventoryController : IItemListDragHandler (no-op lift, accept/reject overlay, optimistic drop + wire; green-arrow 0x060011F7 / red-circle 0x060011F8 export-confirmed) -> GameWindow wiring -> divergence AP-60/61 -> full-suite + visual gate. Exact code per step. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../2026-06-22-d2b-inventory-drag-drop.md | 577 ++++++++++++++++++ 1 file changed, 577 insertions(+) create mode 100644 docs/superpowers/plans/2026-06-22-d2b-inventory-drag-drop.md diff --git a/docs/superpowers/plans/2026-06-22-d2b-inventory-drag-drop.md b/docs/superpowers/plans/2026-06-22-d2b-inventory-drag-drop.md new file mode 100644 index 00000000..365fca0a --- /dev/null +++ b/docs/superpowers/plans/2026-06-22-d2b-inventory-drag-drop.md @@ -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 _pendingMoves = new(); +``` + +Add the methods after `MoveItem` (~line 131): +```csharp + /// + /// Optimistic (instant) move: snapshot the item's current (ContainerId, ContainerSlot) the FIRST + /// time it moves, then (immediate repaint via ObjectMoved). The wire + /// PutItemInContainer is sent by the caller; / + /// reconcile against the server. Retail: local ItemList_InsertItem + ServerSaysMoveItem reconcile. + /// + 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); + } + + /// The server confirmed the move (InventoryPutObjInContainer 0x0022 echo) — drop the + /// pending snapshot. No-op for a server-initiated move we never tracked. + public void ConfirmMove(uint itemId) => _pendingMoves.Remove(itemId); + + /// 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. + 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) " +``` + +--- + +## 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 + /// 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. + 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) " +``` + +--- + +## 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) " +``` + +--- + +## 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? uses = null, List? 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? _sendPutItemInContainer; // (item, container, placement) +``` +Append to the private ctor params (after `sendNoLongerViewing`): +```csharp + Action? sendNoLongerViewing, + Action? 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? sendNoLongerViewing = null, + Action? 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) ────────────── + /// 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. + public void OnDragLift(UiItemList sourceList, UiItemSlot sourceCell, ItemDragPayload payload) { } + + /// 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. + 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; + } + + /// Perform the move: resolve (container, placement) from the target, optimistically move + /// locally (instant), and send PutItemInContainer. Retail: HandleDropRelease + ItemList_InsertItem. + 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); + } + + /// 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. + 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) " +``` + +--- + +## 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) " +``` + +--- + +## 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) " +``` + +--- + +## 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? 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. From f7f9ae052e816cd4679381708ae8d090f3b1521e Mon Sep 17 00:00:00 2001 From: Erik Date: Mon, 22 Jun 2026 19:49:25 +0200 Subject: [PATCH 108/133] feat(D.2b): ClientObjectTable optimistic move + confirm/rollback for inventory drag Co-Authored-By: Claude Opus 4.8 (1M context) --- src/AcDream.Core/Items/ClientObjectTable.cs | 32 ++++++++++++++++ .../Items/ClientObjectTableTests.cs | 38 +++++++++++++++++++ 2 files changed, 70 insertions(+) diff --git a/src/AcDream.Core/Items/ClientObjectTable.cs b/src/AcDream.Core/Items/ClientObjectTable.cs index 558d12f4..238f0eb7 100644 --- a/src/AcDream.Core/Items/ClientObjectTable.cs +++ b/src/AcDream.Core/Items/ClientObjectTable.cs @@ -44,6 +44,11 @@ public sealed class ClientObjectTable private readonly ConcurrentDictionary _containers = new(); private readonly Dictionary> _containerIndex = new(); + // 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 _pendingMoves = new(); + /// Fires when an object is first added to the session. public event Action? ObjectAdded; @@ -130,6 +135,33 @@ public sealed class ClientObjectTable return true; } + /// + /// Optimistic (instant) move: snapshot the item's current (ContainerId, ContainerSlot) the FIRST + /// time it moves, then (immediate repaint via ObjectMoved). The wire + /// PutItemInContainer is sent by the caller; / + /// reconcile against the server. Retail: local ItemList_InsertItem + ServerSaysMoveItem reconcile. + /// + 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); + } + + /// The server confirmed the move (InventoryPutObjInContainer 0x0022 echo) — drop the + /// pending snapshot. No-op for a server-initiated move we never tracked. + public void ConfirmMove(uint itemId) => _pendingMoves.Remove(itemId); + + /// 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. + public bool RollbackMove(uint itemId) + { + if (!_pendingMoves.TryGetValue(itemId, out var pre)) return false; + _pendingMoves.Remove(itemId); + return MoveItem(itemId, pre.container, pre.slot); + } + /// /// Handle a server-driven remove (destroyed item, dropped into 3D /// space, stolen, etc). diff --git a/tests/AcDream.Core.Tests/Items/ClientObjectTableTests.cs b/tests/AcDream.Core.Tests/Items/ClientObjectTableTests.cs index d7d58232..dc239fda 100644 --- a/tests/AcDream.Core.Tests/Items/ClientObjectTableTests.cs +++ b/tests/AcDream.Core.Tests/Items/ClientObjectTableTests.cs @@ -358,6 +358,44 @@ public sealed class ClientObjectTableTests Assert.Null(table.Get(0xA01u)); } + [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)); + [Fact] public void ContainerIndex_SlotChange_ResortsInPlace() // guards the Reindex same-container early-out { From 6f0012bd143b2f3651b6d3812a0b9ec1f8af6dd1 Mon Sep 17 00:00:00 2001 From: Erik Date: Mon, 22 Jun 2026 19:49:52 +0200 Subject: [PATCH 109/133] feat(D.2b): WorldSession.SendPutItemInContainer (0x0019) for inventory moves Co-Authored-By: Claude Opus 4.8 (1M context) --- src/AcDream.Core.Net/WorldSession.cs | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/src/AcDream.Core.Net/WorldSession.cs b/src/AcDream.Core.Net/WorldSession.cs index 47785496..60e4aa6f 100644 --- a/src/AcDream.Core.Net/WorldSession.cs +++ b/src/AcDream.Core.Net/WorldSession.cs @@ -1224,6 +1224,15 @@ public sealed class WorldSession : IDisposable SendGameAction(InteractRequests.BuildUse(seq, targetGuid)); } + /// 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. + public void SendPutItemInContainer(uint itemGuid, uint containerGuid, int placement) + { + uint seq = NextGameActionSequence(); + SendGameAction(InteractRequests.BuildPickUp(seq, itemGuid, containerGuid, placement)); + } + /// Send retail QueryHealth (0x01BF). Server replies UpdateHealth (0x01C0). /// /// Retail anchor: CM_Combat::Event_QueryHealth / gmToolbarUI::HandleSelectionChanged:198635 From 4aebf444d910f8b0e18bfc642974750a49f0c117 Mon Sep 17 00:00:00 2001 From: Erik Date: Mon, 22 Jun 2026 19:51:20 +0200 Subject: [PATCH 110/133] feat(D.2b): wire ConfirmMove (0x0022 echo) + RollbackMove (0x00A0) for optimistic inventory moves Co-Authored-By: Claude Opus 4.8 (1M context) --- src/AcDream.Core.Net/GameEventWiring.cs | 17 ++++++++------- .../GameEventWiringTests.cs | 21 +++++++++++++++++++ 2 files changed, 31 insertions(+), 7 deletions(-) diff --git a/src/AcDream.Core.Net/GameEventWiring.cs b/src/AcDream.Core.Net/GameEventWiring.cs index fc425532..8f660948 100644 --- a/src/AcDream.Core.Net/GameEventWiring.cs +++ b/src/AcDream.Core.Net/GameEventWiring.cs @@ -246,8 +246,9 @@ public static class GameEventWiring dispatcher.Register(GameEventType.InventoryPutObjInContainer, e => { var p = GameEvents.ParsePutObjInContainer(e.Payload.Span); - if (p is not null) items.MoveItem(p.Value.ItemGuid, p.Value.ContainerGuid, - newSlot: (int)p.Value.Placement); + 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 }); // ViewContents (0x0196) — the server's AUTHORITATIVE full contents list for a container you @@ -273,14 +274,16 @@ public static class GameEventWiring if (guid is not null) items.MoveItem(guid.Value, newContainerId: 0u); }); - // B-Wire: InventoryServerSaveFailed (0x00A0) — rollback of a speculative move. - // acdream does not do speculative moves yet (read-only inventory); parse + log - // so the wire is correct. B-Drag wires the rollback behavior. (Divergence note.) + // B-Drag: InventoryServerSaveFailed (0x00A0) — server rejected an optimistic move. + // Snap the item back to its pre-move slot. Log only when there was no pending move + // (a server-initiated failure on a non-optimistic path). dispatcher.Register(GameEventType.InventoryServerSaveFailed, e => { var p = GameEvents.ParseInventoryServerSaveFailed(e.Payload.Span); - if (p is not null) - Console.WriteLine($"[B-Wire] InventoryServerSaveFailed guid=0x{p.Value.ItemGuid:X8} err=0x{p.Value.WeenieError:X}"); + 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)"); }); // B-Wire: CloseGroundContainer (0x0052) — server closed a ground-container diff --git a/tests/AcDream.Core.Net.Tests/GameEventWiringTests.cs b/tests/AcDream.Core.Net.Tests/GameEventWiringTests.cs index 24d868f3..92d7b471 100644 --- a/tests/AcDream.Core.Net.Tests/GameEventWiringTests.cs +++ b/tests/AcDream.Core.Net.Tests/GameEventWiringTests.cs @@ -585,6 +585,27 @@ public sealed class GameEventWiringTests d.Dispatch(env!.Value); } + [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.RecordMembership(0x50000B01u, containerId: 0x500000C0u); + items.MoveItem(0x50000B01u, 0x500000C0u, 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); + } + [Fact] public void WireAll_InventoryPutObjectIn3D_UnparentsFromContainer() { From 81d9b3b37a30ffd83e86e7314ad2cee5a8c0b7ab Mon Sep 17 00:00:00 2001 From: Erik Date: Mon, 22 Jun 2026 19:54:53 +0200 Subject: [PATCH 111/133] feat(D.2b): InventoryController drag-drop handler (optimistic move + green-arrow/red-circle) Co-Authored-By: Claude Opus 4.8 (1M context) --- .../UI/Layout/InventoryController.cs | 86 ++++++++++++++++-- .../UI/Layout/InventoryControllerTests.cs | 87 ++++++++++++++++++- 2 files changed, 165 insertions(+), 8 deletions(-) diff --git a/src/AcDream.App/UI/Layout/InventoryController.cs b/src/AcDream.App/UI/Layout/InventoryController.cs index c0dbc8d6..7d43c24e 100644 --- a/src/AcDream.App/UI/Layout/InventoryController.cs +++ b/src/AcDream.App/UI/Layout/InventoryController.cs @@ -1,5 +1,6 @@ using System; using System.Numerics; +using AcDream.App.UI; using AcDream.Core.Items; namespace AcDream.App.UI.Layout; @@ -11,7 +12,7 @@ namespace AcDream.App.UI.Layout; /// Container-switching is live (click a side bag → Use 0x0036 → ViewContents 0x0196 full-replace); /// drag-into-bag / wield-drop wire are later sub-phases. /// -public sealed class InventoryController +public sealed class InventoryController : IItemListDragHandler { // Element ids — spec §1 (dat dump of 0x21000022 / 0x21000021 + the *::PostInit binds). public const uint ContentsGridId = 0x100001C6u; // gm3DItemsUI m_itemList ("Contents of Backpack") @@ -58,6 +59,7 @@ public sealed class InventoryController private uint _selectedItem; // 0 = none; the panel-wide selected item (green square) private readonly Action? _sendUse; private readonly Action? _sendNoLongerViewing; + private readonly Action? _sendPutItemInContainer; // (item, container, placement) // ACE PropertyInt ids read by retail InqLoad (decomp 0x0058f130: InqInt(5)/InqInt(0xe6)). private const uint EncumbranceValProperty = 5u; // total carried burden @@ -74,7 +76,8 @@ public sealed class InventoryController uint sideBagEmptySprite, uint mainPackEmptySprite, Action? sendUse, - Action? sendNoLongerViewing) + Action? sendNoLongerViewing, + Action? sendPutItemInContainer) { _objects = objects; _playerGuid = playerGuid; @@ -82,6 +85,7 @@ public sealed class InventoryController _strength = strength; _sendUse = sendUse; _sendNoLongerViewing = sendNoLongerViewing; + _sendPutItemInContainer = sendPutItemInContainer; _contentsGrid = layout.FindElement(ContentsGridId) as UiItemList; _containerList = layout.FindElement(ContainerListId) as UiItemList; @@ -121,6 +125,11 @@ public sealed class InventoryController if (_containerList is not null) _containerList.CellEmptySprite = sideBagEmptySprite; if (_topContainer is not null) _topContainer.CellEmptySprite = mainPackEmptySprite; + // B-Drag: register this controller as the drag handler on all three lists. + _contentsGrid?.RegisterDragHandler(this); + _containerList?.RegisterDragHandler(this); + _topContainer?.RegisterDragHandler(this); + // Burden meter: vertical 11×58 bar (gmBackpackUI m_burdenMeter, retail direction 4). _burdenMeter = layout.FindElement(BurdenMeterId) as UiMeter; if (_burdenMeter is not null) @@ -157,10 +166,11 @@ public sealed class InventoryController uint sideBagEmptySprite = 0u, uint mainPackEmptySprite = 0u, Action? sendUse = null, - Action? sendNoLongerViewing = null) + Action? sendNoLongerViewing = null, + Action? sendPutItemInContainer = null) => new InventoryController(layout, objects, playerGuid, iconIds, strength, datFont, contentsEmptySprite, sideBagEmptySprite, mainPackEmptySprite, - sendUse, sendNoLongerViewing); + sendUse, sendNoLongerViewing, sendPutItemInContainer); private void OnObjectChanged(ClientObject o) { if (Concerns(o)) Populate(); } private void OnObjectMoved(ClientObject o, uint from, uint to) @@ -237,6 +247,7 @@ public sealed class InventoryController _topContainer.Flush(); var main = new UiItemSlot { SpriteResolve = _topContainer.SpriteResolve }; main.SetItem(p, _iconIds(ItemType.Container, PlayerPackBaseIcon, 0u, 0u, 0u)); + main.DragAcceptSprite = 0x060011F7u; main.DragRejectSprite = 0x060011F8u; main.Clicked = () => OpenContainer(p); SetCapacityBar(main, p); // main-pack fullness (items / ItemsCapacity) _topContainer.AddItem(main); @@ -262,13 +273,21 @@ public sealed class InventoryController : _iconIds(item.Type, item.IconId, item.IconUnderlayId, item.IconOverlayId, item.Effects); var cell = new UiItemSlot { SpriteResolve = list.SpriteResolve }; cell.SetItem(guid, tex); + 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 if (isContainer) { cell.Clicked = () => OpenContainer(guid); SetCapacityBar(cell, guid); } else cell.Clicked = () => SelectItem(guid); list.AddItem(cell); } private static void AddEmptyCell(UiItemList list) - => list.AddItem(new UiItemSlot { SpriteResolve = list.SpriteResolve }); + { + var cell = new UiItemSlot { SpriteResolve = list.SpriteResolve, SlotIndex = list.GetNumUIItems() }; + cell.DragAcceptSprite = 0x060011F7u; + cell.DragRejectSprite = 0x060011F8u; + list.AddItem(cell); + } /// Set the per-cell container capacity bar — retail UIElement_UIItem::UpdateCapacityDisplay /// (0x004e16e0): visible only for a container with itemsCapacity > 0; fill = @@ -283,6 +302,63 @@ public sealed class InventoryController cell.CapacityFill = Math.Clamp(n / (float)cap, 0f, 1f); } + // ── IItemListDragHandler (B-Drag) — drop an item to move it (optimistic + wire) ────────────── + /// 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. + public void OnDragLift(UiItemList sourceList, UiItemSlot sourceCell, ItemDragPayload payload) { } + + /// 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. + 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; + } + + /// Perform the move: resolve (container, placement) from the target, optimistically move + /// locally (instant), and send PutItemInContainer. Retail: HandleDropRelease + ItemList_InsertItem. + 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); + } + + /// 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. + private bool IsContainerFull(uint container) + { + int cap = _objects.Get(container)?.ItemsCapacity ?? 0; + if (cap <= 0) return false; + return _objects.GetContents(container).Count >= cap; + } + /// Select an item (panel-wide green square) without changing the open container or /// touching the wire. Retail: UIElement_ItemList::ItemList_SetSelectedItem (0x004e2fe0). private void SelectItem(uint guid) diff --git a/tests/AcDream.App.Tests/UI/Layout/InventoryControllerTests.cs b/tests/AcDream.App.Tests/UI/Layout/InventoryControllerTests.cs index 9772068f..296a1059 100644 --- a/tests/AcDream.App.Tests/UI/Layout/InventoryControllerTests.cs +++ b/tests/AcDream.App.Tests/UI/Layout/InventoryControllerTests.cs @@ -51,12 +51,14 @@ public class InventoryControllerTests } private static InventoryController Bind(ImportedLayout layout, ClientObjectTable objects, - int? strength = 100, List? uses = null, List? closes = null) + int? strength = 100, List? uses = null, List? closes = null, + List<(uint item, uint container, int placement)>? puts = null) => InventoryController.Bind(layout, objects, () => Player, - iconIds: (_, _, _, _, _) => 0u, // no real GL upload in tests + iconIds: (_, _, _, _, _) => 0u, strength: () => strength, datFont: null, sendUse: uses is null ? null : g => uses.Add(g), - sendNoLongerViewing: closes is null ? null : g => closes.Add(g)); + sendNoLongerViewing: closes is null ? null : g => closes.Add(g), + sendPutItemInContainer: puts is null ? null : (i, c, p) => puts.Add((i, c, p))); // 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) @@ -403,6 +405,85 @@ public class InventoryControllerTests Assert.Equal(-1f, grid.GetItem(0)!.CapacityFill); // hidden — not a container } + 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 0xFFFF from elsewhere; drop on the grid cell holding 0xB (slot 1). + objects.AddOrUpdate(new ClientObject { ObjectId = 0xFFFFu }); + var bCell = grid.GetItem(1)!; // ItemId == 0xB, SlotIndex 1 + ((IItemListDragHandler)ctrl).HandleDropRelease(grid, bCell, Payload(0xFFFFu)); + + Assert.Contains((0xFFFFu, Player, 1), puts); // insert-before slot 1, into the open container + Assert.Equal(Player, objects.Get(0xFFFFu)!.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 = 0xFFFFu }); + var emptyCell = grid.GetItem(5)!; // an empty padded cell (ItemId 0) + ((IItemListDragHandler)ctrl).HandleDropRelease(grid, emptyCell, Payload(0xFFFFu)); + + Assert.Contains((0xFFFFu, 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 = 0xFFFFu }); + var bagCell = containers.GetItem(0)!; // ItemId == 0xC (the bag) + ((IItemListDragHandler)ctrl).HandleDropRelease(containers, bagCell, Payload(0xFFFFu)); + + Assert.Contains((0xFFFFu, 0xCu, 0), puts); // into the bag, append (placement 0) + Assert.Equal(0xCu, objects.Get(0xFFFFu)!.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 = 0xFFFFu }); + var ctrl = (IItemListDragHandler)Bind(layout, objects); + + Assert.False(ctrl.OnDragOver(containers, containers.GetItem(0)!, Payload(0xFFFFu))); // full bag → red + Assert.True(ctrl.OnDragOver(grid, grid.GetItem(0)!, Payload(0xFFFFu))); // 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(0xAu)); + Assert.Equal(Player, objects.Get(0xAu)!.ContainerId); // NOT removed on lift (unlike the toolbar) + } + // Reads the text of the UiText caption child attached by the controller. private static string CaptionText(UiElement host) { From 124a4a2efaab7433e945c6c59d0105d0e4065643 Mon Sep 17 00:00:00 2001 From: Erik Date: Mon, 22 Jun 2026 19:55:20 +0200 Subject: [PATCH 112/133] feat(D.2b): wire InventoryController drag-drop to the live session Co-Authored-By: Claude Opus 4.8 (1M context) --- src/AcDream.App/Rendering/GameWindow.cs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs index a52bd3f8..350261e8 100644 --- a/src/AcDream.App/Rendering/GameWindow.cs +++ b/src/AcDream.App/Rendering/GameWindow.cs @@ -2240,7 +2240,9 @@ public sealed class GameWindow : IDisposable sideBagEmptySprite: sideBagEmpty, mainPackEmptySprite: mainPackEmpty, sendUse: g => _liveSession?.SendUse(g), - sendNoLongerViewing: g => _liveSession?.SendNoLongerViewingContents(g)); + sendNoLongerViewing: g => _liveSession?.SendNoLongerViewingContents(g), + sendPutItemInContainer: (item, container, placement) => + _liveSession?.SendPutItemInContainer(item, container, placement)); Console.WriteLine("[D.2b-B] retail inventory window from LayoutDesc importer (0x21000023)."); } From 468da225d316ba0ab5428a2d88736dedc69f769a Mon Sep 17 00:00:00 2001 From: Erik Date: Mon, 22 Jun 2026 19:56:19 +0200 Subject: [PATCH 113/133] 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) --- docs/architecture/retail-divergence-register.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docs/architecture/retail-divergence-register.md b/docs/architecture/retail-divergence-register.md index 31fc055e..2f9e8dba 100644 --- a/docs/architecture/retail-divergence-register.md +++ b/docs/architecture/retail-divergence-register.md @@ -158,6 +158,8 @@ accepted-divergence entries (#96, #49, #50). | AP-57 | 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. | `src/AcDream.App/UI/UiItemSlot.cs`; `src/AcDream.App/UI/Layout/InventoryController.cs` | The procedural overlay produces the correct visual result; the 36×36 container prototype (`0x1000033F`) lacks the square child, so the procedural overlay is the only way to show the square on a selected bag — the dat-state path retail uses would require the prototype to be richer than it is. | 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 for the open/selected indicator cases. | `UIElement_ItemList::UpdateOpenContainerIndicator` 0x004e3070; `ItemList_SetSelectedItem` 0x004e2fe0; `SetOpenContainerState` 0x004e1200; `SetSelectedState` 0x004e1240 | | AP-58 | Inventory item **selection is panel-local + visual-only** — clicking a grid item moves the green square but does not wire to the selected-object bar (`SelectedObjectController`) or to global 3D-world selection. | `src/AcDream.App/UI/Layout/InventoryController.cs` (`SelectItem`) | The selected-object bar + 3D-world selection wiring is deferred to the selection follow-up; the panel-local square is the correct first step. | Selecting an inventory item doesn't populate the selected-object strip as retail does — functional gap deferred. | `gmInventoryUI`/`gmBackpackUI` item-select → `ClientUISystem::SetSelectedObject`; `SelectedObjectController` | | AP-59 | The per-cell container **capacity bar** (retail `UIElement_UIItem::UpdateCapacityDisplay 0x004e16e0`, element `0x10000347`) is drawn as a **procedural `UiItemSlot` overlay** (track `0x06004D22` full + fill `0x06004D23` clipped bottom-up to `GetContents(guid).Count / ItemsCapacity`), not via a real dat `UIElement_Meter` child. **Right-anchored flush** (the dat rect X=26 in a 36px cell sat ~5px off the edge — flush per the visual gate) and **bottom-up fill assumed** (the dat `m_eDirection` 0x6f isn't read, cf. AP-50). A CLOSED side bag reads empty until opened (its contents aren't indexed until `ViewContents`) — faithful to retail's known-children count, divergent if retail pre-loads. | `src/AcDream.App/UI/UiItemSlot.cs` (`CapacityFill` draw); `src/AcDream.App/UI/Layout/InventoryController.cs` (`SetCapacityBar`) | UiItemSlot is a behavioral leaf that paints overlays procedurally (cf. AP-57); the meter sprites + fill formula are the faithful port. Right-anchor + bottom-up were visual-gate calls. Further visual polish is deferred — ISSUES #146. | Fill direction / exact bar rect could differ from retail's `m_eDirection` + dat X; closed-bag bars read empty if retail pre-loads container counts. | `UIElement_UIItem::UpdateCapacityDisplay` 0x004e16e0; element `0x10000347` (back `0x06004D22` / front `0x06004D23`); `GetNumContainedItems` | +| AP-60 | Inventory drag **`OnDragLift` is a no-op** + the source cell is **not dimmed**: retail dims the lifted item's source cell (`RecvNotice_ItemListBeginDrag`); acdream leaves the item in place during the drag + the floating ghost. | `src/AcDream.App/UI/Layout/InventoryController.cs` | Cosmetic only — the dragged item is still unambiguously identifiable; dimming the source requires tracking the source cell reference across drag events, deferred to a polish pass. | A drag in progress doesn't visually mark its origin — cosmetic only, no functional effect. | `RecvNotice_ItemListBeginDrag` acclient_2013_pseudo_c.txt | +| 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. | `src/AcDream.App/UI/Layout/InventoryController.cs` (`IsContainerFull`) | A drop into a closed-but-full bag shows green then snaps back (a flicker) instead of pre-showing red. Faithful only when the bag has been opened (then `GetContents` is populated). The server's `0x00A0` + optimistic rollback is the authoritative safety net. | A drop into a closed-but-full bag shows green → brief flicker → snap-back instead of retail's pre-emptive red circle. | `UIElement_ItemList::InqDropIconInfo 0x004e26f0` | --- From 8df395894e6d0b714a9ea26a0bd40c4014a4636d Mon Sep 17 00:00:00 2001 From: Erik Date: Mon, 22 Jun 2026 20:03:24 +0200 Subject: [PATCH 114/133] test(D.2b): close drag-drop spec-review coverage gaps Add the 3 tests the spec-compliance review flagged: 0x0022 ConfirmMove clears the pending move (no rollback after a confirm); OnDragOver advisory-accepts a closed bag (cap>0, contents not indexed = AP-61); App-level drop -> RollbackMove reverts the optimistic move to the original container+slot. Production code was already correct; these exercise the spec's test plan. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../UI/Layout/InventoryControllerTests.cs | 30 +++++++++++++++++++ .../GameEventWiringTests.cs | 22 ++++++++++++++ 2 files changed, 52 insertions(+) diff --git a/tests/AcDream.App.Tests/UI/Layout/InventoryControllerTests.cs b/tests/AcDream.App.Tests/UI/Layout/InventoryControllerTests.cs index 296a1059..efa69730 100644 --- a/tests/AcDream.App.Tests/UI/Layout/InventoryControllerTests.cs +++ b/tests/AcDream.App.Tests/UI/Layout/InventoryControllerTests.cs @@ -484,6 +484,36 @@ public class InventoryControllerTests Assert.Equal(Player, objects.Get(0xAu)!.ContainerId); // NOT removed on lift (unlike the toolbar) } + [Fact] + public void OnDragOver_closedBag_unknownCount_advisoryAccepts() // AP-61 + { + var (layout, _, containers, _, _, _, _, _) = BuildLayout(); + var objects = new ClientObjectTable(); + SeedBag(objects, 0xC, slot: 0, itemsCapacity: 24); // a bag with capacity but NO indexed contents (closed) + objects.AddOrUpdate(new ClientObject { ObjectId = 0xFFFFu }); + var ctrl = (IItemListDragHandler)Bind(layout, objects); + + // GetContents(0xC) is empty (a closed bag's contents aren't indexed until opened) → not known-full → accept. + Assert.True(ctrl.OnDragOver(containers, containers.GetItem(0)!, Payload(0xFFFFu))); + } + + [Fact] + public void Drop_thenServerRollback_revertsTheMove() // optimistic + InventoryServerSaveFailed snap-back + { + var (layout, _, containers, _, _, _, _, _) = BuildLayout(); + var objects = new ClientObjectTable(); + SeedContained(objects, 0xA, Player, slot: 3); // item starts in the main pack at slot 3 + SeedBag(objects, 0xC, slot: 0, itemsCapacity: 24); + var ctrl = Bind(layout, objects); + + ((IItemListDragHandler)ctrl).HandleDropRelease(containers, containers.GetItem(0)!, Payload(0xAu)); + Assert.Equal(0xCu, objects.Get(0xAu)!.ContainerId); // moved into the bag optimistically (instant) + + objects.RollbackMove(0xAu); // server rejected (InventoryServerSaveFailed) + Assert.Equal(Player, objects.Get(0xAu)!.ContainerId); // snapped back to the main pack + Assert.Equal(3, objects.Get(0xAu)!.ContainerSlot); // and the original slot + } + // Reads the text of the UiText caption child attached by the controller. private static string CaptionText(UiElement host) { diff --git a/tests/AcDream.Core.Net.Tests/GameEventWiringTests.cs b/tests/AcDream.Core.Net.Tests/GameEventWiringTests.cs index 92d7b471..ae191438 100644 --- a/tests/AcDream.Core.Net.Tests/GameEventWiringTests.cs +++ b/tests/AcDream.Core.Net.Tests/GameEventWiringTests.cs @@ -606,6 +606,28 @@ public sealed class GameEventWiringTests Assert.Equal(2, items.Get(0x50000B01u)!.ContainerSlot); } + [Fact] + public void WireAll_InventoryPutObjInContainer_ConfirmsOptimisticMove_soNoRollback() + { + var (d, items, _, _, _) = MakeAll(); + items.RecordMembership(0x50000B02u, containerId: 0x500000C0u); + items.MoveItem(0x50000B02u, 0x500000C0u, 2); + items.MoveItemOptimistic(0x50000B02u, 0x500000C1u, 0); // optimistic move (pending snapshot) + + // Server CONFIRMS via InventoryPutObjInContainer (item, container, placement, containerType). + byte[] payload = new byte[16]; + BinaryPrimitives.WriteUInt32LittleEndian(payload.AsSpan(0), 0x50000B02u); + BinaryPrimitives.WriteUInt32LittleEndian(payload.AsSpan(4), 0x500000C1u); + BinaryPrimitives.WriteUInt32LittleEndian(payload.AsSpan(8), 0u); // placement + BinaryPrimitives.WriteUInt32LittleEndian(payload.AsSpan(12), 0u); // containerType + var env = GameEventEnvelope.TryParse(WrapEnvelope(GameEventType.InventoryPutObjInContainer, payload)); + d.Dispatch(env!.Value); + + // ConfirmMove cleared the pending snapshot → a later rollback is a no-op (the move stuck). + Assert.False(items.RollbackMove(0x50000B02u)); + Assert.Equal(0x500000C1u, items.Get(0x50000B02u)!.ContainerId); + } + [Fact] public void WireAll_InventoryPutObjectIn3D_UnparentsFromContainer() { From 975f89c8705987663f954ef5dc84d7825cec70f0 Mon Sep 17 00:00:00 2001 From: Erik Date: Mon, 22 Jun 2026 20:12:54 +0200 Subject: [PATCH 115/133] fix(D.2b): harden optimistic-move pending map (Opus review I1+I2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit I2 (real bug): Clear() (teleport/logoff) and Remove() (item destroyed) now drop the item's _pendingMoves entry — a stale snapshot on a recycled guid could otherwise mis-rollback a different future item. I1 (race): track an OUTSTANDING count per item so an early ConfirmMove (0x0022) for the first of several in-flight moves of the SAME item can't clear the snapshot while a later move is unconfirmed — a later reject can still roll back to the original instead of stranding the item at the rejected position. Both with regression tests; full suite green. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/AcDream.Core/Items/ClientObjectTable.cs | 31 ++++++++++++++----- .../Items/ClientObjectTableTests.cs | 26 ++++++++++++++++ 2 files changed, 49 insertions(+), 8 deletions(-) diff --git a/src/AcDream.Core/Items/ClientObjectTable.cs b/src/AcDream.Core/Items/ClientObjectTable.cs index 238f0eb7..73a40315 100644 --- a/src/AcDream.Core/Items/ClientObjectTable.cs +++ b/src/AcDream.Core/Items/ClientObjectTable.cs @@ -47,7 +47,7 @@ public sealed class ClientObjectTable // 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 _pendingMoves = new(); + private readonly Dictionary _pendingMoves = new(); /// Fires when an object is first added to the session. public event Action? ObjectAdded; @@ -144,17 +144,30 @@ public sealed class ClientObjectTable 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 + // Snapshot the ORIGINAL position once + count OUTSTANDING optimistic moves of this item. The + // count stops an early confirm (0x0022) for the first of several in-flight moves of the SAME + // item from clearing the snapshot while a later move is still unconfirmed — else a later + // reject would find nothing pending and strand the item at the rejected position. + if (_pendingMoves.TryGetValue(itemId, out var p)) + _pendingMoves[itemId] = (p.container, p.slot, p.outstanding + 1); + else + _pendingMoves[itemId] = (item.ContainerId, item.ContainerSlot, 1); return MoveItem(itemId, newContainerId, newSlot); } - /// The server confirmed the move (InventoryPutObjInContainer 0x0022 echo) — drop the - /// pending snapshot. No-op for a server-initiated move we never tracked. - public void ConfirmMove(uint itemId) => _pendingMoves.Remove(itemId); + /// The server confirmed a move (InventoryPutObjInContainer 0x0022 echo) — decrement the + /// outstanding count; drop the snapshot only once no optimistic moves of this item remain. No-op + /// for a server-initiated move we never tracked. + public void ConfirmMove(uint itemId) + { + if (!_pendingMoves.TryGetValue(itemId, out var p)) return; + if (p.outstanding <= 1) _pendingMoves.Remove(itemId); + else _pendingMoves[itemId] = (p.container, p.slot, p.outstanding - 1); + } - /// 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. + /// The server rejected a move (InventoryServerSaveFailed 0x00A0) — restore the item to its + /// pre-move (container, slot) and drop the snapshot entirely (the server's next snapshot reconciles + /// any still-outstanding moves). False if nothing was pending. public bool RollbackMove(uint itemId) { if (!_pendingMoves.TryGetValue(itemId, out var pre)) return false; @@ -171,6 +184,7 @@ public sealed class ClientObjectTable if (!_objects.TryRemove(itemId, out var item)) return false; if (item.ContainerId != 0 && _containerIndex.TryGetValue(item.ContainerId, out var l)) l.Remove(itemId); + _pendingMoves.Remove(itemId); // a destroyed item must not leave a snapshot that mis-rolls-back a recycled guid ObjectRemoved?.Invoke(item); return true; } @@ -430,5 +444,6 @@ public sealed class ClientObjectTable _objects.Clear(); _containers.Clear(); _containerIndex.Clear(); + _pendingMoves.Clear(); // B-Drag: drop in-flight optimistic snapshots (a recycled guid must not mis-rollback) } } diff --git a/tests/AcDream.Core.Tests/Items/ClientObjectTableTests.cs b/tests/AcDream.Core.Tests/Items/ClientObjectTableTests.cs index dc239fda..188370c6 100644 --- a/tests/AcDream.Core.Tests/Items/ClientObjectTableTests.cs +++ b/tests/AcDream.Core.Tests/Items/ClientObjectTableTests.cs @@ -396,6 +396,32 @@ public sealed class ClientObjectTableTests public void RollbackMove_unknownItem_false() => Assert.False(new ClientObjectTable().RollbackMove(0xDEADu)); + [Fact] + public void MoveItemOptimistic_twice_oneConfirm_stillRollsBack() // I1: an early confirm must not strand the item + { + var table = new ClientObjectTable(); + table.Ingest(FullWeenie(0x910u, container: 0xC0u)); table.MoveItem(0x910u, 0xC0u, 5); + table.MoveItemOptimistic(0x910u, 0xC1u, 0); // move 1 (outstanding 1, snapshot C0/5) + table.MoveItemOptimistic(0x910u, 0xC2u, 0); // move 2 (outstanding 2) + table.ConfirmMove(0x910u); // server confirms move 1 → outstanding 1, STILL pending + Assert.True(table.RollbackMove(0x910u)); // move 2 rejected → can still roll back (not stranded at C2) + Assert.Equal(0xC0u, table.Get(0x910u)!.ContainerId); + Assert.Equal(5, table.Get(0x910u)!.ContainerSlot); + } + + [Fact] + public void Clear_dropsPendingMoves_soRecycledGuidIsntMisRolledBack() // I2: pending must not survive a table flush + { + var table = new ClientObjectTable(); + table.Ingest(FullWeenie(0x911u, container: 0xC0u)); + table.MoveItemOptimistic(0x911u, 0xC1u, 0); // pending snapshot for 0x911 + table.Clear(); // teleport/logoff flushes the table + + table.Ingest(FullWeenie(0x911u, container: 0xC5u)); // a recycled guid reappears fresh + Assert.False(table.RollbackMove(0x911u)); // no stale pending → no mis-rollback + Assert.Equal(0xC5u, table.Get(0x911u)!.ContainerId); + } + [Fact] public void ContainerIndex_SlotChange_ResortsInPlace() // guards the Reindex same-container early-out { From 82ab0e045a7a68838672de293e9a80cc71526cbc Mon Sep 17 00:00:00 2001 From: Erik Date: Mon, 22 Jun 2026 20:29:22 +0200 Subject: [PATCH 116/133] =?UTF-8?q?fix(D.2b):=20gapless=20insert=20on=20op?= =?UTF-8?q?timistic=20move=20=E2=80=94=20stop=20the=20inventory=20reshuffl?= =?UTF-8?q?e?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Visual gate: moving an item within a bag reshuffled every item ("the order is not set"). Root cause: insert-before set ONLY the dragged item's ContainerSlot to N, colliding with the item already at N; the sort-by-slot tie then reordered the whole grid on every repaint. Fix: MoveItemOptimistic now does a proper index-based INSERT (shift the others, renumber 0..N-1 gapless) like retail's ItemList_InsertItem, and HandleDropRelease uses the target's GRID INDEX (SlotIndex) as the placement rather than its raw ContainerSlot. Regression test pins the shifted, gapless order. Full suite green. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../UI/Layout/InventoryController.cs | 2 +- src/AcDream.Core/Items/ClientObjectTable.cs | 35 ++++++++++++++++++- .../Items/ClientObjectTableTests.cs | 17 +++++++++ 3 files changed, 52 insertions(+), 2 deletions(-) diff --git a/src/AcDream.App/UI/Layout/InventoryController.cs b/src/AcDream.App/UI/Layout/InventoryController.cs index 7d43c24e..89bf6b1c 100644 --- a/src/AcDream.App/UI/Layout/InventoryController.cs +++ b/src/AcDream.App/UI/Layout/InventoryController.cs @@ -333,7 +333,7 @@ public sealed class InventoryController : IItemListDragHandler { container = EffectiveOpen(); placement = targetCell.ItemId != 0 - ? (_objects.Get(targetCell.ItemId)?.ContainerSlot ?? targetCell.SlotIndex) // insert-before + ? targetCell.SlotIndex // insert-before = the target's GRID INDEX (gapless), not its raw ContainerSlot : _objects.GetContents(container).Count; // first empty = append } else if (targetList == _containerList || targetList == _topContainer) diff --git a/src/AcDream.Core/Items/ClientObjectTable.cs b/src/AcDream.Core/Items/ClientObjectTable.cs index 73a40315..2eb0e69f 100644 --- a/src/AcDream.Core/Items/ClientObjectTable.cs +++ b/src/AcDream.Core/Items/ClientObjectTable.cs @@ -152,7 +152,40 @@ public sealed class ClientObjectTable _pendingMoves[itemId] = (p.container, p.slot, p.outstanding + 1); else _pendingMoves[itemId] = (item.ContainerId, item.ContainerSlot, 1); - return MoveItem(itemId, newContainerId, newSlot); + + // Gapless INSERT — treat newSlot as the insert INDEX, shift the other items, and renumber both + // containers' slots 0..N-1. A raw MoveItem sets ONLY this item's slot, which then collides with + // the item already at that slot; the sort-by-slot tie reshuffles the whole grid on every repaint + // (the "items change position when I move one" bug). Retail's ItemList_InsertItem shifts the list. + uint oldContainer = item.ContainerId; + item.ContainerId = newContainerId; + item.CurrentlyEquippedLocation = EquipMask.None; + + if (oldContainer != 0 && oldContainer != newContainerId + && _containerIndex.TryGetValue(oldContainer, out var srcList)) + { srcList.Remove(itemId); RenumberContainer(srcList); } // keep the source gapless too + + if (newContainerId != 0) + { + if (!_containerIndex.TryGetValue(newContainerId, out var dstList)) + _containerIndex[newContainerId] = dstList = new List(); + dstList.Remove(itemId); // same-container move: pull out first + int idx = (newSlot < 0 || newSlot > dstList.Count) ? dstList.Count : newSlot; + dstList.Insert(idx, itemId); + RenumberContainer(dstList); // sequential ContainerSlots → no ties + } + else item.ContainerSlot = newSlot; + + ObjectMoved?.Invoke(item, oldContainer, newContainerId); + return true; + } + + /// Assign each item in a sequential ContainerSlot (0..N-1) matching + /// its list position — keeps a container gapless + collision-free so the grid order is stable. + private void RenumberContainer(List list) + { + for (int i = 0; i < list.Count; i++) + if (_objects.TryGetValue(list[i], out var o)) o.ContainerSlot = i; } /// The server confirmed a move (InventoryPutObjInContainer 0x0022 echo) — decrement the diff --git a/tests/AcDream.Core.Tests/Items/ClientObjectTableTests.cs b/tests/AcDream.Core.Tests/Items/ClientObjectTableTests.cs index 188370c6..833ebb9f 100644 --- a/tests/AcDream.Core.Tests/Items/ClientObjectTableTests.cs +++ b/tests/AcDream.Core.Tests/Items/ClientObjectTableTests.cs @@ -422,6 +422,23 @@ public sealed class ClientObjectTableTests Assert.Equal(0xC5u, table.Get(0x911u)!.ContainerId); } + [Fact] + public void MoveItemOptimistic_insertBefore_shiftsOthers_keepsGaplessOrder() // the "items change position" bug + { + var table = new ClientObjectTable(); + table.Ingest(FullWeenie(0x920u, container: 0xC0u)); table.MoveItem(0x920u, 0xC0u, 0); + table.Ingest(FullWeenie(0x921u, container: 0xC0u)); table.MoveItem(0x921u, 0xC0u, 1); + table.Ingest(FullWeenie(0x922u, container: 0xC0u)); table.MoveItem(0x922u, 0xC0u, 2); + + // Move 0x922 (index 2) to index 0 → order [0x922, 0x920, 0x921], slots renumbered 0,1,2 (no ties, + // no reshuffle). A raw slot-set would leave 0x922 + 0x920 both at slot 0 → unstable sort. + table.MoveItemOptimistic(0x922u, 0xC0u, 0); + Assert.Equal(new[] { 0x922u, 0x920u, 0x921u }, table.GetContents(0xC0u)); + Assert.Equal(0, table.Get(0x922u)!.ContainerSlot); + Assert.Equal(1, table.Get(0x920u)!.ContainerSlot); + Assert.Equal(2, table.Get(0x921u)!.ContainerSlot); + } + [Fact] public void ContainerIndex_SlotChange_ResortsInPlace() // guards the Reindex same-container early-out { From 702058f7d479a34c2bea40c54e5a8fdf49c8a506 Mon Sep 17 00:00:00 2001 From: Erik Date: Mon, 22 Jun 2026 21:03:44 +0200 Subject: [PATCH 117/133] docs(D.2b): paperdoll (Sub-phase C) handoff + new-session prompt MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Detailed handoff for the next session. Key correction from the deep-dive: retail empty equip slots are TRANSPARENT (the doll shows through), NOT silhouettes — the current blue border is wrong. Decomposes into Slice 1 (equip slots: bind to CurrentlyEquippedLocation + drag-to-wield via the GetAndWieldItem 0x001A wire gap) and Slice 2 (the heavy 3D doll UiViewport Type 0xD + Core->App IUiViewportRenderer seam). Maps the reuse surface (UiItemSlot, drag spine, optimistic-move, EntitySpawnAdapter) + open questions + a paste-ready new-session prompt. Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/research/2026-06-22-paperdoll-handoff.md | 80 +++++++++++++++++++ 1 file changed, 80 insertions(+) create mode 100644 docs/research/2026-06-22-paperdoll-handoff.md diff --git a/docs/research/2026-06-22-paperdoll-handoff.md b/docs/research/2026-06-22-paperdoll-handoff.md new file mode 100644 index 00000000..5e892362 --- /dev/null +++ b/docs/research/2026-06-22-paperdoll-handoff.md @@ -0,0 +1,80 @@ +# Handoff — D.2b Sub-phase C: the paperdoll (equip slots + 3D doll) + +**Date:** 2026-06-22 (banked at the end of the inventory drag-drop session, per [[feedback_session_handoff]]) +**Branch:** `claude/hopeful-maxwell-214a12`, tip `82ab0e0`. `main` is a clean fast-forward ancestor. +**Status:** SCOPED, not started. The decomposition + corrections + reuse map below are the design sketch; a fresh session runs the full brainstorm → spec → plan → subagent-driven → visual-gate flow **per slice**. +**Line numbers drift — grep the symbol.** + +--- + +## Read first (in this order) + +1. **This doc.** +2. **`docs/research/2026-06-16-equipment-paperdoll-deep-dive.md`** — the AUTHORITATIVE research. Full element-id → `EquipMask` table (§3a, ~25 slots), the viewport mechanism (§5), the wield wire (§4), the reuse analysis (§5c), and the open `UNVERIFIED` list (§7). **Do NOT re-derive what's already CONFIRMED there — cite it.** +3. **`claude-memory/project_d2b_retail_ui.md`** — the D.2b SSOT (the shipped log: container-switching, icons, capacity bars, drag-drop; the `UiItemSlot`/drag-spine/importer facts you'll reuse). +4. **`docs/architecture/retail-divergence-register.md`** — for the AP rows you'll add; and `docs/architecture/code-structure.md` Rule 2 (the Core→App seam constraint that governs the doll viewport). + +## Why this is next + +The inventory window is functionally complete in 2D (browse/switch bags, icons, capacity bars, drag-drop move). The **last placeholder** is the paperdoll: the ~25 equip slots render as generic blue `UiDatElement` borders, and there's no character doll. It's the standing roadmap NEXT and the most visible remaining gap when F12 is open. + +## ⚠ Correction to carry in (the deep-dive settles this — don't repeat the old assumption) + +The earlier brainstorm framed Slice 1 as "per-slot body-part **silhouettes**." **That is wrong.** Per the deep-dive §2a (decomp lines 99–102, CONFIRMED): retail equip slots **default INVISIBLE and show only when occupied** — an **empty** slot is **transparent and the doll body shows through it**; an **occupied** slot shows the item icon. There are no per-slot silhouette sprites. acdream's current blue border is simply the wrong rendering of an empty slot. + +Consequence: the doll is the visual backdrop the empty slots reveal. Slice 1 (slots) and Slice 2 (doll) are therefore **interdependent** for the empty-slot look — decide the empty-slot treatment for a doll-less Slice 1 in the brainstorm (transparent-to-panel vs a temporary placeholder). + +## The decomposition — TWO slices (each its own spec → plan → implement) + +### Slice 1 — equip slots (functional; the lighter, independently-valuable first piece) + +Bind the ~25 equip slots to live equipped-item data + make them drag-drop equip/unequip targets. **No 3D doll yet.** Delivers: you SEE your equipped gear (icons in the right slots) and can drag gear onto a slot to wield it / off to unwield. + +- **The slots already exist + are positioned** (the inventory frame mounts gmPaperDollUI at `0x100001CD`; `DatWidgetFactory` builds `0x10000031` → `UiItemList`; B-Grid confirmed the positions). The work is a new **`PaperdollController`** (the `gmPaperDollUI::PostInit` analogue) that: + - Maps each slot **element-id → `EquipMask`** via the deep-dive §3a table (port `GetLocationInfoFromElementID`, decomp 173620 — it's a static switch; transcribe the table, don't call the decomp at runtime). + - Populates each slot's icon from the item whose `ClientObject.CurrentlyEquippedLocation == that slot's EquipMask` (acdream ALREADY tracks `CurrentlyEquippedLocation` per item via `WieldObject 0x0023` + `CreateObject`; `ClientObjectTable`). So the equipped-state data is already there — no new parse needed for Slice 1 MVP. + - Makes each slot a `UiItemSlot` (the shipped shared widget) — single 32×32 cell, occupancy-gated icon, drag accept/reject overlay. Empty-slot rendering per the correction above. + - Registers the controller as the `IItemListDragHandler` for the equip slots (reuse the shipped drag-drop spine): `OnDragOver` accepts iff the dragged item's `ValidLocations` includes the slot's `EquipMask`; `HandleDropRelease` = **wield** (`GetAndWieldItem 0x001A`) optimistically + via wire; dragging a slot's item OUT to the grid = un-wield (`PutItemInContainer 0x0019`, the SHIPPED path). +- **THE WIRE GAP (build it):** `GetAndWieldItem` (`0x001A`, payload `uint itemGuid; (uint)EquipMask`) has **no builder/sender in acdream** (deep-dive §4a — only the unrelated UI font property `0x1A` exists). Add `InteractRequests.BuildGetAndWieldItem(seq, itemGuid, equipMask)` (mirror `BuildPickUp`) + `WorldSession.SendGetAndWieldItem(...)` (mirror `SendPutItemInContainer`). ACE: `GameActionGetAndWieldItem.Handle` (item + EquipMask). holtburger `commands.rs:808` confirms the shape. +- **The appearance reply** (`ObjDescEvent 0xF625`) is already PARSED (deep-dive §4a); it matters for the DOLL (Slice 2), not Slice 1's icons. + +**Slice 1 scope cuts:** no doll, no part-selection highlight, no left/right-side disambiguation polish (the paired wrist/finger slots — handle via the SIDE column in §3a only if trivial), no Aetheria sigil slots (hidden by default). + +### Slice 2 — the 3D doll viewport (the heavy piece: the UI↔3D bridge) + +The `UIElement_Viewport` (Type `0xD`, element `0x100001D5` in gmPaperDollUI) hosting a re-dressed clone of the local player, drawn into the slot region behind the equip slots. **This is the single biggest new piece** — see deep-dive §5 (CONFIRMED viewport mechanism) + §5d (the design-open seam). + +- **Reuse the existing char path** (deep-dive §5c, CONFIRMED): acdream already renders animated, equipped characters in-world via `EntitySpawnAdapter` → `AnimatedEntityState` (palette/part/hidden-part overrides from ObjDesc). The doll = "build a `WorldEntity` from the local player's Setup + current ObjDesc, feed it through that pipeline, draw it with a fixed camera + one distant light into a UI rect." Re-dress on `ObjDescEvent 0xF625` = rebuild the entity's overrides. +- **The new widget = `UiViewport`** (registers at dat Type `0xD`, leaf). It needs a **render-into-rect seam**: a Core `IUiViewportRenderer` interface implemented in App (Code-Structure Rule 2 — Core defines, App implements; never the reverse), invoked as a scissored single-entity 3D pass keyed to the widget's screen rect. The exact integration (after the world pass vs a UI overlay) is **DESIGN-OPEN — settle in the Slice 2 brainstorm** (deep-dive §7). +- Camera/light immediates, heading-toward-viewer (191.37°), idle animation, and the player-clone-vs-fresh-WorldEntity question are all in deep-dive §5d + §7 (some `UNVERIFIED` — decode the float immediates there). + +## What's already in place (reuse, don't rebuild) + +- **The mount:** `GameWindow.cs:~2208` pins `0x100001CD` (paperdoll) in the inventory frame; the importer pulls in the nested gmPaperDollUI (`0x21000024`) subtree (`InventoryFrameImportProbe.cs:25` = `PaperdollPanel`). The equip-slot elements are already imported + positioned. +- **`UiItemSlot`** (shared item widget) — occupancy-gated icon, `SpriteResolve`, drag payload/ghost, `DragAcceptSprite`/`DragRejectSprite`, `IsOpenContainer`/`Selected`/`CapacityFill` overlays. The equip slot is the single-cell case. +- **The drag-drop spine** (`IItemListDragHandler`, `UiRoot` BeginDrag/FinishDrag, `UiItemSlot` drag events) + the **optimistic-move machinery** (`ClientObjectTable.MoveItemOptimistic`/`ConfirmMove`/`RollbackMove` + the gapless-insert + the pending-count) — wield/unwield reuses this (wield = an equip-target drop handler; unwield = the shipped `PutItemInContainer` path). +- **`ClientObjectTable.CurrentlyEquippedLocation`** per item — the equipped-state source for Slice 1 (no new parse needed for MVP). +- **`IconComposer`** — item icons (incl. the `IsThePlayer` constant-backpack special; deep-dive notes the paperdoll icon path). +- **`EntitySpawnAdapter` / `AnimatedEntityState`** — the char render path Slice 2 reuses. +- **`ObjDescEvent 0xF625`** parse + `CreateObject.ReadModelData` (palette/part/texture/anim-part) — Slice 2's re-dress input. + +## Open questions for the brainstorm(s) + +- **Slice 1 empty-slot look without the doll** — transparent-to-panel vs a temporary placeholder (the doll is the real backdrop). The correction above means there's no silhouette sprite to use. +- **Equipped-icon source** — `CurrentlyEquippedLocation` (have it) is enough for MVP; the deep-dive §3c/§7 notes the richer `InventoryPlacement` equipped list (PlayerDescription equipped section) is **not surfaced** by `PlayerDescriptionParser` (partial). Only extend the parser if MVP needs priority/coverage resolution (overlapping armor/clothing) — likely Slice-2-or-later. +- **`GetAndWieldItem` placement/auto-slot** — does the EquipMask come purely from the drop-target slot (like the doll's `GetLocationInfoFromElementID`), and how does auto-wield (drop on the doll body, not a specific slot) pick the slot? holtburger `resolve_and_clear_slots` (§4a) is the reference. +- **The whole of Slice 2's UI↔3D seam** (deep-dive §7) — the heaviest design call; its own brainstorm. +- **`0x100001E0 = MissileAmmo 0x800000`** is LIKELY (corrupted decomp immediate, §7) — confirm by dump if the ammo slot matters for MVP. + +## Acceptance / scope per slice + +- **Slice 1:** equip slots show your equipped gear's icons in the right slots; drag a wieldable item onto a slot → it wields (`GetAndWieldItem 0x001A`, optimistic + rollback); drag a slot's item to the pack → it unwields. Build + full suite green; **visual gate** (F12 → equipped gear shows in the doll slots, drag-to-equip works). Divergence rows for any approximation. Retires the "generic blue border" gap. +- **Slice 2:** the 3D doll renders the re-dressed player clone behind the slots; empty slots show the doll body through them; equipping updates the doll live (via `ObjDescEvent`). Its own spec/plan; the `IUiViewportRenderer` seam is the crux. + +## New-session prompt + +> Continue acdream's D.2b retail-UI inventory arc on branch `claude/hopeful-maxwell-214a12` (tip `82ab0e0`; `main` is a clean ff ancestor). **READ FIRST:** `docs/research/2026-06-22-paperdoll-handoff.md`, then its "Read first" list (esp. the deep-dive `docs/research/2026-06-16-equipment-paperdoll-deep-dive.md` and `claude-memory/project_d2b_retail_ui.md`). +> +> **NEXT: Sub-phase C — the paperdoll, Slice 1 (equip slots), first.** Bind the ~25 mounted equip slots (under `0x100001CD` / gmPaperDollUI `0x21000024`) to live equipped-item data and make them drag-drop wield/unwield targets — a new `PaperdollController` (the `gmPaperDollUI::PostInit` analogue) that maps element-id → `EquipMask` (deep-dive §3a table), populates each slot's icon from the item whose `CurrentlyEquippedLocation == that slot's EquipMask`, and registers as the equip-slots' `IItemListDragHandler`. **Build the wire gap:** `GetAndWieldItem 0x001A` (`itemGuid + EquipMask`) — `InteractRequests.BuildGetAndWieldItem` + `WorldSession.SendGetAndWieldItem` (mirror `BuildPickUp`/`SendPutItemInContainer`); wield = optimistic + rollback (reuse `MoveItemOptimistic`); unwield = the shipped `PutItemInContainer 0x0019` path. **Carry the correction:** retail empty equip slots are TRANSPARENT (the doll shows through), NOT silhouettes — decide the doll-less empty-slot look in the brainstorm. **Slice 2 (the 3D doll `UiViewport` Type 0xD + the Core→App `IUiViewportRenderer` seam) is a SEPARATE, heavier cycle — do not start it in Slice 1.** +> +> Run the full **brainstorm → spec → plan → subagent-driven → visual-gate** flow for Slice 1. **DO NOT auto-kill the running client** — launch with plain `dotnet run`; if a rebuild is locked, ask the user to close it. Reuse the shipped `UiItemSlot` + drag-drop spine + optimistic-move machinery; don't rebuild them. From 2d7ea7446d205395a4d6392533797ae793f6fe27 Mon Sep 17 00:00:00 2001 From: Erik Date: Mon, 22 Jun 2026 21:38:24 +0200 Subject: [PATCH 118/133] docs(D.2b): paperdoll Slice 1 (equip slots) design spec MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Sub-phase C, Slice 1 — bind the ~25 paperdoll equip slots to live equipped-item data + make them drag-drop wield/unwield targets. No 3D doll (that's Slice 2). Brainstorm findings baked in: - The handoff's "build the wire gap" premise is STALE: BuildGetAndWieldItem + SendGetAndWieldItem already shipped in B-Wire. The whole optimistic-move machine + InventoryController:IItemListDragHandler already exist — Slice 1 mirrors them. Unwield is free (the inventory grid handler already does it). - Found a latent bug: acdream's EquipMask enum diverges from canonical AC (acclient.h:3193 INVENTORY_LOC) from bit 0x2000 up (phantom HandArmor/ FootArmor). Correct it to the verbatim retail values + a numeric-pin test. Blast radius is safe (4 round-trip test refs). - Empty equip slots are TRANSPARENT (EmptySprite=0), per the user — faithful, zero Slice-2 rework. Real scope: correct EquipMask (Core) + WieldItemOptimistic & equip-aware rollback snapshot (Core) + ConfirmMove on the WieldObject 0x0023 handler (Core.Net) + PaperdollController (App) + GameWindow wiring. Element-id→mask map verified dump ↔ deep-dive §3a ↔ acclient.h. Co-Authored-By: Claude Opus 4.8 (1M context) --- ...d2b-paperdoll-slice1-equip-slots-design.md | 337 ++++++++++++++++++ 1 file changed, 337 insertions(+) create mode 100644 docs/superpowers/specs/2026-06-22-d2b-paperdoll-slice1-equip-slots-design.md diff --git a/docs/superpowers/specs/2026-06-22-d2b-paperdoll-slice1-equip-slots-design.md b/docs/superpowers/specs/2026-06-22-d2b-paperdoll-slice1-equip-slots-design.md new file mode 100644 index 00000000..01761ea6 --- /dev/null +++ b/docs/superpowers/specs/2026-06-22-d2b-paperdoll-slice1-equip-slots-design.md @@ -0,0 +1,337 @@ +# 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`. 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 +/// 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. +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); +} +``` + +`MoveItem` already sets `ContainerId` + `CurrentlyEquippedLocation` + reindexes + fires `ObjectMoved`. +Setting `WielderId` here matches the steady state (the item's own CreateObject carries `Wielder`). + +--- + +## 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 = `; **`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.CurrentlyEquippedLocation != None || o.WielderId == player || o.ContainerId == player`; +`OnObjectMoved` also repaints when from/to is the player (an item being wielded/unwielded). 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 + WielderId + container; `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. From cf472bd8db2878fae1406427f9d60ff0c4f04bbe Mon Sep 17 00:00:00 2001 From: Erik Date: Mon, 22 Jun 2026 21:50:04 +0200 Subject: [PATCH 119/133] docs(D.2b): paperdoll Slice 1 implementation plan MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Six TDD tasks: (1) de-risk probe (equip slots resolve to UiItemList) → (2) correct EquipMask to canonical retail INVENTORY_LOC + numeric-pin test → (3) WieldItemOptimistic + equip-aware rollback snapshot → (4) ConfirmMove on the WieldObject 0x0023 echo → (5) PaperdollController + tests + divergence rows → (6) GameWindow wiring + full-suite gate. Then the user visual gate. Full no-placeholder code; all signatures verified against source. Co-Authored-By: Claude Opus 4.8 (1M context) --- ...-06-22-d2b-paperdoll-slice1-equip-slots.md | 862 ++++++++++++++++++ 1 file changed, 862 insertions(+) create mode 100644 docs/superpowers/plans/2026-06-22-d2b-paperdoll-slice1-equip-slots.md diff --git a/docs/superpowers/plans/2026-06-22-d2b-paperdoll-slice1-equip-slots.md b/docs/superpowers/plans/2026-06-22-d2b-paperdoll-slice1-equip-slots.md new file mode 100644 index 00000000..b4f7d92e --- /dev/null +++ b/docs/superpowers/plans/2026-06-22-d2b-paperdoll-slice1-equip-slots.md @@ -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; + +/// +/// 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. +/// +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 +/// +/// Equipment slot bitmask — the verbatim retail INVENTORY_LOC 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. +/// +[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 _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 +/// 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). +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 +/// 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. +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 lists) BuildLayout() + { + var ids = new[] { HeadSlot, ShieldSlot, WeaponSlot, FingerLSlot }; + var lists = new Dictionary(); + var byId = new Dictionary(); + 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)!.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; + +/// +/// 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). +/// +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 _playerGuid; + private readonly Func _iconIds; + private readonly Action? _sendWield; // (itemGuid, equipMask) → GetAndWieldItem 0x001A + private readonly List<(EquipMask Mask, UiItemList List)> _slots = new(); + + private PaperdollController( + ImportedLayout layout, ClientObjectTable objects, Func playerGuid, + Func iconIds, Action? 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 playerGuid, + Func iconIds, Action? 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(); } + + /// The object is (or just became / ceased to be) the player's equipped gear. + 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(); + 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 ────────────────────────────────────────────────────────────────────── + /// 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. + public void OnDragLift(UiItemList sourceList, UiItemSlot sourceCell, ItemDragPayload payload) { } + + /// Accept iff the dragged item can be worn here (its ValidLocations intersects the slot mask). + /// Retail: gmPaperDollUI::OnItemListDragOver (decomp 174302). + 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; + } + + /// 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. + 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); + } + + /// Detach event handlers (idempotent). + 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). From bd4b49f810062631ae6ad32e3ca1da6177fd6b7c Mon Sep 17 00:00:00 2001 From: Erik Date: Mon, 22 Jun 2026 21:52:54 +0200 Subject: [PATCH 120/133] =?UTF-8?q?test(D.2b):=20probe=20=E2=80=94=20paper?= =?UTF-8?q?doll=20equip=20slots=20resolve=20to=20UiItemList?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit De-risk gate for Sub-phase C: verifies that the gmPaperDollUI subtree imported under the inventory frame (0x21000023) materialises a representative set of 6 equip-slot element ids as UiItemList widgets, confirming the controller-binding plan can proceed without an importer pre-fix. Co-Authored-By: Claude Sonnet 4.6 --- .../UI/Layout/InventoryFrameImportProbe.cs | 21 +++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/tests/AcDream.App.Tests/UI/Layout/InventoryFrameImportProbe.cs b/tests/AcDream.App.Tests/UI/Layout/InventoryFrameImportProbe.cs index bc4acf60..57269580 100644 --- a/tests/AcDream.App.Tests/UI/Layout/InventoryFrameImportProbe.cs +++ b/tests/AcDream.App.Tests/UI/Layout/InventoryFrameImportProbe.cs @@ -1,5 +1,6 @@ using System; using System.IO; +using AcDream.App.UI; using AcDream.App.UI.Layout; using DatReaderWriter; using DatReaderWriter.Options; @@ -34,6 +35,26 @@ public class InventoryFrameImportProbe return Directory.Exists(d) ? d : null; } + [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"); + } + } + [Fact] public void Mounted_panels_sit_in_front_of_the_backdrop() { From 1810c71f5c45b1f0d8a7f480faa00641d2ed2ccc Mon Sep 17 00:00:00 2001 From: Erik Date: Mon, 22 Jun 2026 21:56:06 +0200 Subject: [PATCH 121/133] fix(D.2b): correct EquipMask to canonical retail INVENTORY_LOC + numeric-pin test The old enum invented two phantom bits (HandArmor=0x2000, FootArmor=0x10000) and used non-retail names (Necklace, LeftBracelet, RightBracelet, LeftRing, RightRing, AetheriaRed/Yellow/Blue), shifting every slot above 0x1000 out of alignment with retail INVENTORY_LOC (acclient.h:3193) and ACE EquipMask. Replace the enum body with verbatim retail values. Add EquipMaskTests to numeric-pin every member so future renumbering breaks at compile/test time. Existing consumers (ClientObjectTable, InventoryController, GameEventWiringTests, ClientObjectTableTests) only reference EquipMask.MeleeWeapon and EquipMask.None -- both present at their correct retail values -- so no call sites needed updating. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/AcDream.Core/Items/ClientObject.cs | 72 +++++++++---------- .../Items/EquipMaskTests.cs | 54 ++++++++++++++ 2 files changed, 90 insertions(+), 36 deletions(-) create mode 100644 tests/AcDream.Core.Tests/Items/EquipMaskTests.cs diff --git a/src/AcDream.Core/Items/ClientObject.cs b/src/AcDream.Core/Items/ClientObject.cs index 2ec5d309..cc3695b6 100644 --- a/src/AcDream.Core/Items/ClientObject.cs +++ b/src/AcDream.Core/Items/ClientObject.cs @@ -57,46 +57,46 @@ public enum ItemType : uint } /// -/// Equipment slot bitmask. 31 slots from head to Aetheria. Paperdoll -/// widget offsets +0x604..+0x660 in the retail panel correspond -/// to these bits 1:1 (see r06 §2 and UI slice 05 paperdoll section). +/// Equipment slot bitmask — the verbatim retail INVENTORY_LOC 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. /// [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, - HandArmor = 0x00002000, - UpperLegArmor = 0x00004000, - LowerLegArmor = 0x00008000, - FootArmor = 0x00010000, - Necklace = 0x00020000, - LeftBracelet = 0x00040000, - RightBracelet = 0x00080000, - LeftRing = 0x00100000, - RightRing = 0x00200000, - MeleeWeapon = 0x00400000, - Shield = 0x00800000, - MissileWeapon = 0x01000000, - Held = 0x02000000, // lit torch, book in hand - MissileAmmo = 0x04000000, - Cloak = 0x08000000, - TrinketOne = 0x10000000, - AetheriaRed = 0x20000000, - AetheriaYellow= 0x40000000, - AetheriaBlue = 0x80000000u, + 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, } /// diff --git a/tests/AcDream.Core.Tests/Items/EquipMaskTests.cs b/tests/AcDream.Core.Tests/Items/EquipMaskTests.cs new file mode 100644 index 00000000..d3576603 --- /dev/null +++ b/tests/AcDream.Core.Tests/Items/EquipMaskTests.cs @@ -0,0 +1,54 @@ +using AcDream.Core.Items; +using Xunit; + +namespace AcDream.Core.Tests.Items; + +/// +/// 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. +/// +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)); +} From 6d65177357e94254d4cd1e6454cd618c5f37ca34 Mon Sep 17 00:00:00 2001 From: Erik Date: Mon, 22 Jun 2026 22:01:07 +0200 Subject: [PATCH 122/133] =?UTF-8?q?style(D.2b):=20EquipMask=20polish=20?= =?UTF-8?q?=E2=80=94=20align=20FingerWearRight,=20note=20bit=2031=20(code?= =?UTF-8?q?=20review)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Code-quality review minors on Task 2: fix the missing space in the FingerWearRight= alignment and document why bit 31 (0x80000000, present only in the header's CLOTHING_LOC composite) has no named member. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/AcDream.Core/Items/ClientObject.cs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/AcDream.Core/Items/ClientObject.cs b/src/AcDream.Core/Items/ClientObject.cs index cc3695b6..b058bf20 100644 --- a/src/AcDream.Core/Items/ClientObject.cs +++ b/src/AcDream.Core/Items/ClientObject.cs @@ -61,6 +61,8 @@ public enum ItemType : uint /// (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. +/// (The header's CLOTHING_LOC composite also sets bit 31, 0x80000000, which is +/// not a named INVENTORY_LOC primitive and has no member here; ALL_LOC tops out at bit 30.) /// [Flags] public enum EquipMask : uint @@ -85,7 +87,7 @@ public enum EquipMask : uint WristWearLeft = 0x00010000, WristWearRight = 0x00020000, FingerWearLeft = 0x00040000, - FingerWearRight= 0x00080000, + FingerWearRight = 0x00080000, MeleeWeapon = 0x00100000, Shield = 0x00200000, MissileWeapon = 0x00400000, From 0c353185c3536ebac628ec43fa8f54f294cb6eb1 Mon Sep 17 00:00:00 2001 From: Erik Date: Mon, 22 Jun 2026 22:03:47 +0200 Subject: [PATCH 123/133] feat(D.2b): WieldItemOptimistic + equip-aware rollback snapshot (Core) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add WieldItemOptimistic (instant optimistic wield — sets ContainerId=WielderId=player, CurrentlyEquippedLocation=equipMask, snapshots pre-wield position) and extend the _pendingMoves tuple to carry the pre-move EquipMask so RollbackMove restores EQUIPPED state faithfully on server rejection. Shared RecordPending helper replaces the inline snapshot block in MoveItemOptimistic. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/AcDream.Core/Items/ClientObjectTable.cs | 38 +++++++++++---- .../Items/ClientObjectTableTests.cs | 46 +++++++++++++++++++ 2 files changed, 76 insertions(+), 8 deletions(-) diff --git a/src/AcDream.Core/Items/ClientObjectTable.cs b/src/AcDream.Core/Items/ClientObjectTable.cs index 2eb0e69f..2a310ea4 100644 --- a/src/AcDream.Core/Items/ClientObjectTable.cs +++ b/src/AcDream.Core/Items/ClientObjectTable.cs @@ -44,10 +44,10 @@ public sealed class ClientObjectTable private readonly ConcurrentDictionary _containers = new(); private readonly Dictionary> _containerIndex = new(); - // B-Drag: pre-move snapshots for optimistic inventory moves. itemId → (container, slot) BEFORE + // B-Drag: pre-move snapshots for optimistic inventory moves. itemId → (container, slot, equip) BEFORE // the optimistic MoveItem; restored by RollbackMove on InventoryServerSaveFailed (0x00A0), // cleared by ConfirmMove on the InventoryPutObjInContainer (0x0022) echo. - private readonly Dictionary _pendingMoves = new(); + private readonly Dictionary _pendingMoves = new(); /// Fires when an object is first added to the session. public event Action? ObjectAdded; @@ -135,6 +135,19 @@ public sealed class ClientObjectTable return true; } + /// 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). + 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); + } + /// /// Optimistic (instant) move: snapshot the item's current (ContainerId, ContainerSlot) the FIRST /// time it moves, then (immediate repaint via ObjectMoved). The wire @@ -148,10 +161,7 @@ public sealed class ClientObjectTable // count stops an early confirm (0x0022) for the first of several in-flight moves of the SAME // item from clearing the snapshot while a later move is still unconfirmed — else a later // reject would find nothing pending and strand the item at the rejected position. - if (_pendingMoves.TryGetValue(itemId, out var p)) - _pendingMoves[itemId] = (p.container, p.slot, p.outstanding + 1); - else - _pendingMoves[itemId] = (item.ContainerId, item.ContainerSlot, 1); + RecordPending(itemId, item); // Gapless INSERT — treat newSlot as the insert INDEX, shift the other items, and renumber both // containers' slots 0..N-1. A raw MoveItem sets ONLY this item's slot, which then collides with @@ -180,6 +190,18 @@ public sealed class ClientObjectTable return true; } + /// 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. + 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); + } + /// Assign each item in a sequential ContainerSlot (0..N-1) matching /// its list position — keeps a container gapless + collision-free so the grid order is stable. private void RenumberContainer(List list) @@ -195,7 +217,7 @@ public sealed class ClientObjectTable { if (!_pendingMoves.TryGetValue(itemId, out var p)) return; if (p.outstanding <= 1) _pendingMoves.Remove(itemId); - else _pendingMoves[itemId] = (p.container, p.slot, p.outstanding - 1); + else _pendingMoves[itemId] = (p.container, p.slot, p.equip, p.outstanding - 1); } /// The server rejected a move (InventoryServerSaveFailed 0x00A0) — restore the item to its @@ -205,7 +227,7 @@ public sealed class ClientObjectTable { if (!_pendingMoves.TryGetValue(itemId, out var pre)) return false; _pendingMoves.Remove(itemId); - return MoveItem(itemId, pre.container, pre.slot); + return MoveItem(itemId, pre.container, pre.slot, pre.equip); } /// diff --git a/tests/AcDream.Core.Tests/Items/ClientObjectTableTests.cs b/tests/AcDream.Core.Tests/Items/ClientObjectTableTests.cs index 833ebb9f..758e641a 100644 --- a/tests/AcDream.Core.Tests/Items/ClientObjectTableTests.cs +++ b/tests/AcDream.Core.Tests/Items/ClientObjectTableTests.cs @@ -453,4 +453,50 @@ public sealed class ClientObjectTableTests Assert.Equal(new[] { 0x541u, 0x540u }, table.GetContents(0xC4u)); Assert.Equal(2, table.GetContents(0xC4u).Count); // no duplicate from the same-container move } + + [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)); } From c1a84cbe0c1727cd711e9c0278d7ad53b13d1e46 Mon Sep 17 00:00:00 2001 From: Erik Date: Mon, 22 Jun 2026 22:15:18 +0200 Subject: [PATCH 124/133] =?UTF-8?q?fix(D.2b):=20wield=20is=20ContainerId-b?= =?UTF-8?q?ased=20=E2=80=94=20drop=20the=20WielderId=20write=20(code=20rev?= =?UTF-8?q?iew)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Code-quality review (needs-changes) on Task 3: WieldItemOptimistic wrote item.WielderId directly, but RollbackMove (via MoveItem) never cleared it → a wield-rollback left a pack item with a stale WielderId=player. Root-cause fix (vs the reviewer's snapshot-WielderId suggestion): acdream's existing WieldObject 0x0023 confirm models a wielded item as ContainerId= wielder + equip=mask and does NOT touch WielderId. So the optimistic path must match — drop the WielderId write entirely. Optimistic state now equals the confirmed state, rollback fully restores through MoveItem alone, and the stale-state class is structurally eliminated. The paperdoll's (WielderId==p || ContainerId==p) filter still matches optimistic wields via ContainerId (login-equipped items match via WielderId from their CreateObject). Also: + the wield+move outstanding-count combo test (spec §8), MoveItem doc note that it doesn't manage WielderId, and the test pins WielderId==0 post- optimistic-wield to document the model. Core 74/74 green. Co-Authored-By: Claude Opus 4.8 (1M context) --- ...-06-22-d2b-paperdoll-slice1-equip-slots.md | 2 +- src/AcDream.Core/Items/ClientObjectTable.cs | 14 +++++++---- .../Items/ClientObjectTableTests.cs | 25 ++++++++++++++++--- 3 files changed, 31 insertions(+), 10 deletions(-) diff --git a/docs/superpowers/plans/2026-06-22-d2b-paperdoll-slice1-equip-slots.md b/docs/superpowers/plans/2026-06-22-d2b-paperdoll-slice1-equip-slots.md index b4f7d92e..1a4d769e 100644 --- a/docs/superpowers/plans/2026-06-22-d2b-paperdoll-slice1-equip-slots.md +++ b/docs/superpowers/plans/2026-06-22-d2b-paperdoll-slice1-equip-slots.md @@ -566,7 +566,7 @@ public class PaperdollControllerTests 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)!.WielderId); + 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 } diff --git a/src/AcDream.Core/Items/ClientObjectTable.cs b/src/AcDream.Core/Items/ClientObjectTable.cs index 2a310ea4..48893182 100644 --- a/src/AcDream.Core/Items/ClientObjectTable.cs +++ b/src/AcDream.Core/Items/ClientObjectTable.cs @@ -119,7 +119,9 @@ public sealed class ClientObjectTable /// Handle a server-driven move — called from /// InventoryPutObjInContainer (0x0022) and WieldObject (0x0023) /// handlers. Updates ContainerId / ContainerSlot / CurrentlyEquippedLocation - /// and fires ObjectMoved. + /// and fires ObjectMoved. Does NOT touch WielderId — neither the wield nor the + /// unwield path manages it (a wielded item is modeled as contained-by-the-wielder), + /// so RollbackMove restores full pre-move state through this method alone. /// public bool MoveItem(uint itemId, uint newContainerId, int newSlot = -1, EquipMask newEquipLocation = EquipMask.None) @@ -191,14 +193,16 @@ public sealed class ClientObjectTable } /// 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. + /// ContainerId = wielderGuid and CurrentlyEquippedLocation = equipMask — EXACTLY what the server's + /// WieldObject 0x0023 confirm does via (acdream models a wielded item as + /// contained-by-the-wielder). Neither path writes WielderId, so the optimistic state equals the + /// confirmed state and via MoveItem fully restores pre-move state. Fires + /// ObjectMoved for an immediate repaint. The caller sends GetAndWieldItem; ConfirmMove (on the 0x0023 + /// echo) / RollbackMove (on 0x00A0) reconcile. 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); } diff --git a/tests/AcDream.Core.Tests/Items/ClientObjectTableTests.cs b/tests/AcDream.Core.Tests/Items/ClientObjectTableTests.cs index 758e641a..4c8f524e 100644 --- a/tests/AcDream.Core.Tests/Items/ClientObjectTableTests.cs +++ b/tests/AcDream.Core.Tests/Items/ClientObjectTableTests.cs @@ -455,17 +455,17 @@ public sealed class ClientObjectTableTests } [Fact] - public void WieldItemOptimistic_equipsInstantly_andSetsWielderAndContainer() + public void WieldItemOptimistic_equipsInstantly_setsContainerAndEquip() { 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.MoveItem(0x940u, pack, newSlot: 2); // a pack item (WielderId stays 0) 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); + Assert.Equal(player, o.ContainerId); // contained-by-wielder (matches the WieldObject 0x0023 confirm) + Assert.Equal(0u, o.WielderId); // optimistic wield does NOT write WielderId (ContainerId-based model) } [Fact] @@ -499,4 +499,21 @@ public sealed class ClientObjectTableTests [Fact] public void WieldItemOptimistic_unknownItem_false() => Assert.False(new ClientObjectTable().WieldItemOptimistic(0xDEADu, 0x1u, EquipMask.HeadWear)); + + [Fact] + public void WieldThenMove_oneConfirm_rollsBackToPreWield() // outstanding-count across wield+move (shared RecordPending) + { + var table = new ClientObjectTable(); + const uint player = 0x50000001u, pack = 0x40000005u, pack2 = 0x40000006u; + table.AddOrUpdate(new ClientObject { ObjectId = 0x950u }); + table.MoveItem(0x950u, pack, newSlot: 2); // pre-wield: pack slot 2 + table.WieldItemOptimistic(0x950u, player, EquipMask.HeadWear); // outstanding 1, snapshot (pack, 2, None) + table.MoveItemOptimistic(0x950u, pack2, 0); // outstanding 2 (same snapshot) + table.ConfirmMove(0x950u); // confirm the first → outstanding 1, still pending + Assert.True(table.RollbackMove(0x950u)); // the second rejected → roll back + var o = table.Get(0x950u)!; + Assert.Equal(pack, o.ContainerId); // to the ORIGINAL pre-wield container + Assert.Equal(2, o.ContainerSlot); // and slot + Assert.Equal(EquipMask.None, o.CurrentlyEquippedLocation); // pre-wield was un-equipped + } } From c5c636674d761b26daec2c198c44067f83ceab5e Mon Sep 17 00:00:00 2001 From: Erik Date: Mon, 22 Jun 2026 22:18:10 +0200 Subject: [PATCH 125/133] =?UTF-8?q?docs(D.2b):=20IsCarriedBy=20=E2=80=94?= =?UTF-8?q?=20note=20WielderId=20is=20wire-only;=20optimistic=20wields=20d?= =?UTF-8?q?etected=20via=20ContainerId?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Code-review minor: prevents a future WielderId-only 'is this wielded?' check from silently missing an optimistically-wielded item (WielderId==0 until the server confirm; ContainerId==wielder is the live signal). Co-Authored-By: Claude Opus 4.8 (1M context) --- src/AcDream.Core/Items/ClientObjectTable.cs | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/AcDream.Core/Items/ClientObjectTable.cs b/src/AcDream.Core/Items/ClientObjectTable.cs index 48893182..a80b0fb2 100644 --- a/src/AcDream.Core/Items/ClientObjectTable.cs +++ b/src/AcDream.Core/Items/ClientObjectTable.cs @@ -482,6 +482,10 @@ public sealed class ClientObjectTable return total; } + // NOTE: WielderId is populated from wire data only (CreateObject → Ingest). An + // optimistically-wielded item (WieldItemOptimistic, before the server confirm) has + // WielderId == 0 and is detected via the ContainerId walk below (ContainerId == wielder). + // A future "is this wielded by me?" check must therefore use ContainerId, NOT WielderId alone. private bool IsCarriedBy(ClientObject o, uint ownerGuid) { if (o.WielderId == ownerGuid) return true; From f8799489c2856c50488f334a20c97b36cba3f304 Mon Sep 17 00:00:00 2001 From: Erik Date: Mon, 22 Jun 2026 22:19:39 +0200 Subject: [PATCH 126/133] fix(D.2b): ConfirmMove on the WieldObject 0x0023 echo (clears optimistic wield) Mirrors the InventoryPutObjInContainer 0x0022 handler which already does MoveItem + ConfirmMove. Without this, WieldItemOptimistic's pending snapshot would linger until the session ended (or incorrectly roll back on 0x00A0). ConfirmMove is a no-op when nothing is pending, so safe for server-initiated login wields. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/AcDream.Core.Net/GameEventWiring.cs | 4 +++- .../GameEventWiringTests.cs | 19 +++++++++++++++++++ 2 files changed, 22 insertions(+), 1 deletion(-) diff --git a/src/AcDream.Core.Net/GameEventWiring.cs b/src/AcDream.Core.Net/GameEventWiring.cs index 8f660948..df487c77 100644 --- a/src/AcDream.Core.Net/GameEventWiring.cs +++ b/src/AcDream.Core.Net/GameEventWiring.cs @@ -238,10 +238,12 @@ public static class GameEventWiring dispatcher.Register(GameEventType.WieldObject, e => { var p = GameEvents.ParseWieldObject(e.Payload.Span); - if (p is not null) items.MoveItem( + 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) }); dispatcher.Register(GameEventType.InventoryPutObjInContainer, e => { diff --git a/tests/AcDream.Core.Net.Tests/GameEventWiringTests.cs b/tests/AcDream.Core.Net.Tests/GameEventWiringTests.cs index ae191438..0bafb2f2 100644 --- a/tests/AcDream.Core.Net.Tests/GameEventWiringTests.cs +++ b/tests/AcDream.Core.Net.Tests/GameEventWiringTests.cs @@ -120,6 +120,25 @@ public sealed class GameEventWiringTests Assert.Equal(0x2000u, item.ContainerId); } + [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 + } + [Fact] public void WireAll_PopupString_RoutesToChatLog() { From 9f187c3e31bac8e5213e25a72b65fa41a72bc1bb Mon Sep 17 00:00:00 2001 From: Erik Date: Mon, 22 Jun 2026 22:24:52 +0200 Subject: [PATCH 127/133] =?UTF-8?q?feat(D.2b):=20PaperdollController=20?= =?UTF-8?q?=E2=80=94=20equip=20slots=20bind=20+=20wield=20drag=20handler?= =?UTF-8?q?=20(Slice=201)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 (1M context) --- .../retail-divergence-register.md | 3 + .../UI/Layout/PaperdollController.cs | 159 ++++++++++++++++++ .../UI/Layout/PaperdollControllerTests.cs | 121 +++++++++++++ 3 files changed, 283 insertions(+) create mode 100644 src/AcDream.App/UI/Layout/PaperdollController.cs create mode 100644 tests/AcDream.App.Tests/UI/Layout/PaperdollControllerTests.cs diff --git a/docs/architecture/retail-divergence-register.md b/docs/architecture/retail-divergence-register.md index 2f9e8dba..34aa1c6c 100644 --- a/docs/architecture/retail-divergence-register.md +++ b/docs/architecture/retail-divergence-register.md @@ -160,6 +160,9 @@ accepted-divergence entries (#96, #49, #50). | AP-59 | The per-cell container **capacity bar** (retail `UIElement_UIItem::UpdateCapacityDisplay 0x004e16e0`, element `0x10000347`) is drawn as a **procedural `UiItemSlot` overlay** (track `0x06004D22` full + fill `0x06004D23` clipped bottom-up to `GetContents(guid).Count / ItemsCapacity`), not via a real dat `UIElement_Meter` child. **Right-anchored flush** (the dat rect X=26 in a 36px cell sat ~5px off the edge — flush per the visual gate) and **bottom-up fill assumed** (the dat `m_eDirection` 0x6f isn't read, cf. AP-50). A CLOSED side bag reads empty until opened (its contents aren't indexed until `ViewContents`) — faithful to retail's known-children count, divergent if retail pre-loads. | `src/AcDream.App/UI/UiItemSlot.cs` (`CapacityFill` draw); `src/AcDream.App/UI/Layout/InventoryController.cs` (`SetCapacityBar`) | UiItemSlot is a behavioral leaf that paints overlays procedurally (cf. AP-57); the meter sprites + fill formula are the faithful port. Right-anchor + bottom-up were visual-gate calls. Further visual polish is deferred — ISSUES #146. | Fill direction / exact bar rect could differ from retail's `m_eDirection` + dat X; closed-bag bars read empty if retail pre-loads container counts. | `UIElement_UIItem::UpdateCapacityDisplay` 0x004e16e0; element `0x10000347` (back `0x06004D22` / front `0x06004D23`); `GetNumContainedItems` | | AP-60 | Inventory drag **`OnDragLift` is a no-op** + the source cell is **not dimmed**: retail dims the lifted item's source cell (`RecvNotice_ItemListBeginDrag`); acdream leaves the item in place during the drag + the floating ghost. | `src/AcDream.App/UI/Layout/InventoryController.cs` | Cosmetic only — the dragged item is still unambiguously identifiable; dimming the source requires tracking the source cell reference across drag events, deferred to a polish pass. | A drag in progress doesn't visually mark its origin — cosmetic only, no functional effect. | `RecvNotice_ItemListBeginDrag` acclient_2013_pseudo_c.txt | | 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. | `src/AcDream.App/UI/Layout/InventoryController.cs` (`IsContainerFull`) | A drop into a closed-but-full bag shows green then snaps back (a flicker) instead of pre-showing red. Faithful only when the bag has been opened (then `GetContents` is populated). The server's `0x00A0` + optimistic rollback is the authoritative safety net. | A drop into a closed-but-full bag shows green → brief flicker → snap-back instead of retail's pre-emptive red circle. | `UIElement_ItemList::InqDropIconInfo 0x004e26f0` | +| AP-62 | **MissileAmmo slot mask LIKELY** — `0x100001E0 → MissileAmmo 0x800000` is inferred; the decomp immediate at `GetLocationInfoFromElementID` 173676 is corrupted to a string pointer, so the mapping was not directly confirmed from the named-retail source. | `src/AcDream.App/UI/Layout/PaperdollController.cs` (`SlotMap`) | Dropping ammo onto that slot wields to the wrong location if the mapping is wrong. Gate-verify via dat dump + cdb. | Ammo wields to the wrong equip location — functional gap if wrong. | `GetLocationInfoFromElementID` decomp 173676; `acclient.h:3193` INVENTORY_LOC | +| AP-63 | **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. | `src/AcDream.App/UI/Layout/PaperdollController.cs` (`HandleDropRelease`) | A dual-wielder cannot off-hand a melee weapon via the doll. | Dual-wield via drag-onto-shield-slot is blocked — functional gap for dual-wield characters. | `gmPaperDollUI::OnItemListDragOver` decomp 174302 | +| AP-64 | **Wield-reject rollback assumes `InventoryServerSaveFailed 0x00A0`** — an optimistic wield rolls back only if ACE emits `0x00A0` for a `GetAndWieldItem` rejection; otherwise corrected by the next authoritative message. Gate-verify via WireMCP. | `src/AcDream.Core.Net/GameEventWiring.cs` (0x00A0 handler) | If ACE uses a different reject opcode for wield, the optimistic state is left dangling until the next full update. | Rejected wield briefly shows the item as equipped in the doll — cosmetic flicker or stuck state if `0x00A0` is not the rejection path. | `InventoryServerSaveFailed 0x00A0`; WireMCP gate-verify | --- diff --git a/src/AcDream.App/UI/Layout/PaperdollController.cs b/src/AcDream.App/UI/Layout/PaperdollController.cs new file mode 100644 index 00000000..fac6dff0 --- /dev/null +++ b/src/AcDream.App/UI/Layout/PaperdollController.cs @@ -0,0 +1,159 @@ +using System; +using System.Collections.Generic; +using AcDream.App.UI; +using AcDream.Core.Items; + +namespace AcDream.App.UI.Layout; + +/// +/// 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). +/// +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 _playerGuid; + private readonly Func _iconIds; + private readonly Action? _sendWield; // (itemGuid, equipMask) → GetAndWieldItem 0x001A + private readonly List<(EquipMask Mask, UiItemList List)> _slots = new(); + + private PaperdollController( + ImportedLayout layout, ClientObjectTable objects, Func playerGuid, + Func iconIds, Action? 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 playerGuid, + Func iconIds, Action? 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(); } + + /// The object is (or just became / ceased to be) the player's equipped gear. + 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(); + 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 ────────────────────────────────────────────────────────────────────── + /// 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. + public void OnDragLift(UiItemList sourceList, UiItemSlot sourceCell, ItemDragPayload payload) { } + + /// Accept iff the dragged item can be worn here (its ValidLocations intersects the slot mask). + /// Retail: gmPaperDollUI::OnItemListDragOver (decomp 174302). + 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; + } + + /// 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. + 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); + } + + /// Detach event handlers (idempotent). + public void Dispose() + { + _objects.ObjectAdded -= OnObjectChanged; + _objects.ObjectMoved -= OnObjectMoved; + _objects.ObjectRemoved -= OnObjectChanged; + _objects.ObjectUpdated -= OnObjectChanged; + } +} diff --git a/tests/AcDream.App.Tests/UI/Layout/PaperdollControllerTests.cs b/tests/AcDream.App.Tests/UI/Layout/PaperdollControllerTests.cs new file mode 100644 index 00000000..526176bb --- /dev/null +++ b/tests/AcDream.App.Tests/UI/Layout/PaperdollControllerTests.cs @@ -0,0 +1,121 @@ +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 lists) BuildLayout() + { + var ids = new[] { HeadSlot, ShieldSlot, WeaponSlot, FingerLSlot }; + var lists = new Dictionary(); + var byId = new Dictionary(); + 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); + } +} From 3b8a39c49ecfd476c9015d3a67e1d7e28bf21866 Mon Sep 17 00:00:00 2001 From: Erik Date: Mon, 22 Jun 2026 22:32:34 +0200 Subject: [PATCH 128/133] =?UTF-8?q?fix(D.2b):=20PaperdollController=20revi?= =?UTF-8?q?ew=20fixes=20=E2=80=94=20player-scope=20Concerns,=20loop=20clar?= =?UTF-8?q?ity?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Code-quality review on Task 5: - I1: Concerns was unscoped (CurrentlyEquippedLocation != None) → an NPC's wielded item (which also carries that wire field) triggered spurious full repaints. Narrowed to (WielderId==p || ContainerId==p), matching InventoryController; OnObjectMoved's from/to-player backstop still catches unwield-into-a-side-bag. Populate's own scope already prevented wrong data; this kills the wasted repaints. - I2: replaced the dual-`index++` (assign-vs-skip) with a for-i loop; SlotIndex = SlotMap position (= the drag payload's SourceSlot on unwield). - M1/M2: comment that the cell's SpriteResolve + the discrete-slot accept/ reject ring (0x060011F9/F8, not the grid insert-arrow) are factory-provided. - Added two behavioral tests: a live player wield repaints the slot (ObjectMoved → Concerns → Populate); an NPC's wielded item never appears on the doll (player-scoping). - Synced the stale spec §4b/§6c/§8 to the Task-3 Option-1 reality (the optimistic wield is ContainerId-based and does NOT write WielderId). App suite 580 passed / 0 failed. Co-Authored-By: Claude Opus 4.8 (1M context) --- ...d2b-paperdoll-slice1-equip-slots-design.md | 20 ++++++++------ .../UI/Layout/PaperdollController.cs | 23 +++++++++++----- .../UI/Layout/PaperdollControllerTests.cs | 26 +++++++++++++++++++ 3 files changed, 54 insertions(+), 15 deletions(-) diff --git a/docs/superpowers/specs/2026-06-22-d2b-paperdoll-slice1-equip-slots-design.md b/docs/superpowers/specs/2026-06-22-d2b-paperdoll-slice1-equip-slots-design.md index 01761ea6..212dae14 100644 --- a/docs/superpowers/specs/2026-06-22-d2b-paperdoll-slice1-equip-slots-design.md +++ b/docs/superpowers/specs/2026-06-22-d2b-paperdoll-slice1-equip-slots-design.md @@ -155,20 +155,22 @@ This makes rollback faithful in **both** directions (today an unwield-reject los ```csharp /// Optimistic (instant) wield: snapshot the pre-wield (container, slot, equip), then set -/// ContainerId = WielderId = wielderGuid and CurrentlyEquippedLocation = equipMask (matching the +/// 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. 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); } ``` `MoveItem` already sets `ContainerId` + `CurrentlyEquippedLocation` + reindexes + fires `ObjectMoved`. -Setting `WielderId` here matches the steady state (the item's own CreateObject carries `Wielder`). +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.) --- @@ -239,9 +241,10 @@ jewelry — a single equip bit intersects exactly one slot mask). If found, `cel iconIds(item.Type, item.IconId, item.IconUnderlayId, item.IconOverlayId, item.Effects))`; else `cell.Clear()` (transparent — nothing drawn). -`Concerns(o)`: repaint when `o.CurrentlyEquippedLocation != None || o.WielderId == player || o.ContainerId == player`; -`OnObjectMoved` also repaints when from/to is the player (an item being wielded/unwielded). Mirror -`InventoryController`'s debounce. +`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` @@ -295,8 +298,9 @@ _paperdollController = AcDream.App.UI.Layout.PaperdollController.Bind( 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 + WielderId + container; `RollbackMove` - restores the pre-wield equip mask (the new snapshot field); outstanding-count across wield+move of the same item. +- **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 diff --git a/src/AcDream.App/UI/Layout/PaperdollController.cs b/src/AcDream.App/UI/Layout/PaperdollController.cs index fac6dff0..c5ba54c7 100644 --- a/src/AcDream.App/UI/Layout/PaperdollController.cs +++ b/src/AcDream.App/UI/Layout/PaperdollController.cs @@ -57,14 +57,18 @@ public sealed class PaperdollController : IItemListDragHandler { _objects = objects; _playerGuid = playerGuid; _iconIds = iconIds; _sendWield = sendWield; - int index = 0; - foreach (var (element, mask) in SlotMap) + for (int i = 0; i < SlotMap.Length; i++) { - if (layout.FindElement(element) is not UiItemList list) { index++; continue; } + var (element, mask) = SlotMap[i]; + if (layout.FindElement(element) is not UiItemList list) 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) + list.Cell.SlotIndex = i; // SlotMap position = this equipped item's drag-payload SourceSlot when dragged out to unwield + list.Cell.EmptySprite = 0u; // TRANSPARENT empty slot (brainstorm decision; no doll behind it in Slice 1) + // Cell.SpriteResolve + the default accept/reject sprites (ItemSlot_DragOver_Accept ring + // 0x060011F9 / reject circle 0x060011F8 — the discrete-slot frames, NOT the inventory grid's + // insert-arrow 0x060011F7) are already wired by DatWidgetFactory when it built the UiItemList; + // no need to re-set them here. _slots.Add((mask, list)); } @@ -85,11 +89,16 @@ public sealed class PaperdollController : IItemListDragHandler private void OnObjectMoved(ClientObject o, uint from, uint to) { if (Concerns(o) || from == _playerGuid() || to == _playerGuid()) Populate(); } - /// The object is (or just became / ceased to be) the player's equipped gear. + /// The object belongs to the player (wielded gear or pack contents) — so a change to it may + /// add/remove/repaint a doll slot. Player-scoped: an NPC's or vendor's wielded item (which also carries + /// CurrentlyEquippedLocation from the wire) must NOT trigger a repaint. A player-equipped item always + /// has WielderId==p (login, from CreateObject) or ContainerId==p (live/optimistic wield, set by + /// WieldItemOptimistic), so the equip-location need not be tested here; OnObjectMoved adds the from/to + /// player backstop for moves that transiently satisfy neither (e.g. unwield into a side bag). private bool Concerns(ClientObject o) { uint p = _playerGuid(); - return o.CurrentlyEquippedLocation != EquipMask.None || o.WielderId == p || o.ContainerId == p; + return o.WielderId == p || o.ContainerId == p; } public void Populate() diff --git a/tests/AcDream.App.Tests/UI/Layout/PaperdollControllerTests.cs b/tests/AcDream.App.Tests/UI/Layout/PaperdollControllerTests.cs index 526176bb..584eb7b2 100644 --- a/tests/AcDream.App.Tests/UI/Layout/PaperdollControllerTests.cs +++ b/tests/AcDream.App.Tests/UI/Layout/PaperdollControllerTests.cs @@ -118,4 +118,30 @@ public class PaperdollControllerTests Bind(layout, new ClientObjectTable()); Assert.Equal(0u, lists[HeadSlot].Cell.EmptySprite); } + + [Fact] + public void Live_player_wield_repaints_the_slot() // event-driven: ObjectMoved → Concerns → Populate + { + var (layout, lists) = BuildLayout(); + var objects = new ClientObjectTable(); + Bind(layout, objects); // slots empty at bind + SeedPackItem(objects, 0xF01u, EquipMask.HeadWear); // a helm appears in the pack (not on the doll) + Assert.Equal(0u, lists[HeadSlot].Cell.ItemId); + objects.WieldItemOptimistic(0xF01u, Player, EquipMask.HeadWear); // player wields it → ObjectMoved(to=player) + Assert.Equal(0xF01u, lists[HeadSlot].Cell.ItemId); // the head slot repainted with the helm + } + + [Fact] + public void Live_npc_equip_does_not_appear_on_the_doll() // player-scoped: a remote creature's wielded item never leaks + { + var (layout, lists) = BuildLayout(); + var objects = new ClientObjectTable(); + Bind(layout, objects); + const uint npc = 0x60000001u; + objects.AddOrUpdate(new ClientObject + { + ObjectId = 0xF02u, WielderId = npc, CurrentlyEquippedLocation = EquipMask.HeadWear, + }); + Assert.Equal(0u, lists[HeadSlot].Cell.ItemId); // the NPC's helm is NOT on the player's doll + } } From 058da60212f492198f7b467a0a5899dcdc40c016 Mon Sep 17 00:00:00 2001 From: Erik Date: Mon, 22 Jun 2026 22:35:51 +0200 Subject: [PATCH 129/133] feat(D.2b): wire PaperdollController into the inventory frame (Slice 1) Adds the _paperdollController field and PaperdollController.Bind(...) call in the inventory-frame block alongside InventoryController, wiring up the paperdoll equip slots with their icon composer and wield action. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/AcDream.App/Rendering/GameWindow.cs | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs index 350261e8..386a68cc 100644 --- a/src/AcDream.App/Rendering/GameWindow.cs +++ b/src/AcDream.App/Rendering/GameWindow.cs @@ -623,6 +623,8 @@ public sealed class GameWindow : IDisposable private AcDream.App.UI.Layout.SelectedObjectController? _selectedObjectController; // Phase D.2b-B — inventory controller (backpack grid + pack-selector + burden meter). private AcDream.App.UI.Layout.InventoryController? _inventoryController; + // Phase D.2b Sub-phase C — paperdoll equip-slot controller (wield drag handler). + private AcDream.App.UI.Layout.PaperdollController? _paperdollController; // Phase D.2b Task 9 — plugin UI registrations buffered before OnLoad; drained in OnLoad. private readonly AcDream.App.Plugins.BufferedUiRegistry? _uiRegistry; // Phase I.2: ImGui debug panel ViewModel. Lives for as long as @@ -2244,6 +2246,14 @@ public sealed class GameWindow : IDisposable sendPutItemInContainer: (item, container, placement) => _liveSession?.SendPutItemInContainer(item, container, placement)); + // 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)); + Console.WriteLine("[D.2b-B] retail inventory window from LayoutDesc importer (0x21000023)."); } else Console.WriteLine("[D.2b-B] inventory: LayoutDesc 0x21000023 not found."); From db0cac03c4bf5a70fd6f6bbdb4b391fd75388fb4 Mon Sep 17 00:00:00 2001 From: Erik Date: Mon, 22 Jun 2026 23:39:27 +0200 Subject: [PATCH 130/133] =?UTF-8?q?fix(D.2b):=20PickupEvent=20removes=20th?= =?UTF-8?q?e=203D=20render,=20not=20the=20weenie=20=E2=80=94=20unwield=20l?= =?UTF-8?q?ands=20in=20the=20pack?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PickupEvent (0xF74A) and DeleteObject (0xF747) are semantically distinct: - 0xF747 = weenie DESTROYED → evict from ClientObjectTable (weenie_object_table) - 0xF74A = object LEFT THE 3D WORLD VIEW (moved into a container) → remove the 3D WorldEntity, but the weenie persists in ClientObjectTable Before this fix, both paths fired EntityDeleted identically, causing ObjectTableWiring to evict the weenie from ClientObjectTable. The follow-up InventoryPutObjInContainer (0x0022) then tried MoveItem on an unknown guid and no-op'd, so the unwielded item simply vanished. Fix: add `bool FromPickup` (default false) to DeleteObject.Parsed. WorldSession sets it true on the PickupEvent path and false on the DeleteObject path. ObjectTableWiring.Wire's EntityDeleted handler skips table.Remove when FromPickup is true, preserving the weenie for the container-move echo. GameWindow.OnLiveEntityDeleted (3D entity removal) is untouched — it fires for both pickups and destroys, as intended. Divergence register: AP-65 added (data ghosts for other-player pickups until teleport/relog clear; harmless — no UI queries ContainerId 0). Tests: +5 (DeleteObject FromPickup parser regression; wiring retain/evict semantics; Parsed default/explicit FromPickup). 343/343 pass. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../retail-divergence-register.md | 1 + src/AcDream.Core.Net/Messages/DeleteObject.cs | 16 +++- src/AcDream.Core.Net/ObjectTableWiring.cs | 8 +- src/AcDream.Core.Net/WorldSession.cs | 14 +-- .../Messages/DeleteObjectTests.cs | 38 ++++++++ .../ObjectTableWiringTests.cs | 87 ++++++++++++++++++- 6 files changed, 151 insertions(+), 13 deletions(-) diff --git a/docs/architecture/retail-divergence-register.md b/docs/architecture/retail-divergence-register.md index 34aa1c6c..69b393f1 100644 --- a/docs/architecture/retail-divergence-register.md +++ b/docs/architecture/retail-divergence-register.md @@ -163,6 +163,7 @@ accepted-divergence entries (#96, #49, #50). | AP-62 | **MissileAmmo slot mask LIKELY** — `0x100001E0 → MissileAmmo 0x800000` is inferred; the decomp immediate at `GetLocationInfoFromElementID` 173676 is corrupted to a string pointer, so the mapping was not directly confirmed from the named-retail source. | `src/AcDream.App/UI/Layout/PaperdollController.cs` (`SlotMap`) | Dropping ammo onto that slot wields to the wrong location if the mapping is wrong. Gate-verify via dat dump + cdb. | Ammo wields to the wrong equip location — functional gap if wrong. | `GetLocationInfoFromElementID` decomp 173676; `acclient.h:3193` INVENTORY_LOC | | AP-63 | **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. | `src/AcDream.App/UI/Layout/PaperdollController.cs` (`HandleDropRelease`) | A dual-wielder cannot off-hand a melee weapon via the doll. | Dual-wield via drag-onto-shield-slot is blocked — functional gap for dual-wield characters. | `gmPaperDollUI::OnItemListDragOver` decomp 174302 | | AP-64 | **Wield-reject rollback assumes `InventoryServerSaveFailed 0x00A0`** — an optimistic wield rolls back only if ACE emits `0x00A0` for a `GetAndWieldItem` rejection; otherwise corrected by the next authoritative message. Gate-verify via WireMCP. | `src/AcDream.Core.Net/GameEventWiring.cs` (0x00A0 handler) | If ACE uses a different reject opcode for wield, the optimistic state is left dangling until the next full update. | Rejected wield briefly shows the item as equipped in the doll — cosmetic flicker or stuck state if `0x00A0` is not the rejection path. | `InventoryServerSaveFailed 0x00A0`; WireMCP gate-verify | +| AP-65 | **PickupEvent (0xF74A) no longer evicts the weenie from `ClientObjectTable`** — only `DeleteObject (0xF747)` evicts, matching the retail `object_table`-vs-`weenie_object_table` split. An item another player picks up near you (only ever gets `PickupEvent`, never `DeleteObject`) lingers as a data-only entry (`ContainerId 0`, not in any view) until teleport/relog `Clear()`. | `src/AcDream.Core.Net/ObjectTableWiring.cs` (EntityDeleted handler); `src/AcDream.Core.Net/WorldSession.cs` (PickupEvent branch) | The weenie entry for nearby pickups is a harmless data ghost — no UI shows it (no container, not wielded). Retail evicts it from `weenie_object_table` when the object fully leaves interest range via `DeleteObject`; acdream defers to teleport/relog clear. | Slight memory growth in long sessions if many world items are picked up by other players near you; item data ghosts cannot cause functional issues because no UI queries `ContainerId 0`. | `CACObjectMaint::DeleteObject` / `SmartBox::HandleDeleteObject`; ACE `Player_Inventory.cs TryDequipObjectWithNetworking` | --- diff --git a/src/AcDream.Core.Net/Messages/DeleteObject.cs b/src/AcDream.Core.Net/Messages/DeleteObject.cs index c18bb139..fc88f28f 100644 --- a/src/AcDream.Core.Net/Messages/DeleteObject.cs +++ b/src/AcDream.Core.Net/Messages/DeleteObject.cs @@ -17,11 +17,23 @@ public static class DeleteObject { public const uint Opcode = 0xF747u; - public readonly record struct Parsed(uint Guid, ushort InstanceSequence); + /// + /// when this delete was sourced from a + /// PickupEvent (0xF74A) — the object left the 3-D world view but + /// the weenie record still exists (it is moving into a container). + /// (default) when sourced from DeleteObject + /// (0xF747) — the weenie was destroyed and must be evicted from + /// . + /// Retail two-table model: PickupEvent removes from object_table; + /// DeleteObject removes from weenie_object_table. + /// + public readonly record struct Parsed(uint Guid, ushort InstanceSequence, bool FromPickup = false); /// /// Parse a 0xF747 body. must start with the /// 4-byte opcode, matching every other parser in this namespace. + /// The returned is always + /// — this parser handles true destroys only. /// public static Parsed? TryParse(ReadOnlySpan body) { @@ -34,6 +46,6 @@ public static class DeleteObject uint guid = BinaryPrimitives.ReadUInt32LittleEndian(body.Slice(4, 4)); ushort instanceSequence = BinaryPrimitives.ReadUInt16LittleEndian(body.Slice(8, 2)); - return new Parsed(guid, instanceSequence); + return new Parsed(guid, instanceSequence, FromPickup: false); } } diff --git a/src/AcDream.Core.Net/ObjectTableWiring.cs b/src/AcDream.Core.Net/ObjectTableWiring.cs index ca21c79a..e47f8aaa 100644 --- a/src/AcDream.Core.Net/ObjectTableWiring.cs +++ b/src/AcDream.Core.Net/ObjectTableWiring.cs @@ -24,7 +24,13 @@ public static class ObjectTableWiring ArgumentNullException.ThrowIfNull(table); session.EntitySpawned += s => table.Ingest(ToWeenieData(s)); - session.EntityDeleted += d => table.Remove(d.Guid); + // Retail two-table model: + // PickupEvent (0xF74A) → object left the 3-D world view; the weenie persists + // (it is moving into a container, e.g. unwield-to-pack). Remove the 3-D + // render entity only — do NOT evict from ClientObjectTable. + // DeleteObject (0xF747) → weenie is DESTROYED; evict from both tables. + // FromPickup = true guards the weenie eviction so it only fires for true destroys. + session.EntityDeleted += d => { if (!d.FromPickup) table.Remove(d.Guid); }; // B-Wire: apply EVERY PropertyInt update on a visible object (0x02CE), not just // UiEffects — the server is the authority on object properties. UpdateIntProperty diff --git a/src/AcDream.Core.Net/WorldSession.cs b/src/AcDream.Core.Net/WorldSession.cs index 60e4aa6f..bfde2a28 100644 --- a/src/AcDream.Core.Net/WorldSession.cs +++ b/src/AcDream.Core.Net/WorldSession.cs @@ -807,16 +807,18 @@ public sealed class WorldSession : IDisposable } else if (op == PickupEvent.Opcode) { - // ACE sends PickupEvent (0xF74A) instead of DeleteObject - // when a player picks up a world item (Player_Tracking - // .RemoveTrackedObject with fromPickup=true). Downstream - // view-removal semantics are identical, so we adapt to - // DeleteObject.Parsed and reuse the existing handler. + // ACE sends PickupEvent (0xF74A) when an object leaves the 3-D world view + // (player picks it up, or a player unwields gear that goes back to their pack). + // The WEENIE is NOT destroyed — it is moving into a container. We adapt to + // DeleteObject.Parsed so the render/entity layer (GameWindow.OnLiveEntityDeleted) + // still removes the 3-D WorldEntity, but FromPickup = true tells ObjectTableWiring + // to skip the ClientObjectTable eviction (retail two-table model: PickupEvent + // targets object_table only; DeleteObject 0xF747 targets weenie_object_table). var parsed = PickupEvent.TryParse(body); if (parsed is not null) EntityDeleted?.Invoke( new DeleteObject.Parsed( - parsed.Value.Guid, parsed.Value.InstanceSequence)); + parsed.Value.Guid, parsed.Value.InstanceSequence, FromPickup: true)); } else if (op == UpdateMotion.Opcode) { diff --git a/tests/AcDream.Core.Net.Tests/Messages/DeleteObjectTests.cs b/tests/AcDream.Core.Net.Tests/Messages/DeleteObjectTests.cs index b464cab1..b1140d94 100644 --- a/tests/AcDream.Core.Net.Tests/Messages/DeleteObjectTests.cs +++ b/tests/AcDream.Core.Net.Tests/Messages/DeleteObjectTests.cs @@ -36,4 +36,42 @@ public sealed class DeleteObjectTests Assert.Equal(0x80000439u, parsed!.Value.Guid); Assert.Equal((ushort)0x1234, parsed.Value.InstanceSequence); } + + /// + /// Regression guard: TryParse (0xF747 = true destroy) must always produce + /// FromPickup = false. PickupEvent (0xF74A) is a different opcode and is + /// the ONLY path that sets FromPickup = true (in WorldSession). + /// + [Fact] + public void TryParse_AlwaysReturnsFromPickupFalse() + { + Span body = stackalloc byte[12]; + BinaryPrimitives.WriteUInt32LittleEndian(body, DeleteObject.Opcode); + BinaryPrimitives.WriteUInt32LittleEndian(body.Slice(4), 0x80000001u); + BinaryPrimitives.WriteUInt16LittleEndian(body.Slice(8), 0x0001); + + var parsed = DeleteObject.TryParse(body); + + Assert.NotNull(parsed); + Assert.False(parsed!.Value.FromPickup, + "DeleteObject 0xF747 is a true destroy — FromPickup must be false."); + } + + /// + /// FromPickup = true can be constructed via the record directly (as + /// WorldSession does for PickupEvent 0xF74A). Default is false. + /// + [Fact] + public void Parsed_DefaultFromPickupIsFalse() + { + var p = new DeleteObject.Parsed(0x80000001u, 1); + Assert.False(p.FromPickup); + } + + [Fact] + public void Parsed_FromPickupTrueCanBeSet() + { + var p = new DeleteObject.Parsed(0x80000001u, 1, FromPickup: true); + Assert.True(p.FromPickup); + } } diff --git a/tests/AcDream.Core.Net.Tests/ObjectTableWiringTests.cs b/tests/AcDream.Core.Net.Tests/ObjectTableWiringTests.cs index 9c3d099a..855f77c9 100644 --- a/tests/AcDream.Core.Net.Tests/ObjectTableWiringTests.cs +++ b/tests/AcDream.Core.Net.Tests/ObjectTableWiringTests.cs @@ -7,10 +7,13 @@ namespace AcDream.Core.Net.Tests; /// /// D.5.4 Task 7 — ObjectTableWiring. /// -/// Integration test is omitted: WorldSession.EntitySpawned has no internal -/// test seam to fire it without a real Tick + packet bytes, so subscription -/// correctness is covered by build (type-checks) + the live run. Only the -/// pure mapping (ToWeenieData) is unit-tested here. +/// WorldSession.EntitySpawned/EntityDeleted have no in-process test seam (the +/// ctor opens a UDP socket), so the subscription wiring is tested via the guard +/// logic extracted as a local handler — this is the same lambda body that +/// ObjectTableWiring.Wire wires to session.EntityDeleted. The retail two-table +/// semantics are: PickupEvent (0xF74A) removes from the 3-D object_table but +/// KEEPS the weenie in ClientObjectTable; DeleteObject (0xF747) evicts the +/// weenie from both. FromPickup = true guards the eviction. /// public sealed class ObjectTableWiringTests { @@ -94,4 +97,80 @@ public sealed class ObjectTableWiringTests Assert.Equal(100, d.MaxStructure); Assert.Equal(4.5f, d.Workmanship); } + + // ------------------------------------------------------------------------- + // The handler that ObjectTableWiring.Wire subscribes to session.EntityDeleted. + // Testing it directly avoids needing a live WorldSession (UDP socket). + // ------------------------------------------------------------------------- + private static Action MakeEntityDeletedHandler(ClientObjectTable table) + => d => { if (!d.FromPickup) table.Remove(d.Guid); }; + + private static WeenieData MinimalWeenie(uint guid) => new( + Guid: guid, + Name: "Test Item", + Type: null, + WeenieClassId: 1u, + IconId: 0x06000001u, + IconOverlayId: 0u, + IconUnderlayId: 0u, + Effects: 0u, + Value: null, + StackSize: null, + StackSizeMax: null, + Burden: null, + ContainerId: null, + WielderId: null, + ValidLocations: null, + CurrentWieldedLocation: null, + Priority: null, + ItemsCapacity: null, + ContainersCapacity: null, + Structure: null, + MaxStructure: null, + Workmanship: null); + + /// + /// PickupEvent path: FromPickup = true → weenie is RETAINED in ClientObjectTable. + /// The 3-D render entity is removed (EntityDeleted still fires), but the weenie + /// data persists so the follow-up InventoryPutObjInContainer can find it. + /// + [Fact] + public void EntityDeleted_FromPickupTrue_RetainsWeenieInTable() + { + var table = new ClientObjectTable(); + var handler = MakeEntityDeletedHandler(table); + + const uint guid = 0x80000100u; + table.Ingest(MinimalWeenie(guid)); + Assert.NotNull(table.Get(guid)); // pre-condition: item is in the table + + // Fire EntityDeleted as WorldSession does for PickupEvent (0xF74A). + // The handler itself represents the EntityDeleted subscription — firing it IS the event. + handler(new DeleteObject.Parsed(guid, 0, FromPickup: true)); + + // Post-condition: weenie still in table (it moved into a container, was NOT destroyed). + // EntityDeleted still fires (the handler ran), so the 3-D render layer would remove the WorldEntity. + Assert.NotNull(table.Get(guid)); + } + + /// + /// DeleteObject path: FromPickup = false → weenie IS evicted from ClientObjectTable. + /// Regression guard — true destroys (0xF747) must still clean up the table. + /// + [Fact] + public void EntityDeleted_FromPickupFalse_EvictsWeenieFromTable() + { + var table = new ClientObjectTable(); + var handler = MakeEntityDeletedHandler(table); + + const uint guid = 0x80000200u; + table.Ingest(MinimalWeenie(guid)); + Assert.NotNull(table.Get(guid)); // pre-condition: item is in the table + + // Fire EntityDeleted as WorldSession does for DeleteObject (0xF747). + handler(new DeleteObject.Parsed(guid, 0, FromPickup: false)); + + // Post-condition: weenie evicted (truly destroyed). + Assert.Null(table.Get(guid)); + } } From a4c68520ea6383757c5f1eaf5711dddacf1fecf3 Mon Sep 17 00:00:00 2001 From: Erik Date: Tue, 23 Jun 2026 00:02:11 +0200 Subject: [PATCH 131/133] fix(D.2b): paperdoll empty slots show a visible frame (not transparent) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit User correction at the visual gate: the green "figure" in the paperdoll is the LIVE 3D character (the doll — can be naked), NOT per-slot silhouettes. The default view (Slots button OFF) = the doll + non-armor slots; pressing Slots hides the doll and shows the armor slots. So the doll + the Slots toggle are Slice 2 (the UiViewport); there are no per-slot silhouette sprites to chase. For Slice 1 (no doll yet) the right empty-slot look is simply a VISIBLE FRAME so every slot position can be seen + used — which fixes the "I see only slots with equipment, no empty slots" report. The earlier transparent (EmptySprite=0) came from the stale silhouette assumption. PaperdollController now takes an emptySlotSprite; GameWindow passes the inventory grid's empty square (0x06004D20) for a consistent visible frame. App suite 580 green. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/AcDream.App/Rendering/GameWindow.cs | 5 ++++- src/AcDream.App/UI/Layout/PaperdollController.cs | 12 ++++++++---- .../UI/Layout/PaperdollControllerTests.cs | 11 ++++++----- 3 files changed, 18 insertions(+), 10 deletions(-) diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs index 386a68cc..a3ab1db0 100644 --- a/src/AcDream.App/Rendering/GameWindow.cs +++ b/src/AcDream.App/Rendering/GameWindow.cs @@ -2252,7 +2252,10 @@ public sealed class GameWindow : IDisposable invLayout, Objects, playerGuid: () => _playerServerGuid, iconIds: (type, icon, under, over, effects) => iconComposer.GetIcon(type, icon, under, over, effects), - sendWield: (item, mask) => _liveSession?.SendGetAndWieldItem(item, mask)); + sendWield: (item, mask) => _liveSession?.SendGetAndWieldItem(item, mask), + // Empty equip slots show a visible frame (same square as the inventory grid) so every + // slot position is seen + usable; the live 3D character (the doll) is Slice 2. + emptySlotSprite: contentsEmpty); Console.WriteLine("[D.2b-B] retail inventory window from LayoutDesc importer (0x21000023)."); } diff --git a/src/AcDream.App/UI/Layout/PaperdollController.cs b/src/AcDream.App/UI/Layout/PaperdollController.cs index c5ba54c7..41763f01 100644 --- a/src/AcDream.App/UI/Layout/PaperdollController.cs +++ b/src/AcDream.App/UI/Layout/PaperdollController.cs @@ -53,7 +53,8 @@ public sealed class PaperdollController : IItemListDragHandler private PaperdollController( ImportedLayout layout, ClientObjectTable objects, Func playerGuid, - Func iconIds, Action? sendWield) + Func iconIds, Action? sendWield, + uint emptySlotSprite) { _objects = objects; _playerGuid = playerGuid; _iconIds = iconIds; _sendWield = sendWield; @@ -64,7 +65,9 @@ public sealed class PaperdollController : IItemListDragHandler list.RegisterDragHandler(this); list.Cell.SourceKind = ItemDragSource.Equipment; list.Cell.SlotIndex = i; // SlotMap position = this equipped item's drag-payload SourceSlot when dragged out to unwield - list.Cell.EmptySprite = 0u; // TRANSPARENT empty slot (brainstorm decision; no doll behind it in Slice 1) + list.Cell.EmptySprite = emptySlotSprite; // visible empty-slot frame so every position is seen + usable. The live + // 3D character (the "figure" — Slice 1/2 correction: NOT a per-slot + // silhouette) is the doll viewport, which arrives in Slice 2 with the Slots toggle. // Cell.SpriteResolve + the default accept/reject sprites (ItemSlot_DragOver_Accept ring // 0x060011F9 / reject circle 0x060011F8 — the discrete-slot frames, NOT the inventory grid's // insert-arrow 0x060011F7) are already wired by DatWidgetFactory when it built the UiItemList; @@ -82,8 +85,9 @@ public sealed class PaperdollController : IItemListDragHandler public static PaperdollController Bind( ImportedLayout layout, ClientObjectTable objects, Func playerGuid, - Func iconIds, Action? sendWield = null) - => new PaperdollController(layout, objects, playerGuid, iconIds, sendWield); + Func iconIds, Action? sendWield = null, + uint emptySlotSprite = 0u) + => new PaperdollController(layout, objects, playerGuid, iconIds, sendWield, emptySlotSprite); private void OnObjectChanged(ClientObject o) { if (Concerns(o)) Populate(); } private void OnObjectMoved(ClientObject o, uint from, uint to) diff --git a/tests/AcDream.App.Tests/UI/Layout/PaperdollControllerTests.cs b/tests/AcDream.App.Tests/UI/Layout/PaperdollControllerTests.cs index 584eb7b2..add55b4f 100644 --- a/tests/AcDream.App.Tests/UI/Layout/PaperdollControllerTests.cs +++ b/tests/AcDream.App.Tests/UI/Layout/PaperdollControllerTests.cs @@ -32,10 +32,11 @@ public class PaperdollControllerTests } private static PaperdollController Bind(ImportedLayout layout, ClientObjectTable objects, - List<(uint item, uint mask)>? wields = null) + List<(uint item, uint mask)>? wields = null, uint emptySlot = 0u) => PaperdollController.Bind(layout, objects, () => Player, iconIds: (_, _, _, _, _) => 0x1234u, - sendWield: wields is null ? null : (i, m) => wields.Add((i, m))); + sendWield: wields is null ? null : (i, m) => wields.Add((i, m)), + emptySlotSprite: emptySlot); private static void SeedEquipped(ClientObjectTable t, uint guid, EquipMask loc) { @@ -112,11 +113,11 @@ public class PaperdollControllerTests } [Fact] - public void Empty_equip_slot_is_transparent() // EmptySprite=0 ⇒ nothing drawn (no doll behind it in Slice 1) + public void Empty_equip_slot_shows_the_configured_frame() // visible frame so all positions are usable (Slice 1; the doll is Slice 2) { var (layout, lists) = BuildLayout(); - Bind(layout, new ClientObjectTable()); - Assert.Equal(0u, lists[HeadSlot].Cell.EmptySprite); + Bind(layout, new ClientObjectTable(), emptySlot: 0x06004D20u); + Assert.Equal(0x06004D20u, lists[HeadSlot].Cell.EmptySprite); } [Fact] From 68971480a778ba5e22ea7c9e55686dbed2dec07c Mon Sep 17 00:00:00 2001 From: Erik Date: Tue, 23 Jun 2026 00:09:52 +0200 Subject: [PATCH 132/133] docs(D.2b): reconcile paperdoll docs to the corrected model (Slice 1 shipped) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Divergence AP-66: Slice-1 empty-slot frame vs the Slice-2 doll-backed transparent look (retired when the doll viewport lands). - Paperdoll handoff: prominent top note correcting the WRONG "transparent / per-slot silhouettes" framing — the figure IS the live 3D doll; the Slots toggle + doll = Slice 2. Points to the project memory's Slice 1 entry for the full DO-NOT-RETRY. (The detailed shipped-log + corrected model live in the auto-loaded claude-memory/project_d2b_retail_ui.md, updated this session.) Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/architecture/retail-divergence-register.md | 1 + docs/research/2026-06-22-paperdoll-handoff.md | 11 +++++++++++ 2 files changed, 12 insertions(+) diff --git a/docs/architecture/retail-divergence-register.md b/docs/architecture/retail-divergence-register.md index 69b393f1..33912121 100644 --- a/docs/architecture/retail-divergence-register.md +++ b/docs/architecture/retail-divergence-register.md @@ -164,6 +164,7 @@ accepted-divergence entries (#96, #49, #50). | AP-63 | **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. | `src/AcDream.App/UI/Layout/PaperdollController.cs` (`HandleDropRelease`) | A dual-wielder cannot off-hand a melee weapon via the doll. | Dual-wield via drag-onto-shield-slot is blocked — functional gap for dual-wield characters. | `gmPaperDollUI::OnItemListDragOver` decomp 174302 | | AP-64 | **Wield-reject rollback assumes `InventoryServerSaveFailed 0x00A0`** — an optimistic wield rolls back only if ACE emits `0x00A0` for a `GetAndWieldItem` rejection; otherwise corrected by the next authoritative message. Gate-verify via WireMCP. | `src/AcDream.Core.Net/GameEventWiring.cs` (0x00A0 handler) | If ACE uses a different reject opcode for wield, the optimistic state is left dangling until the next full update. | Rejected wield briefly shows the item as equipped in the doll — cosmetic flicker or stuck state if `0x00A0` is not the rejection path. | `InventoryServerSaveFailed 0x00A0`; WireMCP gate-verify | | AP-65 | **PickupEvent (0xF74A) no longer evicts the weenie from `ClientObjectTable`** — only `DeleteObject (0xF747)` evicts, matching the retail `object_table`-vs-`weenie_object_table` split. An item another player picks up near you (only ever gets `PickupEvent`, never `DeleteObject`) lingers as a data-only entry (`ContainerId 0`, not in any view) until teleport/relog `Clear()`. | `src/AcDream.Core.Net/ObjectTableWiring.cs` (EntityDeleted handler); `src/AcDream.Core.Net/WorldSession.cs` (PickupEvent branch) | The weenie entry for nearby pickups is a harmless data ghost — no UI shows it (no container, not wielded). Retail evicts it from `weenie_object_table` when the object fully leaves interest range via `DeleteObject`; acdream defers to teleport/relog clear. | Slight memory growth in long sessions if many world items are picked up by other players near you; item data ghosts cannot cause functional issues because no UI queries `ContainerId 0`. | `CACObjectMaint::DeleteObject` / `SmartBox::HandleDeleteObject`; ACE `Player_Inventory.cs TryDequipObjectWithNetworking` | +| AP-66 | **Empty equip slots show a generic frame (Slice 1) instead of the retail doll-backed transparent look** — retail's slot-view leaves empty armor slots transparent with the live 3-D doll body behind them; Slice 1 has no doll yet, so empty slots render a visible frame (`0x06004D20`, the inventory grid's empty square) so every position is seen + usable. The "figure" in the paperdoll is the doll, NOT a per-slot silhouette. | `src/AcDream.App/UI/Layout/PaperdollController.cs` (`emptySlotSprite`); `src/AcDream.App/Rendering/GameWindow.cs` (PaperdollController.Bind) | Retired in Slice 2 when the doll `UiViewport` (`0x100001D5`) renders behind the slots and the empty slots flip to transparent (`EmptySprite=0`). | Empty equip slots look like plain framed squares instead of revealing the character body — cosmetic, pending Slice 2's doll viewport. | `gmPaperDollUI` init `SetVisible(0)` per slot; user retail screenshots (slot-view) | --- diff --git a/docs/research/2026-06-22-paperdoll-handoff.md b/docs/research/2026-06-22-paperdoll-handoff.md index 5e892362..028572d8 100644 --- a/docs/research/2026-06-22-paperdoll-handoff.md +++ b/docs/research/2026-06-22-paperdoll-handoff.md @@ -5,6 +5,17 @@ **Status:** SCOPED, not started. The decomposition + corrections + reuse map below are the design sketch; a fresh session runs the full brainstorm → spec → plan → subagent-driven → visual-gate flow **per slice**. **Line numbers drift — grep the symbol.** +> **⚠ SUPERSEDED / CORRECTED 2026-06-23 — Slice 1 SHIPPED (`a4c6852`).** This handoff's "⚠ Correction" +> section below ("empty equip slots are TRANSPARENT, no silhouettes") **was WRONG** and cost a visual-gate +> iteration. The user-axiom truth (his retail screenshots): the paperdoll has a **"Slots" toggle button +> (`0x100005BE` = `m_SlotCheckbox`)**. Button OFF (default) = the **live 3-D character** (the doll — can be +> naked) + the non-armor slots (weapon/shield/wand/jewelry/under-clothes); button ON = the doll **hides** and +> the **armor slots** become visible. **The "figure"/"silhouette" IS the doll (live render), NOT per-slot +> silhouette sprites — there are none.** So the doll `UiViewport` (Type 0xD, `0x100001D5`) + the Slots toggle +> = **Slice 2**. Slice 1 (equip-slot bind + wield/unwield + **visible empty-slot frames** + the `EquipMask` +> correction + the `PickupEvent` unwield fix) SHIPPED. **The corrected model + the full DO-NOT-RETRY list live +> in `claude-memory/project_d2b_retail_ui.md` (the Slice 1 entry) — read that, not the §"⚠ Correction" below.** + --- ## Read first (in this order) From c88bc5c8eb2f469652bbdbabc2a1329ad2593215 Mon Sep 17 00:00:00 2001 From: Erik Date: Tue, 23 Jun 2026 07:56:54 +0200 Subject: [PATCH 133/133] =?UTF-8?q?docs(D.2b):=20Slice=202=20handoff=20?= =?UTF-8?q?=E2=80=94=20paperdoll=203D=20doll=20viewport=20+=20the=20Slots?= =?UTF-8?q?=20toggle?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Carries the corrected paperdoll model forward (figure = live doll, NOT silhouettes; the Slots button toggles doll-view vs armor-slot-view — REPLACE, not overlay). Scopes Slice 2 = (A) the toggle (read ListenToElementMessage idMessage==1) + (B) the UiViewport Type 0xD doll via a Core->App IUiViewportRenderer seam reusing EntitySpawnAdapter. Includes the decomp anchors, camera/light immediates to decode, open questions, and the new-session prompt. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../2026-06-23-paperdoll-slice2-handoff.md | 89 +++++++++++++++++++ 1 file changed, 89 insertions(+) create mode 100644 docs/research/2026-06-23-paperdoll-slice2-handoff.md diff --git a/docs/research/2026-06-23-paperdoll-slice2-handoff.md b/docs/research/2026-06-23-paperdoll-slice2-handoff.md new file mode 100644 index 00000000..952a6910 --- /dev/null +++ b/docs/research/2026-06-23-paperdoll-slice2-handoff.md @@ -0,0 +1,89 @@ +# Handoff — D.2b Sub-phase C, Slice 2: the paperdoll 3-D doll + the "Slots" toggle + +**Date:** 2026-06-23 (end of the Slice 1 session) +**Branch:** `claude/hopeful-maxwell-214a12`. **Slice 1 SHIPPED + merged** — `main` is fast-forwarded to the branch tip (`main == branch` at `6897148`). Slice 2 continues on this branch. +**Status:** SCOPED, not started. Run the full **brainstorm → spec → plan → subagent-driven → visual-gate** flow. +**Line numbers drift — grep the symbol.** + +--- + +## Read first (in this order) + +1. **This doc.** +2. **`claude-memory/project_d2b_retail_ui.md`** — the D.2b SSOT. The **"SLICE 1 … SHIPPED 2026-06-23"** entry holds the CORRECTED paperdoll model + the full DO-NOT-RETRY. **It supersedes the visual/look claims in the older docs below.** +3. **`docs/research/2026-06-16-equipment-paperdoll-deep-dive.md`** — the AUTHORITATIVE element-id / wire / viewport research: §5 (the viewport mechanism, CONFIRMED), §5c (reuse analysis), §5d + §7 (camera/light immediates + the UNVERIFIED list). **⚠ Caveat:** its §2a "empty slot shows the doll body behind it" and the 2026-06-22 handoff's "transparent / per-slot silhouettes" framing were **WRONG about the LOOK** — see the corrected model below. The element ids, opcodes, and viewport plumbing in it are still good. +4. **`docs/architecture/code-structure.md` Rule 2** — Core defines interfaces, App implements; never the reverse. Governs the `IUiViewportRenderer` seam. +5. **`src/AcDream.App/UI/Layout/PaperdollController.cs`** — Slice 1's controller (the 21 slot bindings + wield/unwield drag handler you'll extend, not rewrite). +6. **`memory/reference_modern_rendering_pipeline.md`** + `EntitySpawnAdapter`/`AnimatedEntityState` — the in-world animated-character path the doll reuses. + +## The CORRECTED paperdoll model (USER AXIOM — confirmed against his retail screenshots, 2026-06-23) + +**There are NO per-slot silhouette sprites.** The "figure" in the paperdoll **IS the live 3-D character** (the doll — it can be naked if nothing is equipped). The panel has **two views toggled by the "Slots" button** (`0x100005BE` = `m_SlotCheckbox`, a `UIElement_Button`): + +- **Button OFF (default) — "doll view":** show the **3-D character** + the **non-armor** slots (under-clothes / weapon / wand / shield / jewelry). The armor slots are hidden. +- **Button ON — "slot view":** the **character disappears** and the **armor slots become visible** (the grid). + +It is a **TOGGLE (mutually exclusive: doll XOR armor-slots)**, NOT slots-overlaid-on-the-doll. Verify the exact show/hide split against the decomp (below) + the user's screenshots before coding — do not re-derive the look from the old deep-dive prose. + +**What Slice 1 already did (don't redo):** bound all ~21 equip slots (`PaperdollController`, element-id→`EquipMask` map), wield (`GetAndWieldItem 0x001A`, optimistic + rollback) + unwield (the inventory-grid path + the `PickupEvent` two-table fix), and gave empty slots a **visible frame** (`emptySlotSprite = 0x06004D20`) so all positions are usable. Slice 1 shows **all** slots at once (no toggle, no doll). Slice 2 adds the doll + the toggle on top. + +## Slice 2 — two pieces (each its own spec → plan → implement; the viewport is the crux) + +### A. The "Slots" toggle (the lighter piece — do FIRST, it de-risks the layout) + +Wire the `m_SlotCheckbox` button (`0x100005BE`) to switch between doll-view and slot-view: +- **Map each slot as ARMOR vs NON-ARMOR.** Retail's `RemakeCharacterInventory` (decomp `0x004a65c0`) builds `clothingPriorityMask` from the mask **`0x8007fff`** (line 176016) — clothing/jewelry/misc; the armor slots are the `*Armor` `EquipMask` bits (`ChestArmor 0x200 … LowerLegArmor 0x4000`). Confirm the exact partition from `GetLocationInfoFromElementID` (the §3a table) + the toggle handler. +- **READ THE TOGGLE HANDLER FIRST:** `gmPaperDollUI::ListenToElementMessage` (decomp `0x004a5c30`, ~line 175593) — the `idMessage == 1` branch (button click, ~line 175628+, NOT yet read this session) is the show/hide logic. The init does `SetAttribute_Bool(m_SlotCheckbox, 0xe, 0)` (line 175585) = start unchecked (doll view). Port the exact `SetVisible` toggling it does on the armor slots + the viewport. +- In acdream: `PaperdollController` keeps refs to the armor slots (toggle-hidden in doll view) + the viewport widget; the button's click flips their `Visible`. The non-armor slots stay visible in both views (verify). +- **Slice-1 caveat to revisit:** AP-66 (empty slots show a frame) — decide whether the frame stays in slot-view (likely yes) and what an empty NON-ARMOR slot shows in doll-view. The "frames flip to transparent / doll-through" idea in AP-66 was written under the overlay assumption; the TOGGLE model means there is no doll behind the armor slots in slot-view, so the frame likely **stays**. Re-word AP-66 once the toggle behavior is confirmed. + +### B. The 3-D doll `UiViewport` (the heavy piece — the UI↔3D bridge, the real crux) + +A `UiViewport` widget (registers at dat **Type `0xD`**, element **`0x100001D5`**) that renders a re-dressed clone of the local player into the widget's screen rect (a scissored single-entity 3-D pass), shown in doll-view. + +- **The Core→App seam (Code-Structure Rule 2):** define `IUiViewportRenderer` in `AcDream.Core` (or the UI.Abstractions layer), implement in `AcDream.App` (it has GL + `EntitySpawnAdapter`). The widget's `OnDraw` only has a 2-D `UiRenderContext`; the 3-D pass needs a dedicated overlay hook the `UiHost`/`GameWindow` invokes for any `UiViewport` present (NOT inside `OnDraw`). **The exact integration point (after the world pass vs a UI overlay) is DESIGN-OPEN — settle it in the brainstorm** (deep-dive §5d/§7). +- **Reuse the existing char path (deep-dive §5c, CONFIRMED):** build a `WorldEntity` from the local player's Setup + current ObjDesc, feed it through `EntitySpawnAdapter`/`AnimatedEntityState` (palette/part/hidden-part overrides), draw it with a fixed camera + one distant light into the rect. Re-dress on `ObjDescEvent 0xF625` (already PARSED) = rebuild the entity's overrides — the C# analog of retail `RedressCreature` (decomp `0x004a5c90`?/line 173990 / 175535). +- **Camera/light/heading immediates** (retail `gmPaperDollUI::PostInit`, decomp ~175509-535; raw hex read this session, **decode + VERIFY**): + - `SetCamera(viewport, &dir, &pos)` with `dir ≈ (0x3df5c28f=0.12, 0xc019999a=-2.4, 0x3f6147ae=0.88)`, `pos = (0,0,0)`. (Arg order pos-vs-dir UNVERIFIED — `UIElement_Viewport::SetCamera` → `CreatureMode::SetCameraPosition/Direction`.) + - `SetLight(viewport, DISTANT_LIGHT, 2.0f, &dir)` with `dir ≈ (0x3e99999a=0.3, 0x3ff33333=1.9, <3rd component is a strncpy/lifter artifact — recover from Ghidra/re-decompile>)`. + - `set_heading(191.367905°)` so the doll faces the viewer; idle animation via `m_didAnimation` (`UpdateForRace` `0x004a…`/line 174129 swaps the idle DID per body-type via `DBObj::GetDIDByEnum`); `CreatureMode::UseSharpMode` (sharper mip bias). +- **Player-clone vs fresh WorldEntity (UNVERIFIED, deep-dive §7):** retail clones the player `CPhysicsObj` (`makeObject(GetPhysicsObject(player_id))`). acdream's local player is NOT a renderable `WorldEntity` (it's the camera), so LIKELY build a dedicated `WorldEntity` from the player's Setup + ObjDesc and host it in a private viewport scene. Confirm the player's Setup id + current ObjDesc are available client-side (PlayerDescription / CreateObject / the equipped ObjDescEvents). +- **Polish, NOT MVP:** part-selection lighting (`ApplyPartSelectionLighting` / `GetSelectionMaskFromObject` / `CreateClickMap`, lines 174034/174762/174636) — the "which armor piece is this?" highlight + the doll click-map; the Aetheria sigil slots (`0x10000595/96/97`, `SetVisible(0)` by default). Defer. + +## What's already in place (reuse, don't rebuild) + +- **`PaperdollController`** — the slot bindings + wield/unwield + the element-id→`EquipMask` map. Extend it with the armor/non-armor partition + the viewport ref + the toggle. +- **The mount + import** — the gmPaperDollUI subtree (`0x21000024`) is imported under `0x100001CD` in the inventory frame; the viewport element `0x100001D5` is in the tree (currently skipped — `DatWidgetFactory` Type 12→skip; Type `0xD` is NOT registered yet → register it to `UiViewport`). +- **`EntitySpawnAdapter` / `AnimatedEntityState` / `WorldEntity`** — the per-instance animated-character render path (palette/part/hidden-part overrides). The doll's model pipeline. +- **`ObjDescEvent 0xF625`** parse + `CreateObject.ReadModelData` (palette/sub-palette/texture/anim-part) — the re-dress input. +- **`ClientObjectTable` + the wield wire** (`GetAndWieldItem`, `WieldObject 0x0023` + `ConfirmMove`, `PickupEvent` two-table fix) — all shipped in Slice 1. + +## Open questions / UNVERIFIED (settle in the brainstorm) + +1. **Overlay vs replace** — confirmed REPLACE (toggle) per the user, but verify the exact `SetVisible` set the `ListenToElementMessage idMessage==1` handler toggles (armor slots + viewport; do the non-armor slots stay shown in slot-view?). +2. **The `IUiViewportRenderer` integration point** — after the world pass, or a dedicated UI-overlay 3-D pass? Scissor + viewport from the widget's screen rect. Code-Structure Rule 2 = Core interface, App impl. +3. **Camera/light immediates** — decode + verify the floats above; recover the corrupted 3rd light component from Ghidra. +4. **Player-clone vs fresh `WorldEntity`** — acdream has no player `WorldEntity`; build one from Setup+ObjDesc. Confirm the data is available client-side. +5. **AP-66 fate** — does the empty-slot frame stay in slot-view (likely) or change? Re-word the register row once the toggle behavior is confirmed. +6. **`0x100001E0 = MissileAmmo 0x800000`** still LIKELY (AP-62) — gate-verify the ammo slot. + +## Decomp anchors (named-retail `acclient_2013_pseudo_c.txt`) + +- `gmPaperDollUI::PostInit` (~175232; the slot/viewport/button init I read this session — `SetVisible(0)` per slot, `SetCamera`/`SetLight`/`UseSharpMode`/`RedressCreature` at 175509-535, `m_SlotCheckbox = 0x100005BE` at 175557). +- `gmPaperDollUI::ListenToElementMessage` (~175593; **read the `idMessage==1` branch ~175628+ for the toggle**). +- `gmPaperDollUI::RedressCreature` (173990 / 175535; clone + `set_heading(191.37°)` + `set_sequence_animation` + `DoObjDescChangesFromDefault`). +- `gmPaperDollUI::UpdateForRace` (174129; per-body-type camera + idle DID). +- `gmPaperDollUI::RemakeCharacterInventory` (175983) + `SetUIItemIntoLocation` (175713/175950; populate equipped items — Slice 1 does the equivalent). +- `UIElement_Viewport::Create/SetCamera/SetLight/PostInit` + `CreatureMode::Render` (deep-dive §5a/§5b, lines 119029/91665). + +## Acceptance (Slice 2) + +- The "Slots" button toggles: doll-view (3-D character + non-armor slots) ↔ slot-view (armor slots). Matches the user's two screenshots. +- The doll renders the re-dressed local player (correct race/gender/equipped gear; naked if nothing equipped), faces the viewer, idles; updates live on equip/unequip via `ObjDescEvent 0xF625`. +- Build + full suite green; **visual gate** (user compares to retail). Divergence rows for any approximation; the `IUiViewportRenderer` seam respects Code-Structure Rule 2. + +## New-session prompt + +> Continue acdream's D.2b retail-UI inventory arc on branch `claude/hopeful-maxwell-214a12` (Slice 1 shipped; `main` is ff-merged to the tip). **READ FIRST:** `docs/research/2026-06-23-paperdoll-slice2-handoff.md`, then its "Read first" list — especially the **Slice 1 entry in `claude-memory/project_d2b_retail_ui.md`** (the CORRECTED paperdoll model) and the deep-dive `docs/research/2026-06-16-equipment-paperdoll-deep-dive.md` §5. +> +> **NEXT: Sub-phase C — the paperdoll, Slice 2.** The corrected model (user axiom): the paperdoll "figure" IS the **live 3-D character** (not silhouettes); the **"Slots" button (`0x100005BE`)** TOGGLES between **doll-view** (3-D character + non-armor slots) and **slot-view** (armor slots) — mutually exclusive, NOT overlaid. Build (A) the **toggle** first — read `gmPaperDollUI::ListenToElementMessage`'s `idMessage==1` branch (~decomp 175628+) for the exact `SetVisible` show/hide of the armor slots + viewport; partition armor vs non-armor slots (armor = the `*Armor` EquipMask bits; clothing/jewelry/weapon = non-armor, cf. `clothingPriorityMask` `0x8007fff`). Then (B) the **3-D doll `UiViewport`** (register dat **Type `0xD`**, element `0x100001D5`): a Core `IUiViewportRenderer` seam (Code-Structure Rule 2 — Core defines, App implements) driving a scissored single-entity 3-D pass keyed to the widget rect; reuse `EntitySpawnAdapter`/`AnimatedEntityState` by building a `WorldEntity` from the local player's Setup + current ObjDesc, re-dressed on `ObjDescEvent 0xF625` (the C# analog of `RedressCreature`); fixed camera + one distant light + `set_heading(191.37°)` + idle anim (decode the `PostInit` immediates ~175509-535 + verify). **Extend the shipped `PaperdollController` — do NOT rewrite the slot bindings/wield/unwield.** The UI↔3-D integration point is DESIGN-OPEN — settle it in the brainstorm. Run the full **brainstorm → spec → plan → subagent-driven → visual-gate** flow. **DO NOT auto-kill the running client** — launch with plain `dotnet run`; if a rebuild is locked, ask the user to close it.