From 69ad2cf12de88e81d4643b005e9e9f56c3caa19a Mon Sep 17 00:00:00 2001 From: Erik Date: Mon, 22 Jun 2026 10:40:51 +0200 Subject: [PATCH] =?UTF-8?q?docs(D.2b):=20implementation=20plan=20=E2=80=94?= =?UTF-8?q?=20inventory=20empty-slot=20art=20via=20cell-template=20resolut?= =?UTF-8?q?ion?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .../plans/2026-06-22-d2b-empty-slot-art.md | 556 ++++++++++++++++++ 1 file changed, 556 insertions(+) create mode 100644 docs/superpowers/plans/2026-06-22-d2b-empty-slot-art.md diff --git a/docs/superpowers/plans/2026-06-22-d2b-empty-slot-art.md b/docs/superpowers/plans/2026-06-22-d2b-empty-slot-art.md new file mode 100644 index 00000000..37c13797 --- /dev/null +++ b/docs/superpowers/plans/2026-06-22-d2b-empty-slot-art.md @@ -0,0 +1,556 @@ +# 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; + +/// +/// Real-dat test for : 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). +/// +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; + +/// +/// 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. +/// +public static class ItemListCellTemplate +{ + /// 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). + 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 + + /// + /// Resolve the empty-slot sprite (a 0x06xxxxxx RenderSurface id) for the cells of item-list + /// element in LayoutDesc . + /// Returns 0 when the layout/element/attribute/prototype/media is absent (the caller keeps + /// the default). + /// + public static uint ResolveEmptySprite(DatCollection dats, uint listLayoutId, uint listElementId) + { + var listLd = dats.Get(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(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) " +``` + +--- + +### 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; + /// Empty-slot sprite for THIS list's cells, resolved from the dat cell template + /// (retail attribute 0x1000000e -> catalog 0x21000037 prototype's ItemSlot_Empty; see + /// ). 0 = leave the + /// default (0x060074CF). Applied to every existing + /// AND future cell. + 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) " +``` + +--- + +### 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 playerGuid, + Func iconIds, + Func 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) " +``` + +--- + +### 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) " +``` + +--- + +### 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 . 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) " +``` + +- [ ] **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 1–4. ✓ +- §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 3–4. ✓ +- §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 1–3 + Task 5 full suite. ✓ +- §8 acceptance (build+test green; rows + ISSUES; visual gate) → Tasks 4–5. ✓ + +**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 2–3 tests. Consistent throughout.