acdream/docs/superpowers/plans/2026-06-22-d2b-empty-slot-art.md
Erik 69ad2cf12d docs(D.2b): implementation plan — inventory empty-slot art via cell-template resolution
5-task TDD plan for the OPEN empty-slot-art issue: ItemListCellTemplate resolver
(ports UIElement_ItemList::InternalCreateItem's 0x1000000e -> 0x21000037 lookup),
UiItemList.CellEmptySprite, InventoryController + GameWindow wiring, divergence
rows AP-55/AP-56, ISSUES close + visual gate. Each task is test-first with exact
code; Task 1 pins the exact retail sprites from the live dat.

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

556 lines
28 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# D.2b Inventory empty-slot art — Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Make the inventory contents grid, side-bag column, and main-pack cell render their correct per-list empty-slot background — resolved from the dat exactly as retail does — instead of the hardcoded generic toolbar square.
**Architecture:** Port retail's `UIElement_ItemList::InternalCreateItem` resolver into a small static helper (`ItemListCellTemplate.ResolveEmptySprite`): read attribute `0x1000000e` off the list's ElementDesc → a cell-template element id → look it up in catalog LayoutDesc `0x21000037` → take that prototype's empty-slot media. A new `UiItemList.CellEmptySprite` carries the resolved sprite to the cells; `InventoryController` sets it for the three lists; `GameWindow` resolves and passes the three values (mirroring the existing `0x21000037` digit-array read + `ToolbarController.Bind`). `UiItemSlot` and the toolbar are untouched.
**Tech Stack:** C# / .NET 10, xUnit, `DatReaderWriter` (dat reader), the existing `AcDream.App.UI.Layout` importer.
**Spec:** `docs/superpowers/specs/2026-06-22-d2b-empty-slot-art-design.md`.
---
## File Structure
| File | Create/Modify | Responsibility |
|---|---|---|
| `src/AcDream.App/UI/Layout/ItemListCellTemplate.cs` | **Create** | The pure dat→sprite resolver (port of `InternalCreateItem`'s template lookup). |
| `tests/AcDream.App.Tests/UI/Layout/ItemListCellTemplateTests.cs` | **Create** | Real-dat smoke + the "pin the exact asset" printout (skips without the dat). |
| `src/AcDream.App/UI/UiItemList.cs` | Modify | Add `CellEmptySprite` (stamps cells). |
| `tests/AcDream.App.Tests/UI/UiItemListTests.cs` | Modify | Unit test for `CellEmptySprite` (no dat). |
| `src/AcDream.App/UI/Layout/InventoryController.cs` | Modify | `Bind`/ctor gain the three sprites; apply to the three lists. |
| `tests/AcDream.App.Tests/UI/Layout/InventoryControllerTests.cs` | Modify | Test the three lists receive the sprites. |
| `src/AcDream.App/Rendering/GameWindow.cs` | Modify (near `:2217`) | Resolve the three sprites; pass into `InventoryController.Bind`. |
| `docs/architecture/retail-divergence-register.md` | Modify | Two AP rows (toolbar hardcode; flat-cell approximation). |
| `docs/ISSUES.md` | Modify | Move the empty-slot-art issue to *Recently closed* (inventory portion). |
`UiItemSlot.cs` is **not** modified — its `0x060074CF` default stays (correct for the toolbar); cells receive the per-list value via `UiItemList.CellEmptySprite`.
---
### Task 1: The resolver + real-dat pinning test
**Files:**
- Create: `src/AcDream.App/UI/Layout/ItemListCellTemplate.cs`
- Test: `tests/AcDream.App.Tests/UI/Layout/ItemListCellTemplateTests.cs`
- [ ] **Step 1: Write the failing real-dat test**
Create `tests/AcDream.App.Tests/UI/Layout/ItemListCellTemplateTests.cs`:
```csharp
using System;
using System.IO;
using AcDream.App.UI.Layout;
using DatReaderWriter;
using DatReaderWriter.Options;
using Xunit;
using Xunit.Abstractions;
namespace AcDream.App.Tests.UI.Layout;
/// <summary>
/// Real-dat test for <see cref="ItemListCellTemplate.ResolveEmptySprite"/>: the inventory
/// item-lists must resolve their empty-slot sprite from the dat cell template (attribute
/// 0x1000000e -> catalog 0x21000037 prototype's ItemSlot_Empty), NOT the generic toolbar
/// square 0x060074CF. Also PRINTS the resolved ids — the "pin the exact asset" record.
/// Skips when the live dat directory is absent (CI).
/// </summary>
public class ItemListCellTemplateTests
{
private const uint Generic = 0x060074CFu; // generic toolbar-shortcut empty square (the bug)
private const uint ContentsLayout = 0x21000021u; private const uint ContentsGrid = 0x100001C6u;
private const uint BackpackLayout = 0x21000022u; private const uint SideBag = 0x100001CAu;
private const uint MainPack = 0x100001C9u;
private readonly ITestOutputHelper _out;
public ItemListCellTemplateTests(ITestOutputHelper o) => _out = o;
private static string? DatDir()
{
var d = Environment.GetEnvironmentVariable("ACDREAM_DAT_DIR")
?? Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile),
"Documents", "Asheron's Call");
return Directory.Exists(d) ? d : null;
}
[Fact]
public void Inventory_lists_resolve_a_real_nongeneric_empty_sprite()
{
var datDir = DatDir();
if (datDir is null) return; // CI: no live dat — skip (smoke test)
using var dats = new DatCollection(datDir, DatAccessType.Read);
foreach (var (name, layout, elem) in new[]
{
("contents", ContentsLayout, ContentsGrid),
("side-bag", BackpackLayout, SideBag),
("main-pack", BackpackLayout, MainPack),
})
{
uint sprite = ItemListCellTemplate.ResolveEmptySprite(dats, layout, elem);
_out.WriteLine($"{name}: list 0x{elem:X8} (layout 0x{layout:X8}) -> empty sprite 0x{sprite:X8}");
Assert.True(sprite != 0u, $"{name}: resolved 0 (no 0x1000000e attr / prototype / media)");
Assert.True(sprite != Generic, $"{name}: resolved the generic 0x060074CF — the bug, not the fix");
Assert.True((sprite & 0xFF000000u) == 0x06000000u, $"{name}: 0x{sprite:X8} is not a 0x06 RenderSurface id");
}
}
}
```
- [ ] **Step 2: Run the test to verify it fails**
Run: `dotnet test tests/AcDream.App.Tests/AcDream.App.Tests.csproj --filter "FullyQualifiedName~ItemListCellTemplateTests"`
Expected: **compile error**`ItemListCellTemplate` does not exist.
- [ ] **Step 3: Implement the resolver**
Create `src/AcDream.App/UI/Layout/ItemListCellTemplate.cs`:
```csharp
using DatReaderWriter;
using DatReaderWriter.DBObjs;
using DatReaderWriter.Types;
namespace AcDream.App.UI.Layout;
/// <summary>
/// Resolves the empty-slot background sprite for a UIElement_ItemList's cells the way retail
/// does. Retail ref: UIElement_ItemList::InternalCreateItem (acclient_2013_pseudo_c.txt:231486 /
/// 0x004e3570): GetAttribute_Enum(this, 0x1000000e, &id) -> GetByEnum(0x10000038,5,0x23) [the
/// shared UIItem catalog LayoutDesc, = 0x21000037] -> CreateChildElement(catalog, id); the cloned
/// prototype's ItemSlot_Empty (state 0x1000001c) media is the empty-cell background.
/// </summary>
public static class ItemListCellTemplate
{
/// <summary>The shared UIItem cell-template catalog. Hardcoded: retail resolves it via
/// GetByEnum(0x10000038,5,0x23) through a master enum-map DAT object (no code literal);
/// 0x21000037 is the only LayoutDesc that is a catalog of standalone UIItem (0x10000032)
/// prototypes. Validated by the real-dat test (the resolved sprite must be a real 0x06 surface).</summary>
public const uint CatalogLayoutId = 0x21000037u;
private const uint CellTemplateAttr = 0x1000000eu; // UIElement attribute: cell-template element id
private const uint IconChildId = 0x1000033Bu; // m_elem_Icon sub-element (carries ItemSlot_Empty)
private const string ItemSlotEmpty = "ItemSlot_Empty"; // UIStateId.ToString() for state 0x1000001c
/// <summary>
/// Resolve the empty-slot sprite (a 0x06xxxxxx RenderSurface id) for the cells of item-list
/// element <paramref name="listElementId"/> in LayoutDesc <paramref name="listLayoutId"/>.
/// Returns 0 when the layout/element/attribute/prototype/media is absent (the caller keeps
/// the <see cref="UiItemSlot"/> default).
/// </summary>
public static uint ResolveEmptySprite(DatCollection dats, uint listLayoutId, uint listElementId)
{
var listLd = dats.Get<LayoutDesc>(listLayoutId);
if (listLd is null) return 0;
var listElem = FindDesc(listLd, listElementId);
if (listElem is null) return 0;
uint protoId = ReadCellTemplateId(listElem); // attr 0x1000000e
if (protoId == 0) return 0;
var catalog = dats.Get<LayoutDesc>(CatalogLayoutId);
if (catalog is null) return 0;
var proto = FindDesc(catalog, protoId);
if (proto is null) return 0;
// Priority: a child's DirectState background frame (the 36x36 backpack cell's recessed
// border, e.g. 0x06005D9C); else the m_elem_Icon child's ItemSlot_Empty media (the 32x32
// contents cell, e.g. 0x06004D20). See the spec's explicit single-sprite contract.
uint frame = FirstChildDirectStateImage(proto);
return frame != 0 ? frame : IconChildEmptyImage(proto);
}
// ── attribute 0x1000000e (read like the font DID 0x1A: bare DataIdBaseProperty or an array) ──
private static uint ReadCellTemplateId(ElementDesc elem)
{
uint id = ReadIdFromState(elem.StateDesc);
if (id != 0) return id;
foreach (var s in elem.States)
{
id = ReadIdFromState(s.Value);
if (id != 0) return id;
}
return 0;
}
private static uint ReadIdFromState(StateDesc? sd)
{
if (sd?.Properties is null) return 0;
return sd.Properties.TryGetValue(CellTemplateAttr, out var raw) ? ReadId(raw) : 0;
}
private static uint ReadId(BaseProperty raw)
{
if (raw is ArrayBaseProperty arr && arr.Value.Count > 0) return ReadId(arr.Value[0]);
if (raw is DataIdBaseProperty did) return did.Value;
return 0;
}
// ── prototype media ──
private static uint FirstChildDirectStateImage(ElementDesc proto)
{
foreach (var kv in proto.Children)
{
uint f = DirectStateImage(kv.Value);
if (f != 0) return f;
}
return 0;
}
private static uint IconChildEmptyImage(ElementDesc proto)
{
var icon = FindDescIn(proto, IconChildId); // recursive: handles inner wrappers (e.g. 0x10000340)
if (icon is null) return 0;
foreach (var s in icon.States)
if (s.Key.ToString() == ItemSlotEmpty)
{
uint f = StateImage(s.Value);
if (f != 0) return f;
}
return DirectStateImage(icon); // some prototypes carry empty media on the DirectState
}
private static uint DirectStateImage(ElementDesc d)
=> d.StateDesc is null ? 0u : StateImage(d.StateDesc);
private static uint StateImage(StateDesc sd)
{
foreach (var m in sd.Media)
if (m is MediaDescImage img && img.File != 0) return img.File;
return 0;
}
// ── depth-first element search by id (LayoutImporter.FindDesc is private there) ──
private static ElementDesc? FindDesc(LayoutDesc ld, uint id)
{
foreach (var kv in ld.Elements)
{
var f = FindDescIn(kv.Value, id);
if (f is not null) return f;
}
return null;
}
private static ElementDesc? FindDescIn(ElementDesc d, uint id)
{
if (d.ElementId == id) return d;
foreach (var kv in d.Children)
{
var f = FindDescIn(kv.Value, id);
if (f is not null) return f;
}
return null;
}
}
```
- [ ] **Step 4: Run the test to verify it passes (and PIN the values)**
Run (this machine has the live dat at `~/Documents/Asheron's Call`):
`dotnet test tests/AcDream.App.Tests/AcDream.App.Tests.csproj --filter "FullyQualifiedName~ItemListCellTemplateTests" -v n`
Expected: **PASS**. In the test output, **record the three printed sprite ids** (the "pin the exact asset" deliverable), e.g. `contents: ... -> empty sprite 0x06004D20`. If any line prints `0x00000000` or fails the `!= Generic` assert, the `0x1000000e` attribute was not found where expected — fall back to the cdb route in the spec §6 (break at `InternalCreateItem` `004e3616`, read arg4 + the catalog DID) and extend `ReadId`/the priority before continuing.
- [ ] **Step 5: Commit**
```bash
git add src/AcDream.App/UI/Layout/ItemListCellTemplate.cs tests/AcDream.App.Tests/UI/Layout/ItemListCellTemplateTests.cs
git commit -m "feat(ui): D.2b empty-slot art — ItemListCellTemplate resolver (0x1000000e -> 0x21000037)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>"
```
---
### Task 2: `UiItemList.CellEmptySprite`
**Files:**
- Modify: `src/AcDream.App/UI/UiItemList.cs`
- Test: `tests/AcDream.App.Tests/UI/UiItemListTests.cs`
- [ ] **Step 1: Write the failing unit test**
Add to `tests/AcDream.App.Tests/UI/UiItemListTests.cs` (inside the existing test class):
```csharp
[Fact]
public void CellEmptySprite_stamps_existing_and_future_cells()
{
var list = new UiItemList(); // ctor adds one default cell
Assert.Equal(0x060074CFu, list.GetItem(0)!.EmptySprite); // UiItemSlot default before set
list.CellEmptySprite = 0x06004D20u; // a pack-slot sprite
Assert.Equal(0x06004D20u, list.GetItem(0)!.EmptySprite); // existing cell re-stamped
list.AddItem(new UiItemSlot());
Assert.Equal(0x06004D20u, list.GetItem(1)!.EmptySprite); // new cell stamped
}
[Fact]
public void CellEmptySprite_zero_leaves_the_UiItemSlot_default()
{
var list = new UiItemList();
list.CellEmptySprite = 0u;
Assert.Equal(0x060074CFu, list.GetItem(0)!.EmptySprite); // unchanged default
}
```
- [ ] **Step 2: Run the test to verify it fails**
Run: `dotnet test tests/AcDream.App.Tests/AcDream.App.Tests.csproj --filter "FullyQualifiedName~UiItemListTests.CellEmptySprite"`
Expected: **compile error**`UiItemList` has no `CellEmptySprite`.
- [ ] **Step 3: Implement `CellEmptySprite`**
In `src/AcDream.App/UI/UiItemList.cs`, add the property after the `SpriteResolve` property (around line 29):
```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; see
/// <see cref="Layout.ItemListCellTemplate.ResolveEmptySprite"/>). 0 = leave the
/// <see cref="UiItemSlot.EmptySprite"/> default (0x060074CF). Applied to every existing
/// AND future cell.</summary>
public uint CellEmptySprite
{
get => _cellEmptySprite;
set
{
_cellEmptySprite = value;
if (value != 0)
foreach (var c in _cells) c.EmptySprite = value;
}
}
```
In `AddItem`, after the existing `cell.SpriteResolve ??= SpriteResolve;` line, add:
```csharp
if (_cellEmptySprite != 0) cell.EmptySprite = _cellEmptySprite;
```
- [ ] **Step 4: Run the test to verify it passes**
Run: `dotnet test tests/AcDream.App.Tests/AcDream.App.Tests.csproj --filter "FullyQualifiedName~UiItemListTests.CellEmptySprite"`
Expected: **PASS** (both tests).
- [ ] **Step 5: Commit**
```bash
git add src/AcDream.App/UI/UiItemList.cs tests/AcDream.App.Tests/UI/UiItemListTests.cs
git commit -m "feat(ui): D.2b empty-slot art — UiItemList.CellEmptySprite stamps cells
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>"
```
---
### Task 3: `InventoryController` applies the three sprites
**Files:**
- Modify: `src/AcDream.App/UI/Layout/InventoryController.cs`
- Test: `tests/AcDream.App.Tests/UI/Layout/InventoryControllerTests.cs`
- [ ] **Step 1: Write the failing test**
Add to `tests/AcDream.App.Tests/UI/Layout/InventoryControllerTests.cs`:
```csharp
[Fact]
public void Empty_sprites_are_applied_to_the_three_lists()
{
var (layout, grid, containers, top, _, _, _, _) = BuildLayout();
InventoryController.Bind(layout, new ClientObjectTable(), () => Player,
iconIds: (_, _, _, _, _) => 0u, strength: () => 100, datFont: null,
contentsEmptySprite: 0x06004D20u, sideBagEmptySprite: 0x06005D9Cu, mainPackEmptySprite: 0x06005D9Cu);
Assert.Equal(0x06004D20u, grid.GetItem(0)!.EmptySprite); // a padded empty contents cell
Assert.Equal(0x06005D9Cu, containers.GetItem(0)!.EmptySprite); // a padded empty side-bag cell
Assert.Equal(0x06005D9Cu, top.GetItem(0)!.EmptySprite); // the main-pack cell
}
```
- [ ] **Step 2: Run the test to verify it fails**
Run: `dotnet test tests/AcDream.App.Tests/AcDream.App.Tests.csproj --filter "FullyQualifiedName~InventoryControllerTests.Empty_sprites"`
Expected: **compile error**`Bind` has no `contentsEmptySprite`/`sideBagEmptySprite`/`mainPackEmptySprite` parameters.
- [ ] **Step 3: Add the parameters and apply them**
In `src/AcDream.App/UI/Layout/InventoryController.cs`:
(a) Add three parameters to the **private constructor** signature (after `UiDatFont? datFont`):
```csharp
UiDatFont? datFont,
uint contentsEmptySprite,
uint sideBagEmptySprite,
uint mainPackEmptySprite)
```
(b) Inside the constructor, after the existing `if (_containerList is not null) { ... }` block that sets `Columns`/`CellWidth`/`CellHeight` (and before the burden-meter block), add:
```csharp
// Per-list empty-slot art, resolved from the dat cell template (retail attr 0x1000000e
// -> catalog 0x21000037 prototype's ItemSlot_Empty). 0 = leave the UiItemSlot default.
if (_contentsGrid is not null) _contentsGrid.CellEmptySprite = contentsEmptySprite;
if (_containerList is not null) _containerList.CellEmptySprite = sideBagEmptySprite;
if (_topContainer is not null) _topContainer.CellEmptySprite = mainPackEmptySprite;
```
(c) Update the public **`Bind`** factory to take + forward the three values (defaulted to 0 so every existing caller and test still compiles):
```csharp
public static InventoryController Bind(
ImportedLayout layout,
ClientObjectTable objects,
Func<uint> playerGuid,
Func<ItemType, uint, uint, uint, uint, uint> iconIds,
Func<int?> strength,
UiDatFont? datFont,
uint contentsEmptySprite = 0u,
uint sideBagEmptySprite = 0u,
uint mainPackEmptySprite = 0u)
=> new InventoryController(layout, objects, playerGuid, iconIds, strength, datFont,
contentsEmptySprite, sideBagEmptySprite, mainPackEmptySprite);
```
- [ ] **Step 4: Run the test to verify it passes**
Run: `dotnet test tests/AcDream.App.Tests/AcDream.App.Tests.csproj --filter "FullyQualifiedName~InventoryControllerTests"`
Expected: **PASS** (the new test + all existing `InventoryControllerTests` — the defaults keep them green).
- [ ] **Step 5: Commit**
```bash
git add src/AcDream.App/UI/Layout/InventoryController.cs tests/AcDream.App.Tests/UI/Layout/InventoryControllerTests.cs
git commit -m "feat(ui): D.2b empty-slot art — InventoryController applies per-list empty sprites
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>"
```
---
### Task 4: GameWindow wiring + divergence rows
**Files:**
- Modify: `src/AcDream.App/Rendering/GameWindow.cs` (around the `InventoryController.Bind` call, `:2217`)
- Modify: `docs/architecture/retail-divergence-register.md`
- [ ] **Step 1: Resolve the three sprites and pass them into `Bind`**
In `src/AcDream.App/Rendering/GameWindow.cs`, immediately **before** the `_inventoryController = AcDream.App.UI.Layout.InventoryController.Bind(` call (currently at ~line 2217), insert:
```csharp
// Resolve each inventory list's empty-slot art from the dat cell template
// (retail UIElement_ItemList::InternalCreateItem 0x004e3570: attr 0x1000000e ->
// catalog 0x21000037 prototype's ItemSlot_Empty). The contents grid lives in
// gm3DItemsUI (0x21000021); the side-bag + main-pack in gmBackpackUI (0x21000022).
uint contentsEmpty, sideBagEmpty, mainPackEmpty;
lock (_datLock)
{
contentsEmpty = AcDream.App.UI.Layout.ItemListCellTemplate.ResolveEmptySprite(_dats!, 0x21000021u, 0x100001C6u);
sideBagEmpty = AcDream.App.UI.Layout.ItemListCellTemplate.ResolveEmptySprite(_dats!, 0x21000022u, 0x100001CAu);
mainPackEmpty = AcDream.App.UI.Layout.ItemListCellTemplate.ResolveEmptySprite(_dats!, 0x21000022u, 0x100001C9u);
}
Console.WriteLine($"[D.2b empty-slot] contents=0x{contentsEmpty:X8} sideBag=0x{sideBagEmpty:X8} mainPack=0x{mainPackEmpty:X8}");
```
Then extend the existing `Bind(...)` call's argument list — after `datFont: vitalsDatFont` (the current last argument, line ~2224) change the trailing `);` so the call ends:
```csharp
datFont: vitalsDatFont,
contentsEmptySprite: contentsEmpty,
sideBagEmptySprite: sideBagEmpty,
mainPackEmptySprite: mainPackEmpty);
```
- [ ] **Step 2: Add the two divergence-register rows**
In `docs/architecture/retail-divergence-register.md`, in the AP (Approximation/Adaptation) table (after the `AP-54` row, line ~156), add two rows (renumber if `AP-55`/`AP-56` are taken — use the next free ids):
```markdown
| AP-55 | The toolbar's item slots keep the hardcoded `UiItemSlot.EmptySprite = 0x060074CF` rather than resolving their cell template via attribute `0x1000000e`. Correct in outcome (the toolbar's `0x1000000e` resolves to a generic prototype whose empty media is `0x060074CF`) but not dat-resolved. | `src/AcDream.App/UI/UiItemSlot.cs` (`EmptySprite` default); `src/AcDream.App/UI/Layout/ToolbarController.cs` | The inventory lists now port the retail resolver (`ItemListCellTemplate`); the toolbar was left on its hardcoded default to avoid regressing frozen, working art. Retire when the toolbar is routed through `ItemListCellTemplate`. | If a future toolbar layout's `0x1000000e` points at a non-generic prototype, the toolbar would show stale art. | `UIElement_ItemList::InternalCreateItem` 0x004e3570; catalog `0x21000037` |
| AP-56 | `UiItemSlot` is a flat single-sprite cell; for a prototype with both a frame child and an inner icon (the 36×36 backpack prototype `0x1000033F`: frame `0x06005D9C` + inner `0x06000F6E`), only the dominant background (the frame) is drawn. Retail clones the full prototype subtree (frame + inner layered). | `src/AcDream.App/UI/UiItemSlot.cs`; `src/AcDream.App/UI/Layout/ItemListCellTemplate.cs` (`ResolveEmptySprite` single-sprite contract) | acdream's cell draws one empty sprite; replicating retail's multi-layer prototype needs a layered cell or a subtree clone. Frame-first chosen as the dominant visible art. Revisit at the visual gate / build a layered cell if the backpack slots read wrong. | The 36×36 backpack cells may lack the inner placeholder layer vs retail. | `UIElement_ItemList::InternalCreateItem` 0x004e3570; prototype `0x1000033F` |
```
- [ ] **Step 3: Build and run the full inventory test slice**
Run: `dotnet build src/AcDream.App/AcDream.App.csproj -c Debug`
Expected: **build succeeds**.
Run: `dotnet test tests/AcDream.App.Tests/AcDream.App.Tests.csproj --filter "FullyQualifiedName~InventoryControllerTests|FullyQualifiedName~ItemListCellTemplateTests|FullyQualifiedName~UiItemListTests"`
Expected: **PASS**.
- [ ] **Step 4: Commit**
```bash
git add src/AcDream.App/Rendering/GameWindow.cs docs/architecture/retail-divergence-register.md
git commit -m "feat(ui): D.2b empty-slot art — GameWindow resolves + wires per-list empty sprites
Inventory contents grid / side-bag / main-pack cells now render the retail
pack-slot empty art (dat cell template via ItemListCellTemplate) instead of the
generic toolbar square. Divergence rows AP-55 (toolbar still hardcoded) + AP-56
(flat single-sprite cell vs retail's layered prototype).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>"
```
---
### Task 5: Full verification, ISSUES, and the visual gate
**Files:**
- Modify: `docs/ISSUES.md`
- [ ] **Step 1: Run the FULL test suite (all four projects)**
Run: `dotnet test`
Expected: **0 failures** across Core.Net / App / UI / Core. (Run the full suite — a filtered batch hid a cross-file regression during B-Wire.)
- [ ] **Step 2: Move the ISSUES entry to *Recently closed* (inventory portion)**
In `docs/ISSUES.md`, find the OPEN issue *"Inventory + equipment slots show the wrong empty-slot background art"*. Edit it so the inventory portion is closed and only the paperdoll equip-slot silhouettes remain (Sub-phase C):
- Change its status line to: `**Status:** PARTIALLY CLOSED — inventory contents grid + side-bag + main-pack empty art now resolved from the dat cell template (`ItemListCellTemplate`), commit <Task-4 SHA>. REMAINING: paperdoll equip-slot silhouettes (Sub-phase C — needs the 0x10000032 UiItemSlot registration + UiViewport).`
- If the repo convention is to fully close + open a follow-up, instead move the whole entry to *Recently closed* with the SHA and add a one-line OPEN issue *"Paperdoll equip slots show a generic blue border, not per-slot silhouettes (Sub-phase C)"*. Match whichever pattern the surrounding ISSUES entries use.
- [ ] **Step 3: Commit the ISSUES update**
```bash
git add docs/ISSUES.md
git commit -m "docs: D.2b empty-slot art — close inventory portion of the empty-slot-art issue
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>"
```
- [ ] **Step 4: STOP — visual gate (user)**
This is the acceptance test and requires the user's eyes. Launch the client (plain `dotnet run --no-build`, **do not auto-kill** a running client — ask the user to close it if a rebuild is locked), open the inventory (F12, `ACDREAM_RETAIL_UI=1`), and have the user confirm:
- The contents-grid empty cells show the retail pack-slot background (not the generic toolbar square).
- The side-bag column empty cells show the correct backpack-slot art.
If the 36×36 backpack cells read wrong (frame-only vs retail's frame+inner), that is AP-56 — escalate to a layered `UiItemSlot` empty (out of this plan's scope) or flip the `ResolveEmptySprite` priority to icon-first; confirm with the user before changing.
---
## Self-Review
**Spec coverage:**
- §2 retail mechanism → Task 1 resolver (ports `InternalCreateItem`'s `0x1000000e` → catalog → `ItemSlot_Empty`). ✓
- §4.1 components (helper, `CellEmptySprite`, controller, GameWindow) → Tasks 14. ✓
- §4.2 `CellEmptySprite` (stamps existing + future cells) → Task 2. ✓
- §4.3 resolver contract (frame-first → icon-empty) → Task 1 `ResolveEmptySprite`. ✓
- §4.4 data flow (per-list resolution from `0x21000021`/`0x21000022``Bind` → cells) → Tasks 34. ✓
- §5 divergence rows (toolbar hardcode + flat-cell) → Task 4 Step 2. ✓
- §6 step-1 validation (pin `0x1000000e` values) → Task 1 Step 4 (the test prints them). ✓
- §7 testing (resolver smoke non-zero/≠generic/0x06; `CellEmptySprite` unit; controller applies; `DefaultEmptySprite_isToolbarBorder` untouched) → Tasks 13 + Task 5 full suite. ✓
- §8 acceptance (build+test green; rows + ISSUES; visual gate) → Tasks 45. ✓
**Placeholder scan:** No "TBD"/"TODO"/"implement later". The recorded `0x1000000e` values (Task 1 Step 4) are an output to capture at run time, not a code placeholder — the resolver is value-agnostic; the test asserts structural properties (non-zero, ≠ generic, 0x06-prefixed), so no magic literal is required for the suite to pass. The `0x06004D20`/`0x06005D9C` literals in the Task 2/3 *unit* tests are illustrative pack-slot ids (any non-zero values exercise the stamping logic); they are not asserted against the dat.
**Type consistency:** `ResolveEmptySprite(DatCollection, uint, uint) → uint` is used identically in Task 1 (test), Task 4 (GameWindow). `UiItemList.CellEmptySprite` (uint) set in Task 3, defined in Task 2. `InventoryController.Bind(..., uint contentsEmptySprite=0, uint sideBagEmptySprite=0, uint mainPackEmptySprite=0)` defined in Task 3, called with named args in Task 4. `UiItemSlot.EmptySprite` (existing, uint) read in Tasks 23 tests. Consistent throughout.