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

28 KiB
Raw Blame History

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:

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 errorItemListCellTemplate does not exist.

  • Step 3: Implement the resolver

Create src/AcDream.App/UI/Layout/ItemListCellTemplate.cs:

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
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):

    [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 errorUiItemList has no CellEmptySprite.

  • Step 3: Implement CellEmptySprite

In src/AcDream.App/UI/UiItemList.cs, add the property after the SpriteResolve property (around line 29):

    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:

        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
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:

    [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 errorBind 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):

        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:

        // 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):

    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
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:

                // 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:

                    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):

| 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
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

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/0x21000022Bind → 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.