acdream/docs/superpowers/plans/2026-06-20-d2b-inventory-grid-mount.md
Erik 132bf36daa docs(D.2b-B): B-Grid implementation plan
4-task TDD plan: UiItemList grid mode (CellOffset/Columns/cell pitch),
the LayoutImporter.Resolve sub-window mount (ShouldMountBaseChildren +
attach base subtree), GameWindow swap of the placeholder for the real
Import(0x21000023), then full-suite regression guard + visual.

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

16 KiB
Raw Blame History

D.2b B-Grid Implementation Plan (inventory sub-window mount + UiItemList grid)

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 LayoutImporter.Import(0x21000023) produce the full nested inventory frame and give UiItemList an N-cell grid, so F12 shows the real inventory window.

Architecture: A ~4-line mount in LayoutImporter.Resolve attaches a base element's resolved children to a childless, media-less inheritor (the three gmInventoryUI panels nest via the existing BaseElement+BaseLayoutId path). UiItemList gains a column-count + cell-pitch layout, with single-cell (toolbar) behavior preserved by defaults. GameWindow swaps the Sub-phase A placeholder for the real import.

Tech Stack: C# .NET 10, xUnit, the AcDream.App.UI toolkit + LayoutImporter, DatCollection.

Spec: docs/superpowers/specs/2026-06-20-d2b-inventory-grid-mount-design.md


File Structure

  • Modify src/AcDream.App/UI/UiItemList.cs — add Columns/CellWidth/CellHeight + grid layout (CellOffset helper, LayoutCells).
  • Modify src/AcDream.App/UI/Layout/LayoutImporter.csShouldMountBaseChildren predicate + the Resolve mount.
  • Modify src/AcDream.App/Rendering/GameWindow.cs — swap the placeholder for Import(0x21000023).
  • Create tests/AcDream.App.Tests/UI/UiItemListGridTests.cs — grid layout tests.
  • Create tests/AcDream.App.Tests/UI/LayoutImporterMountTests.cs — mount predicate tests.

(InternalsVisibleTo from AcDream.AppAcDream.App.Tests is already configured, so internal static helpers are testable — see UiItemSlot.DragAcceptVisual.)


Task 1: UiItemList grid mode

Files:

  • Modify: src/AcDream.App/UI/UiItemList.cs

  • Test: tests/AcDream.App.Tests/UI/UiItemListGridTests.cs (create)

  • Step 1: Write the failing tests

Create tests/AcDream.App.Tests/UI/UiItemListGridTests.cs:

using AcDream.App.UI;

namespace AcDream.App.Tests.UI;

public class UiItemListGridTests
{
    [Fact]
    public void CellOffset_RowMajor()
    {
        Assert.Equal((0f, 0f),   UiItemList.CellOffset(0, 3, 36, 36));
        Assert.Equal((72f, 0f),  UiItemList.CellOffset(2, 3, 36, 36));
        Assert.Equal((36f, 36f), UiItemList.CellOffset(4, 3, 36, 36)); // col 1, row 1
    }

    [Fact]
    public void GridMode_PositionsCellsInColumns()
    {
        var list = new UiItemList { Columns = 3, CellWidth = 36, CellHeight = 36 };
        list.Flush();                                   // drop the ctor's default cell
        for (int i = 0; i < 7; i++) list.AddItem(new UiItemSlot());

        var c4 = list.GetItem(4)!;
        Assert.Equal(36f, c4.Left);
        Assert.Equal(36f, c4.Top);
        Assert.Equal(36f, c4.Width);
        Assert.Equal(36f, c4.Height);
    }

    [Fact]
    public void FillMode_SizesSingleCellToList()
    {
        // CellWidth defaults to 0 = "fill the list" (single-cell toolbar legacy).
        var list = new UiItemList { Width = 36, Height = 36 };
        list.Flush();
        list.AddItem(new UiItemSlot());

        var c = list.Cell;
        Assert.Equal(0f, c.Left);
        Assert.Equal(0f, c.Top);
        Assert.Equal(36f, c.Width);
        Assert.Equal(36f, c.Height);
    }
}
  • Step 2: Run the tests to verify they fail

Run: dotnet test tests/AcDream.App.Tests/AcDream.App.Tests.csproj --filter "FullyQualifiedName~UiItemListGridTests" Expected: FAIL — compile error, 'UiItemList' does not contain a definition for 'Columns' / 'CellOffset'.

  • Step 3: Implement grid mode

