acdream/docs/superpowers/specs/2026-06-21-d2b-inventory-window-finish-design.md
Erik 0fbb76b2e7 docs(D.2b): inventory window finish (Stage 1) spec
Stage 1 of the "full retail inventory" arc (Stage 2 = paperdoll, separate
spec). Grounded in the dat dumps: contents grid 0x100001C6 is 192x96 (6x3) +
scrollbar 0x100001C7 (16x96); backdrop 0x100001D0 is full-window 300x362
(so the "torn" look is the unclipped grid overflowing below the frame —
fixed by clipping); side-bag column 0x100001CA is 36x252 (7 slots).
Components: (A) UiItemList clip+scroll reusing UiScrollable + bind the gutter
scrollbar like ChatWindowController; (B) backdrop coverage — primary fix is A,
residual gated on the post-A screenshot; (C) side-bag column pads empty slots
up to capacity at 36px pitch. Paperdoll + B-Drag out of scope.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-21 20:07:10 +02:00

9.6 KiB
Raw Blame History

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 UiItemSlots 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.