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>
14 KiB
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:
0x060074CFis not "the toolbar's sprite" — it is the genericItemSlot_Emptyshared by many0x21000037catalog elements (the toolbar shortcut prototypes derive from base0x1000045C/0x10000445and use it).0x21000037is 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 standaloneUIElement_UIItem(class0x10000032) cell prototypes.- The dat doesn't say which prototype a list uses. The itemlist base
0x2100003D(element0x10000339) and the contents grid0x100001C6are bare 32×32 containers; the cell art is bound at runtime byUIElement_ItemList. - acdream synthesizes bare cells (
new UiItemSlotwith the hardcodedEmptySprite = 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) → 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
0x10000032UiItemSlotregistration + theUiViewport). 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
0x060074CFis the correct outcome (its0x1000000eresolves 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):
listLd = dats.Get<LayoutDesc>(listLayoutId); findlistElem(idlistElementId) inlistLd.Elements(recursively intoChildren). Not found → return0.- 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 theBaseElement/BaseLayoutIdchain once (the itemlist base0x10000339is bare, so this just confirms absence). Still absent → return0(caller keeps theUiItemSlotdefault; logged). catalogLd = dats.Get<LayoutDesc>(0x21000037);proto = catalogLd.Elements[cellProtoId]. Not found → return0.- Pick the prototype's single empty-background sprite, in this priority (explicit — handles both observed prototype shapes):
a. Frame child — if
protohas a child whoseStateDesc(DirectState) carries a background image (the recessed pack-slot frame; e.g. the 36×36 prototype0x1000033F's child0x10000450→0x06005D9C), return thatFile. b. m_elem_Icon empty — else find the icon sub-element (id0x1000033B, recursively through any inner wrapper such as0x10000340) and return itsItemSlot_Empty(state0x1000001c)MediaDescImage.File(e.g. the 32×32 prototype0x1000033A→0x06004D20). c. else → return0.
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 = 0x060074CFrather than resolving their cell template via0x1000000e; correct in outcome (the toolbar's0x1000000eresolves to a generic prototype) but not dat-resolved. Retire when the toolbar is routed throughItemListCellTemplate." (AP-55 or next free id, added in the implementation commit.) - A second row for the flat-cell approximation: "acdream's
UiItemSlotis a flat single-sprite cell; for a prototype with both a frame child and an inner icon (the 36×36 backpack0x1000033F: frame0x06005D9C+ inner0x06000F6E), 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")
- 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, omittingPropertieskeys like0x1000000e). Dump0x1000000efor0x100001C6(layout0x21000021),0x100001C9and0x100001CA(layout0x21000022). Record: the property type, the resolved prototype id, and that prototype's chosen sprite per §4.3. This confirms the catalog is0x21000037and locks the golden test values. (If0x1000000eis absent on a list element, escalate to the cdb fallback: break atInternalCreateItem004e3616, read arg4 + the catalog DID — per the research report's §"How to verify next".) ItemListCellTemplate.ResolveEmptySprite+ unit/real-dat tests.UiItemList.CellEmptySprite+ unit test.InventoryController.Bindparams + apply; update affected InventoryController tests.GameWindowwiring.- 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 == theItemSlot_Emptyof the prototype named by that element's0x1000000e(structural assertion — no magic literal until step 1 locks it). Same for0x100001C9/0x100001CA(layout0x21000022). UiItemList.CellEmptySpriteunit test: assigning it stamps existing and subsequently-added cells;0leaves theUiItemSlotdefault.InventoryController: afterBindwith the three sprites, the contents-grid / side-bag / main-pack cells carry the expectedEmptySprite; the padded empty cells too.- Unchanged:
UiItemSlotTests.DefaultEmptySprite_isToolbarBorderstays green (the default is still0x060074CF). 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
0x1000000eproperty 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.