In src/AcDream.App/UI/UiItemList.cs, replace the AddItem method and the OnDraw method:

    public void AddItem(UiItemSlot cell)
    {
        cell.SpriteResolve ??= SpriteResolve;
        _cells.Add(cell);
        AddChild(cell);
        LayoutCells();
    }
    protected override void OnDraw(UiRenderContext ctx)
    {
        // The factory sets Width/Height AFTER construction, so re-layout each frame:
        // fill mode keeps the single toolbar cell sized to the list; grid mode tiles cells.
        LayoutCells();
    }

Then add these members to the class (e.g. after the Cell property):

    /// <summary>Grid columns (row-major). 1 = single column. Ignored in fill mode.</summary>
    public int Columns { get; set; } = 1;

    /// <summary>Fixed cell width in grid mode. 0 = "fill the list" — the single cell sizes
    /// to the whole list (the toolbar single-slot legacy). Set &gt;0 (with CellHeight) for a grid.</summary>
    public float CellWidth { get; set; }
    /// <summary>Fixed cell height in grid mode (pairs with CellWidth).</summary>
    public float CellHeight { get; set; }

    /// <summary>Row-major pixel offset of cell <paramref name="index"/> in a grid of
    /// <paramref name="columns"/> columns at the given cell pitch.</summary>
    internal static (float x, float y) CellOffset(int index, int columns, float cellW, float cellH)
    {
        int col = index % columns, row = index / columns;
        return (col * cellW, row * cellH);
    }

    /// <summary>Position every cell per the current mode: fill (CellWidth&lt;=0) sizes the single
    /// cell to the list; grid (CellWidth&gt;0) tiles cells row-major at the cell pitch.</summary>
    private void LayoutCells()
    {
        if (CellWidth <= 0f)
        {
            if (_cells.Count > 0)
            {
                var c = _cells[0];
                c.Left = 0; c.Top = 0; c.Width = Width; c.Height = Height;
            }
            return;
        }
        int cols = Columns < 1 ? 1 : Columns;
        for (int i = 0; i < _cells.Count; i++)
        {
            var (x, y) = CellOffset(i, cols, CellWidth, CellHeight);
            var cell = _cells[i];
            cell.Left = x; cell.Top = y; cell.Width = CellWidth; cell.Height = CellHeight;
        }
    }
  • Step 4: Run the tests to verify they pass

Run: dotnet test tests/AcDream.App.Tests/AcDream.App.Tests.csproj --filter "FullyQualifiedName~UiItemListGridTests" Expected: PASS (3 tests).

  • Step 5: Commit
git add src/AcDream.App/UI/UiItemList.cs tests/AcDream.App.Tests/UI/UiItemListGridTests.cs
git commit -m "feat(ui): D.2b-B — UiItemList N-cell grid mode

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>"

Task 2: Sub-window mount in LayoutImporter.Resolve

Files:

  • Modify: src/AcDream.App/UI/Layout/LayoutImporter.cs (add ShouldMountBaseChildren; restructure Resolve)

  • Test: tests/AcDream.App.Tests/UI/LayoutImporterMountTests.cs (create)

  • Step 1: Write the failing tests

Create tests/AcDream.App.Tests/UI/LayoutImporterMountTests.cs:

using AcDream.App.UI.Layout;

namespace AcDream.App.Tests.UI;

public class LayoutImporterMountTests
{
    [Fact]
    public void Mounts_ChildlessMediaLessInheritor_WithContentfulBase()
        => Assert.True(LayoutImporter.ShouldMountBaseChildren(derivedChildCount: 0, derivedMediaCount: 0, baseChildCount: 5));

    [Fact]
    public void DoesNotMount_WhenDerivedHasOwnMedia()        // close button / title
        => Assert.False(LayoutImporter.ShouldMountBaseChildren(0, 1, 5));

    [Fact]
    public void DoesNotMount_WhenDerivedHasOwnChildren()
        => Assert.False(LayoutImporter.ShouldMountBaseChildren(2, 0, 5));

    [Fact]
    public void DoesNotMount_WhenBaseIsChildless()           // vitals/chat/toolbar style prototypes
        => Assert.False(LayoutImporter.ShouldMountBaseChildren(0, 0, 0));
}
  • Step 2: Run the tests to verify they fail

Run: dotnet test tests/AcDream.App.Tests/AcDream.App.Tests.csproj --filter "FullyQualifiedName~LayoutImporterMountTests" Expected: FAIL — compile error, 'LayoutImporter' does not contain a definition for 'ShouldMountBaseChildren'.

  • Step 3: Add the predicate

