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