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>
This commit is contained in:
parent
e6c8b65f77
commit
660bcc2fcf
1 changed files with 145 additions and 0 deletions
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.*
|
||||||
Loading…
Add table
Add a link
Reference in a new issue