In src/AcDream.App/UI/Layout/LayoutImporter.cs, add this method just above the private Resolve method (after the Import method, in the "Inheritance resolution" region):

    /// <summary>True when a pure-container leaf should inherit its base's subtree (the
    /// gmInventoryUI sub-window mount): the derived element has no own children, no own
    /// state media, and the base resolved to ≥1 child. Inert for media-bearing inheritors
    /// (close button / title), elements with their own children, and childless style
    /// prototypes (vitals/chat/toolbar text).</summary>
    internal static bool ShouldMountBaseChildren(int derivedChildCount, int derivedMediaCount, int baseChildCount)
        => derivedChildCount == 0 && derivedMediaCount == 0 && baseChildCount > 0;
  • Step 4: Wire the mount into Resolve

In src/AcDream.App/UI/Layout/LayoutImporter.cs, replace the entire body of the private Resolve method:

    private static ElementInfo Resolve(
        DatCollection dats,
        ElementDesc d,
        HashSet<(uint layoutId, uint elementId)> baseChain)
    {
        // Read this element's own fields + media (no inheritance, no children yet).
        var self = ToInfo(d);
        var result = self;
        List<ElementInfo>? baseChildren = null;

        // Apply BaseElement / BaseLayoutId inheritance if present.
        if (d.BaseElement != 0 && d.BaseLayoutId != 0
            && baseChain.Add((d.BaseLayoutId, d.BaseElement)))
        {
            var baseLd   = dats.Get<LayoutDesc>(d.BaseLayoutId);
            var baseDesc = baseLd is null ? null : FindDesc(baseLd, d.BaseElement);
            if (baseDesc is not null)
            {
                // Recurse the base chain (already guarded by the HashSet add above).
                var baseInfo = Resolve(dats, baseDesc, baseChain);
                // Derived fields override the base; children are attached below.
                result = ElementReader.Merge(baseInfo, self);
                baseChildren = baseInfo.Children;   // capture for the sub-window mount
            }
        }

        // Resolve + attach children.  Each child gets a FRESH base-chain set:
        // the cycle guard is per-element, not shared across siblings.
        foreach (var kv in d.Children)
            result.Children.Add(Resolve(dats, kv.Value, new HashSet<(uint, uint)>()));

        // Sub-window mount: a pure-container leaf (no own children, no own media) that inherits
        // from a base WITH content attaches the base's subtree. Targets the gmInventoryUI panels
        // (0x100001CD/CE/CF — Type-0, media-less, childless, BaseLayoutId → a gm*UI window);
        // inert for media-bearing inheritors (close button/title) and childless style prototypes
        // (vitals/chat/toolbar). See ShouldMountBaseChildren + the B-Grid spec.
        if (baseChildren is not null
            && ShouldMountBaseChildren(d.Children.Count, self.StateMedia.Count, baseChildren.Count))
            result.Children.AddRange(baseChildren);

        return result;
    }
  • Step 5: Run the tests to verify they pass

Run: dotnet test tests/AcDream.App.Tests/AcDream.App.Tests.csproj --filter "FullyQualifiedName~LayoutImporterMountTests" Expected: PASS (4 tests).

  • Step 6: Commit
git add src/AcDream.App/UI/Layout/LayoutImporter.cs tests/AcDream.App.Tests/UI/LayoutImporterMountTests.cs
git commit -m "feat(ui): D.2b-B — sub-window mount (inheritor attaches base subtree)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>"

Task 3: Swap the placeholder for the real import (GameWindow)

No unit tests — GL-coupled, runs only with ACDREAM_RETAIL_UI=1; verified by build + the visual check in Task 4.

Files:

  • Modify: src/AcDream.App/Rendering/GameWindow.cs (the Sub-phase A placeholder block in _options.RetailUi)

  • Step 1: Replace the placeholder mount

In src/AcDream.App/Rendering/GameWindow.cs, find the Sub-phase A placeholder block:

            // Phase D.2b-A — placeholder inventory window. Starts HIDDEN; F12
            // (InputAction.ToggleInventoryPanel) reveals it via the UiRoot window
            // manager. Throwaway scaffolding: Sub-phase B replaces the body with the
            // real gmInventoryUI (0x21000023) nested layout, keeping the same name.
            var inventoryWindow = new AcDream.App.UI.UiNineSlicePanel(ResolveChrome)
            {
                Left = 220, Top = 120, Width = 320, Height = 400,
                Visible = false,
            };
            _uiHost.Root.AddChild(inventoryWindow);
            _uiHost.RegisterWindow(AcDream.App.UI.WindowNames.Inventory, inventoryWindow);
            Console.WriteLine("[D.2b-A] placeholder inventory window registered (F12 toggles).");

