acdream/docs/superpowers/specs/2026-06-22-d2b-empty-slot-art-design.md
Erik 660bcc2fcf docs(D.2b): design — faithful inventory empty-slot art via cell-template resolution
Brainstorm + decomp investigation for the OPEN issue "Inventory + equipment
slots show the wrong empty-slot background art". Verified against the named
decomp that retail's UIElement_ItemList::InternalCreateItem (004e3570) sources
each list's empty-cell sprite from attribute 0x1000000e on the list's own
ElementDesc -> catalog LayoutDesc 0x21000037 -> the prototype's ItemSlot_Empty
(0x1000001c) media. acdream hardcodes 0x060074CF (the generic toolbar square),
bypassing the per-list cell-template inheritance entirely.

Design ports the resolver: a new ItemListCellTemplate.ResolveEmptySprite helper
(mirrors the existing GameWindow 0x21000037 digit-array read), a
UiItemList.CellEmptySprite property, and InventoryController wiring for the
contents grid / side-bag / main-pack lists. Toolbar untouched (its 0x060074CF
is correct); paperdoll silhouettes stay Sub-phase C.

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

14 KiB
Raw Blame History

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:

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) → InternalCreateItemUIItem_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

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 0x100004500x06005D9C), 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 0x1000033A0x06004D20). 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.