Replace it with the real import:

            // Phase D.2b-B — the real inventory window from LayoutDesc 0x21000023 (gmInventoryUI),
            // via the LayoutImporter. The sub-window mount pulls in the nested paperdoll/backpack/
            // 3D-items panels (Type-0 leaves inheriting BaseLayoutId 0x21000024/22/21). Starts
            // HIDDEN; F12 toggles it via the window manager. Position is the dat's own (X=500,Y=138).
            AcDream.App.UI.Layout.ImportedLayout? invLayout;
            lock (_datLock)
                invLayout = AcDream.App.UI.Layout.LayoutImporter.Import(
                    _dats!, 0x21000023u, ResolveChrome, vitalsDatFont);
            if (invLayout is not null)
            {
                var inventoryWindow = invLayout.Root;
                inventoryWindow.Visible = false;
                inventoryWindow.Anchors = AcDream.App.UI.AnchorEdges.None;   // user-positioned
                inventoryWindow.Draggable = true;
                _uiHost.Root.AddChild(inventoryWindow);
                _uiHost.RegisterWindow(AcDream.App.UI.WindowNames.Inventory, inventoryWindow);
                Console.WriteLine("[D.2b-B] retail inventory window from LayoutDesc importer (0x21000023).");
            }
            else Console.WriteLine("[D.2b-B] inventory: LayoutDesc 0x21000023 not found.");

(_dats, _datLock, ResolveChrome, and vitalsDatFont are all in scope in this block — the vitals/chat imports above use the same.)

  • Step 2: Build to verify it compiles

Run: dotnet build src/AcDream.App/AcDream.App.csproj -c Debug Expected: Build succeeded, 0 errors.

  • Step 3: Commit
git add src/AcDream.App/Rendering/GameWindow.cs
git commit -m "feat(ui): D.2b-B — F12 shows the real gmInventoryUI (0x21000023)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>"

Task 4: Regression guard + full verification

  • Step 1: Run the full test suite (regression guard)

Run: dotnet test Expected: PASS — full suite green (2757 baseline + 3 grid + 4 mount-predicate = ~2764). The existing LayoutImporter / vitals / chat / toolbar tests passing confirms the mount is inert for those windows (their bases are childless prototypes).

  • Step 2: Hand off for visual verification

Launch with ACDREAM_RETAIL_UI=1 (standard launch in CLAUDE.md) and confirm with the user:

  • F12 now shows the real nested inventory frame (outer chrome + backpack strip on the right + a 3D-items area + an empty paperdoll panel), not the blank placeholder.
  • F12 again hides it; clicking it raises it; it doesn't toggle while chat is focused (Sub-phase A behavior intact).
  • Vitals, chat, and toolbar look unchanged (the mount regression guard, visually).

Watch the launch log for [D.2b-B] retail inventory window from LayoutDesc importer (0x21000023). and the absence of importer errors.

Do NOT mark B-Grid done until the user confirms the visual.


Self-Review

Spec coverage:

  • Sub-window mount (spec §4) → Task 2 (ShouldMountBaseChildren + Resolve). ✓
  • UiItemList grid mode (spec §5) → Task 1. ✓
  • Placeholder → real import swap (spec §9) → Task 3. ✓
  • Grid + mount-predicate tests (spec §6) → Tasks 12. ✓
  • Regression guard, vitals/chat/toolbar unchanged (spec §6/§8) → Task 4 Step 1 (suite) + Step 2 (visual). ✓
  • No divergence row (spec §7) → nothing to add. ✓
  • Visual acceptance (spec §8) → Task 4 Step 2. ✓

Placeholder scan: No TBD/TODO/"handle edge cases"; every code step shows full code. ✓

Type consistency: Columns/CellWidth/CellHeight/CellOffset/LayoutCells (Task 1), ShouldMountBaseChildren(int,int,int) (Task 2 — same signature in predicate, test, and Resolve call site), LayoutImporter.Import(_dats, 0x21000023u, ResolveChrome, vitalsDatFont) (Task 3, matches the vitals/chat call shape). ✓