acdream/docs/superpowers/plans/2026-06-21-d2b-inventory-controller.md
Erik 5b3295ad13 docs(D.2b-B): InventoryController implementation plan (8 tasks, TDD)
Bite-sized TDD tasks: BurdenMath encumbrance ports (T1), SumCarriedBurden
fallback (T2), UiMeter vertical fill (T3), InventoryController bind+populate
(T4) + burden+captions (T5), GameWindow wiring (T6), divergence/docs (T7),
visual gate (T8). Self-reviewed against the spec; test helpers verified
against the shipped widget APIs.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-21 08:50:21 +02:00

43 KiB
Raw Blame History

D.2b B-Controller — Inventory Population 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 F12 inventory window show the player's live contents — populate the "Contents of Backpack" grid + the right-strip pack-selector from ClientObjectTable, drive the vertical burden meter, and render the Type-0 captions.

Architecture: A new InventoryController (the gm*UI::PostInit analogue) binds the imported gmInventoryUI (LayoutDesc 0x21000023) tree by element id, partitions GetContents(player) into loose items + side bags, fills the UiItemList grids (reusing B-Grid's grid mode), drives a faithful burden meter (one new vertical-fill path on UiMeter), and attaches caption UiText overlays. Pure AC math (encumbrance) lives in Core; the controller + widget live in App. Bound inline in GameWindow's existing inventory-init block.

Tech Stack: C# .NET 10, xUnit, the shipped D.2b toolkit (UiItemList/UiItemSlot/UiMeter/UiText/ImportedLayout/IconComposer).

Spec: docs/superpowers/specs/2026-06-21-d2b-inventory-controller-design.md


File Structure

File Responsibility Create/Modify
src/AcDream.Core/Items/ClientObject.cs (BurdenMath) Encumbrance math (capacity, load ratio, fill, percent) — ports of EncumbranceSystem + SetLoadLevel Modify
src/AcDream.Core/Items/ClientObjectTable.cs SumCarriedBurden(owner) — client-side carried-Burden fallback Modify
src/AcDream.App/UI/UiMeter.cs Vertical fill path (the burden bar is vertical) Modify
src/AcDream.App/UI/Layout/InventoryController.cs The controller: bind by id, populate grids, drive burden, captions Create
src/AcDream.App/Rendering/GameWindow.cs Wire InventoryController.Bind into the inventory-init block Modify (~2149)
tests/AcDream.Core.Tests/Items/BurdenMathTests.cs Encumbrance golden values Create
tests/AcDream.Core.Tests/Items/ClientObjectTableBurdenTests.cs SumCarriedBurden Create
tests/AcDream.App.Tests/UI/UiMeterVerticalTests.cs Vertical fill rect Create
tests/AcDream.App.Tests/UI/Layout/InventoryControllerTests.cs Bind + populate + partition + burden + captions Create
docs/architecture/retail-divergence-register.md New AP rows Modify

Reference (read before coding): spec §1 (element id table), §4 (burden formula + decomp anchors), and the live code: ToolbarController.cs (controller pattern), SelectedObjectController.cs (UiText-attach pattern), UiItemList.cs (grid mode), DatWidgetFactory.BuildMeter (meter sprite assignment — already correct for the burden meter).


Task 1: Encumbrance math (BurdenMath)

Port EncumbranceSystem::EncumbranceCapacity (decomp 256393, 0x004fcc00), EncumbranceSystem::Load (256413, 0x004fcc40), and the gmBackpackUI::SetLoadLevel fill/percent transform (176536, 0x004a6ea0). BurdenMath already has BurdenPerStrength = 150 and ComputeMax.

Files:

  • Modify: src/AcDream.Core/Items/ClientObject.cs (the BurdenMath static class, ~line 215)

  • Test: tests/AcDream.Core.Tests/Items/BurdenMathTests.cs

  • Step 1: Write the failing test

Create tests/AcDream.Core.Tests/Items/BurdenMathTests.cs:

using AcDream.Core.Items;
using Xunit;

namespace AcDream.Core.Tests.Items;

public class BurdenMathTests
{
    [Theory]
    [InlineData(100, 0, 15000)]   // base: str*150
    [InlineData(100, 3, 24000)]   // +clamp(3*30,0,150)=90 -> +90*100
    [InlineData(100, 10, 30000)]  // 10*30=300 clamped to 150 -> +150*100
    [InlineData(0, 5, 0)]         // str<=0 -> 0
    public void EncumbranceCapacity_matches_retail(int str, int aug, int expected)
        => Assert.Equal(expected, BurdenMath.EncumbranceCapacity(str, aug));

    [Theory]
    [InlineData(15000, 7500, 0.5f)]
    [InlineData(15000, 0, 0f)]
    [InlineData(0, 100, 0f)]      // cap<=0 guard
    public void LoadRatio_is_burden_over_capacity(int cap, int burden, float expected)
        => Assert.Equal(expected, BurdenMath.LoadRatio(cap, burden), 4);

    [Theory]
    [InlineData(0f, 0f)]
    [InlineData(0.5f, 0.16667f)]
    [InlineData(1.0f, 0.33333f)]
    [InlineData(3.0f, 1.0f)]      // full at 300%
    [InlineData(4.0f, 1.0f)]      // clamped
    public void LoadToFill_is_third_clamped(float load, float expected)
        => Assert.Equal(expected, BurdenMath.LoadToFill(load), 4);

    [Theory]
    [InlineData(0f, 0)]
    [InlineData(0.5f, 50)]
    [InlineData(1.0f, 100)]
    [InlineData(3.0f, 300)]
    [InlineData(4.0f, 400)]       // percent NOT clamped
    public void LoadToPercent_is_floor_load_times_100(float load, int expected)
        => Assert.Equal(expected, BurdenMath.LoadToPercent(load));
}
  • Step 2: Run test to verify it fails

Run: dotnet test tests/AcDream.Core.Tests/AcDream.Core.Tests.csproj --filter "FullyQualifiedName~BurdenMathTests" Expected: FAIL — BurdenMath has no EncumbranceCapacity/LoadRatio/LoadToFill/LoadToPercent (compile error).

  • Step 3: Add the methods to BurdenMath

In src/AcDream.Core/Items/ClientObject.cs, inside public static class BurdenMath, add (keep existing members):

    /// <summary>Augmentation bonus-burden per aug rank (retail EncumbranceCapacity, 0x1e).</summary>
    public const int AugBurdenPerRank = 30;
    /// <summary>Max augmentation bonus-burden contribution per Strength (retail clamp 0x96).</summary>
    public const int AugBurdenCap = 150;

    /// <summary>
    /// Retail <c>EncumbranceSystem::EncumbranceCapacity(strength, aug)</c>
    /// (named-retail decomp 256393, <c>0x004fcc00</c>):
    /// <c>strength &lt;= 0 ? 0 : strength*150 + clamp(aug*30, 0, 150)*strength</c>.
    /// </summary>
    public static int EncumbranceCapacity(int strength, int aug)
    {
        if (strength <= 0) return 0;
        int bonus = aug * AugBurdenPerRank;
        if (bonus < 0) bonus = 0;                  // decomp: eax_2 < 0 -> base only
        if (bonus > AugBurdenCap) bonus = AugBurdenCap;
        return strength * BurdenPerStrength + bonus * strength;
    }

    /// <summary>
    /// Retail <c>EncumbranceSystem::Load(capacity, burden)</c> (decomp 256413,
    /// <c>0x004fcc40</c>): the encumbrance ratio <c>burden / capacity</c>
    /// (1.0 = at capacity, up to ~3.0). The decompiler mangled the FP divide to
    /// <c>return burden</c>; the <c>cap &lt;= 0</c> guard + single divide is unambiguous.
    /// Returns 0 when capacity &lt;= 0 (no-data).
    /// </summary>
    public static float LoadRatio(int capacity, int burden)
        => capacity <= 0 ? 0f : (float)burden / capacity;

    /// <summary>
    /// Retail <c>gmBackpackUI::SetLoadLevel</c> bar fill (decomp 176542,
    /// <c>0x004a6ea6</c>): <c>clamp(load * 0.3333…, 0, 1)</c> — the bar is 1/3 full at
    /// 100% capacity, full at 300%.
    /// </summary>
    public static float LoadToFill(float load)
    {
        float fill = load / 3f;
        if (fill < 0f) return 0f;
        return fill > 1f ? 1f : fill;
    }

    /// <summary>
    /// Retail <c>gmBackpackUI::SetLoadLevel</c> percent text (decomp 176576): after
    /// <c>arg2 = load/3</c>, the text is <c>floor(arg2 * 300) = floor(load * 100)</c>,
    /// NOT clamped (reads up to 300%+).
    /// </summary>
    public static int LoadToPercent(float load)
        => (int)System.MathF.Floor(load * 100f);
  • Step 4: Run test to verify it passes

Run: dotnet test tests/AcDream.Core.Tests/AcDream.Core.Tests.csproj --filter "FullyQualifiedName~BurdenMathTests" Expected: PASS (12 cases).

  • Step 5: Commit
git add src/AcDream.Core/Items/ClientObject.cs tests/AcDream.Core.Tests/Items/BurdenMathTests.cs
git commit -m "feat(core): D.2b-B — port EncumbranceSystem capacity/load + SetLoadLevel fill/percent

EncumbranceCapacity (decomp 0x004fcc00), Load (0x004fcc40), SetLoadLevel
fill=load/3 clamped + percent=floor(load*100) (0x004a6ea0). Golden tests."

Task 2: Carried-burden fallback (ClientObjectTable.SumCarriedBurden)

acdream does not track the player's wire EncumbranceVal yet, so the controller falls back to summing carried Burden. (Divergence row in Task 7.)

Files:

  • Modify: src/AcDream.Core/Items/ClientObjectTable.cs

  • Test: tests/AcDream.Core.Tests/Items/ClientObjectTableBurdenTests.cs

  • Step 1: Write the failing test

Create tests/AcDream.Core.Tests/Items/ClientObjectTableBurdenTests.cs:

using AcDream.Core.Items;
using Xunit;

namespace AcDream.Core.Tests.Items;

public class ClientObjectTableBurdenTests
{
    private const uint Player = 0x50000001u;

    [Fact]
    public void SumCarriedBurden_sums_pack_sidebag_and_wielded_but_not_unrelated()
    {
        var t = new ClientObjectTable();
        // loose pack item
        t.AddOrUpdate(new ClientObject { ObjectId = 0xA, ContainerId = Player, Burden = 100 });
        // side bag in pack
        t.AddOrUpdate(new ClientObject { ObjectId = 0xB, ContainerId = Player, Burden = 50,
            Type = ItemType.Container });
        // item inside the side bag (2-deep)
        t.AddOrUpdate(new ClientObject { ObjectId = 0xC, ContainerId = 0xB, Burden = 30 });
        // wielded (ContainerId 0, WielderId = player)
        t.AddOrUpdate(new ClientObject { ObjectId = 0xD, WielderId = Player, Burden = 200 });
        // unrelated object in the world
        t.AddOrUpdate(new ClientObject { ObjectId = 0xE, ContainerId = 0, Burden = 999 });

        Assert.Equal(380, t.SumCarriedBurden(Player));   // 100+50+30+200
    }

    [Fact]
    public void SumCarriedBurden_unknown_owner_is_zero()
        => Assert.Equal(0, new ClientObjectTable().SumCarriedBurden(Player));
}
  • Step 2: Run test to verify it fails

Run: dotnet test tests/AcDream.Core.Tests/AcDream.Core.Tests.csproj --filter "FullyQualifiedName~ClientObjectTableBurdenTests" Expected: FAIL — no SumCarriedBurden.

  • Step 3: Add SumCarriedBurden to ClientObjectTable

In src/AcDream.Core/Items/ClientObjectTable.cs, add (near GetContents):

    /// <summary>
    /// Σ Burden over every object carried by <paramref name="ownerGuid"/>: items
    /// whose container chain roots at the owner (pack + side-bag contents, retail
    /// hierarchy is 2-deep) plus items wielded by the owner. The client-side
    /// equivalent of the server's <c>EncumbranceVal</c> (PropertyInt 5) — used by
    /// the inventory burden bar until the wire value is parsed (B-Wire). The hop
    /// cap (8) is a cycle guard; real chains are ≤2.
    /// </summary>
    public int SumCarriedBurden(uint ownerGuid)
    {
        int total = 0;
        foreach (var o in _objects.Values)
            if (IsCarriedBy(o, ownerGuid))
                total += o.Burden;
        return total;
    }

    private bool IsCarriedBy(ClientObject o, uint ownerGuid)
    {
        if (o.WielderId == ownerGuid) return true;
        uint c = o.ContainerId;
        for (int hops = 0; c != 0 && hops < 8; hops++)
        {
            if (c == ownerGuid) return true;
            c = _objects.TryGetValue(c, out var parent) ? parent.ContainerId : 0u;
        }
        return false;
    }
  • Step 4: Run test to verify it passes

Run: dotnet test tests/AcDream.Core.Tests/AcDream.Core.Tests.csproj --filter "FullyQualifiedName~ClientObjectTableBurdenTests" Expected: PASS (2 cases).

  • Step 5: Commit
git add src/AcDream.Core/Items/ClientObjectTable.cs tests/AcDream.Core.Tests/Items/ClientObjectTableBurdenTests.cs
git commit -m "feat(core): D.2b-B — ClientObjectTable.SumCarriedBurden (carried-Burden fallback)"

Task 3: UiMeter vertical fill

The burden bar (0x100001D9, 11×58) is vertical; UiMeter is horizontal-only. Per UIElement_Meter::DrawChildren (decomp 123574) the meter draws its own DirectState (BackTile) as the track + the child (FrontTile) clipped to the fill fraction along m_eDirection. BuildMeter's single-image assignment is already correct (meter-own→BackTile/track, child→FrontTile/fill); this task only adds the vertical clip.

Files:

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

  • Test: tests/AcDream.App.Tests/UI/UiMeterVerticalTests.cs

  • Step 1: Write the failing test

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

using AcDream.App.UI;
using Xunit;

namespace AcDream.App.Tests.UI;

public class UiMeterVerticalTests
{
    [Fact]
    public void Vertical_fill_from_bottom_occupies_lower_fraction()
    {
        // 11x58 bar, 50% → fill rect is the bottom 29px.
        var (x, y, w, h) = UiMeter.ComputeVFillRect(0.5f, 11f, 58f, fromBottom: true);
        Assert.Equal(0f, x);
        Assert.Equal(29f, y, 3);    // h - h*p = 58 - 29
        Assert.Equal(11f, w);
        Assert.Equal(29f, h, 3);
    }

    [Fact]
    public void Vertical_fill_from_top_starts_at_zero()
    {
        var (_, y, _, h) = UiMeter.ComputeVFillRect(0.25f, 11f, 58f, fromBottom: false);
        Assert.Equal(0f, y);
        Assert.Equal(14.5f, h, 3);
    }

    [Theory]
    [InlineData(-1f, 0f)]
    [InlineData(2f, 58f)]
    public void Vertical_fill_clamps_fraction(float pct, float expectedH)
    {
        var (_, _, _, h) = UiMeter.ComputeVFillRect(pct, 11f, 58f, fromBottom: true);
        Assert.Equal(expectedH, h, 3);
    }
}
  • Step 2: Run test to verify it fails

Run: dotnet test tests/AcDream.App.Tests/AcDream.App.Tests.csproj --filter "FullyQualifiedName~UiMeterVerticalTests" Expected: FAIL — no ComputeVFillRect.

  • Step 3: Add the vertical path to UiMeter

In src/AcDream.App/UI/UiMeter.cs:

(a) Add properties after the sprite-id block (after FrontRight):

    /// <summary>Vertical orientation (retail <c>m_eDirection</c> 2/4). Default false =
    /// horizontal (direction 1). The burden bar is the only vertical meter today.</summary>
    public bool Vertical { get; set; }

    /// <summary>For a vertical meter, fill grows from the bottom up (retail direction 4)
    /// when true, top-down (direction 2) when false. Default bottom-up. Visual-confirmed.</summary>
    public bool FillFromBottom { get; set; } = true;

(b) Add the testable rect helper next to ComputeFillRect:

    /// <summary>Clamp <paramref name="pct"/> to [0,1] and return the vertical fill rect
    /// (local px). <paramref name="fromBottom"/> true → the fill occupies the bottom
    /// <c>h*pct</c> px (retail direction 4); false → the top (direction 2).</summary>
    public static (float x, float y, float w, float h) ComputeVFillRect(
        float pct, float w, float h, bool fromBottom)
    {
        if (pct < 0f) pct = 0f;
        if (pct > 1f) pct = 1f;
        float fh = h * pct;
        return (0f, fromBottom ? h - fh : 0f, w, fh);
    }

(c) In OnDraw, replace the sprite branch (the if (SpriteResolve is { } resolve && (...)) block) so it routes vertical meters to a vertical draw. Change:

        if (SpriteResolve is { } resolve && (BackLeft != 0 || BackTile != 0 || FrontTile != 0))
        {
            DrawHBar(ctx, resolve, BackLeft, BackTile, BackRight, Width);
            if (pct is not null && p > 0f)
                DrawHBar(ctx, resolve, FrontLeft, FrontTile, FrontRight, Width * p);
        }

to:

        if (SpriteResolve is { } resolve && (BackLeft != 0 || BackTile != 0 || FrontTile != 0))
        {
            if (Vertical)
            {
                // Track full height, fill clipped to the fraction along the fill direction.
                // The burden meter is a single-tile bar (BackTile/FrontTile, no caps).
                DrawVBar(ctx, resolve, BackTile, Height, fromBottom: FillFromBottom, isFill: false);
                if (pct is not null && p > 0f)
                    DrawVBar(ctx, resolve, FrontTile, Height * p, fromBottom: FillFromBottom, isFill: true);
            }
            else
            {
                DrawHBar(ctx, resolve, BackLeft, BackTile, BackRight, Width);
                if (pct is not null && p > 0f)
                    DrawHBar(ctx, resolve, FrontLeft, FrontTile, FrontRight, Width * p);
            }
        }

(d) Add DrawVBar after DrawHBar:

    /// <summary>Draws a single-tile vertical bar slice. The track (<paramref name="isFill"/>
    /// false) spans the full height; the fill spans <paramref name="visibleH"/> px from the
    /// bottom (<paramref name="fromBottom"/>) or top, UV-cropped to that fraction of the
    /// sprite so the fill reveals the matching part of the art (retail
    /// UIElement_Meter::DrawChildren Box2D clip, direction 2/4). A 0 id is a no-op.</summary>
    private void DrawVBar(UiRenderContext ctx, Func<uint, (uint tex, int w, int h)> resolve,
        uint tileId, float visibleH, bool fromBottom, bool isFill)
    {
        if (tileId == 0 || visibleH <= 0f) return;
        var (tex, _, _) = resolve(tileId);
        if (tex == 0) return;
        float w = Width, h = Height;
        if (visibleH > h) visibleH = h;
        float frac = h > 0f ? visibleH / h : 0f;
        // Bottom-up fill: bottom of the rect AND bottom of the sprite (v in [1-frac, 1]).
        // Top-down / track: top of the rect AND top of the sprite (v in [0, frac]).
        float y  = isFill && fromBottom ? h - visibleH : 0f;
        float v0 = isFill && fromBottom ? 1f - frac : 0f;
        float v1 = isFill && fromBottom ? 1f : frac;
        ctx.DrawSprite(tex, 0f, y, w, visibleH, 0f, v0, 1f, v1, System.Numerics.Vector4.One);
    }

(Note: track always draws full height → frac=1, v0=0, v1=1. Good.)

  • Step 4: Run test to verify it passes

Run: dotnet test tests/AcDream.App.Tests/AcDream.App.Tests.csproj --filter "FullyQualifiedName~UiMeterVerticalTests" Expected: PASS (4 cases). Also confirm the existing meter tests still pass: Run: dotnet test tests/AcDream.App.Tests/AcDream.App.Tests.csproj --filter "FullyQualifiedName~UiMeter" Expected: PASS.

  • Step 5: Commit
git add src/AcDream.App/UI/UiMeter.cs tests/AcDream.App.Tests/UI/UiMeterVerticalTests.cs
git commit -m "feat(ui): D.2b-B — UiMeter vertical fill (burden bar, retail m_eDirection 2/4)"

Task 4: InventoryController — bind + grid population

The controller skeleton: find-by-id bind, partition GetContents(player), populate the 3D-items grid + side-bag list + main-pack cell. (Burden + captions in Task 5.)

Files:

  • Create: src/AcDream.App/UI/Layout/InventoryController.cs

  • Test: tests/AcDream.App.Tests/UI/Layout/InventoryControllerTests.cs

  • Step 1: Write the failing test

Create tests/AcDream.App.Tests/UI/Layout/InventoryControllerTests.cs. The helper builds a dat-free ImportedLayout from constructed widgets (constructor is public):

using System.Collections.Generic;
using AcDream.App.UI;
using AcDream.App.UI.Layout;
using AcDream.Core.Items;
using Xunit;

namespace AcDream.App.Tests.UI.Layout;

public class InventoryControllerTests
{
    private const uint Player = 0x50000001u;

    // UiElement is abstract — a concrete, parameterless stand-in for the root + caption hosts
    // (the real captions are Type-0 → UiDatElement; in tests we only need a host that holds children).
    private sealed class TestElement : UiElement { }

    // Element ids (spec §1).
    private const uint ContentsGrid = 0x100001C6u;
    private const uint ContainerList = 0x100001CAu;
    private const uint TopContainer = 0x100001C9u;
    private const uint BurdenMeter = 0x100001D9u;
    private const uint BurdenText  = 0x100001D8u;
    private const uint BurdenCaption = 0x100001D7u;
    private const uint ContentsCaption = 0x100001C5u;

    private static (ImportedLayout layout, UiItemList grid, UiItemList containers,
                    UiItemList top, UiMeter meter, UiElement burdenText,
                    UiElement burdenCap, UiElement contentsCap) BuildLayout()
    {
        var grid       = new UiItemList { Width = 192, Height = 96 };
        var containers = new UiItemList { Width = 36,  Height = 252 };
        var top        = new UiItemList { Width = 36,  Height = 36 };
        var meter      = new UiMeter    { Width = 11,  Height = 58 };
        var burdenText = new TestElement { Width = 36,  Height = 15 };
        var burdenCap  = new TestElement { Width = 36,  Height = 15 };
        var contentsCap= new TestElement { Width = 192, Height = 15 };
        var root       = new TestElement { Width = 300, Height = 362 };
        root.AddChild(grid); root.AddChild(containers); root.AddChild(top);
        root.AddChild(meter); root.AddChild(burdenText); root.AddChild(burdenCap);
        root.AddChild(contentsCap);
        var byId = new Dictionary<uint, UiElement>
        {
            [ContentsGrid] = grid, [ContainerList] = containers, [TopContainer] = top,
            [BurdenMeter] = meter, [BurdenText] = burdenText, [BurdenCaption] = burdenCap,
            [ContentsCaption] = contentsCap,
        };
        return (new ImportedLayout(root, byId), grid, containers, top, meter,
                burdenText, burdenCap, contentsCap);
    }

    private static InventoryController Bind(ImportedLayout layout, ClientObjectTable objects,
        int? strength = 100)
        => InventoryController.Bind(layout, objects, () => Player,
            iconIds: (_, _, _, _, _) => 0u,        // no real GL upload in tests
            strength: () => strength, datFont: null);

    [Fact]
    public void Populate_fills_contents_grid_with_loose_items_only()
    {
        var (layout, grid, containers, _, _, _, _, _) = BuildLayout();
        var objects = new ClientObjectTable();
        objects.AddOrUpdate(new ClientObject { ObjectId = 0xA, ContainerId = Player,
            ContainerSlot = 0, Burden = 10 });                                   // loose
        objects.AddOrUpdate(new ClientObject { ObjectId = 0xB, ContainerId = Player,
            ContainerSlot = 1, Burden = 20 });                                   // loose
        objects.AddOrUpdate(new ClientObject { ObjectId = 0xC, ContainerId = Player,
            ContainerSlot = 2, Type = ItemType.Container });                     // side bag

        Bind(layout, objects);

        Assert.Equal(2, grid.GetNumUIItems());          // 2 loose
        Assert.Equal(0xAu, grid.GetItem(0)!.ItemId);
        Assert.Equal(0xBu, grid.GetItem(1)!.ItemId);
        Assert.Equal(1, containers.GetNumUIItems());     // 1 side bag
        Assert.Equal(0xCu, containers.GetItem(0)!.ItemId);
    }

    [Fact]
    public void Grid_is_six_columns_thirtytwo_px()
    {
        var (layout, grid, _, _, _, _, _, _) = BuildLayout();
        Bind(layout, new ClientObjectTable());
        Assert.Equal(6, grid.Columns);
        Assert.Equal(32f, grid.CellWidth);
        Assert.Equal(32f, grid.CellHeight);
    }

    [Fact]
    public void ObjectAdded_for_player_item_rebuilds_grid()
    {
        var (layout, grid, _, _, _, _, _, _) = BuildLayout();
        var objects = new ClientObjectTable();
        Bind(layout, objects);
        Assert.Equal(0, grid.GetNumUIItems());

        objects.Ingest(new WeenieData(0xA, "Sword", ItemType.MeleeWeapon, 1, 0, 0, 0, 0,
            null, null, null, 5, Player, null, null, null, null, null, null, null, null, null));

        Assert.Equal(1, grid.GetNumUIItems());
        Assert.Equal(0xAu, grid.GetItem(0)!.ItemId);
    }
}
  • Step 2: Run test to verify it fails

Run: dotnet test tests/AcDream.App.Tests/AcDream.App.Tests.csproj --filter "FullyQualifiedName~InventoryControllerTests" Expected: FAIL — no InventoryController.

  • Step 3: Create InventoryController (bind + populate)

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

using System;
using AcDream.Core.Items;

namespace AcDream.App.UI.Layout;

/// <summary>
/// Binds the imported gmInventoryUI tree (LayoutDesc 0x21000023) and populates it from
/// <see cref="ClientObjectTable"/>. The acdream analogue of retail
/// gmInventoryUI/gmBackpackUI/gm3DItemsUI ::PostInit (named-retail decomp 176236/176596/176728).
/// Read-only: no container switching / drag / wield-drop wire (later sub-phases).
/// </summary>
public sealed class InventoryController
{
    // Element ids — spec §1 (dat dump of 0x21000022 / 0x21000021 + the *::PostInit binds).
    public const uint ContentsGridId   = 0x100001C6u;  // gm3DItemsUI m_itemList ("Contents of Backpack")
    public const uint ContainerListId  = 0x100001CAu;  // gmBackpackUI m_containerList (side-bag selector)
    public const uint TopContainerId   = 0x100001C9u;  // gmBackpackUI m_topContainer (main-pack cell)
    public const uint BurdenMeterId    = 0x100001D9u;  // gmBackpackUI m_burdenMeter (vertical)
    public const uint BurdenTextId     = 0x100001D8u;  // gmBackpackUI m_burdenText ("%d%%")
    public const uint BurdenCaptionId  = 0x100001D7u;  // "Burden"
    public const uint ContentsCaptionId= 0x100001C5u;  // "Contents of Backpack"

    // 3D-items grid: 192x96 → 6 cols x 3 rows of the 32x32 UIItem cell (template 0x21000037).
    private const int  ContentsColumns = 6;
    private const float CellPx = 32f;

    private readonly ClientObjectTable _objects;
    private readonly Func<uint> _playerGuid;
    private readonly Func<ItemType, uint, uint, uint, uint, uint> _iconIds;

    private readonly UiItemList? _contentsGrid;
    private readonly UiItemList? _containerList;
    private readonly UiItemList? _topContainer;

    private InventoryController(
        ImportedLayout layout,
        ClientObjectTable objects,
        Func<uint> playerGuid,
        Func<ItemType, uint, uint, uint, uint, uint> iconIds,
        Func<int?> strength,
        UiDatFont? datFont)
    {
        _objects    = objects;
        _playerGuid = playerGuid;
        _iconIds    = iconIds;

        _contentsGrid  = layout.FindElement(ContentsGridId)  as UiItemList;
        _containerList = layout.FindElement(ContainerListId) as UiItemList;
        _topContainer  = layout.FindElement(TopContainerId)  as UiItemList;

        if (_contentsGrid is not null)
        {
            _contentsGrid.Columns    = ContentsColumns;
            _contentsGrid.CellWidth  = CellPx;
            _contentsGrid.CellHeight = CellPx;
        }
        if (_containerList is not null)
        {
            _containerList.Columns    = 1;
            _containerList.CellWidth  = CellPx;
            _containerList.CellHeight = CellPx;
        }

        // Burden + captions are bound in Task 5 (BindBurden / BindCaptions called here).

        // Rebuild on any change to the player's possessions.
        _objects.ObjectAdded   += OnObjectChanged;
        _objects.ObjectMoved   += OnObjectMoved;
        _objects.ObjectRemoved += OnObjectChanged;
        _objects.ObjectUpdated += OnObjectChanged;

        Populate();
    }

    public static InventoryController Bind(
        ImportedLayout layout,
        ClientObjectTable objects,
        Func<uint> playerGuid,
        Func<ItemType, uint, uint, uint, uint, uint> iconIds,
        Func<int?> strength,
        UiDatFont? datFont)
        => new InventoryController(layout, objects, playerGuid, iconIds, strength, datFont);

    private void OnObjectChanged(ClientObject o)        { if (Concerns(o)) Populate(); }
    private void OnObjectMoved(ClientObject o, uint from, uint to)
        { if (Concerns(o) || from == _playerGuid() || to == _playerGuid()) Populate(); }

    /// <summary>True if the object is in (or wielded by) the player — i.e. a rebuild is warranted.</summary>
    private bool Concerns(ClientObject o)
    {
        uint p = _playerGuid();
        return o.ContainerId == p || o.WielderId == p
            || (o.ContainerId != 0 && _objects.Get(o.ContainerId)?.ContainerId == p);  // 2-deep
    }

    /// <summary>Retail gm*UI display refresh: partition the player's direct contents into loose
    /// items (the "Contents of Backpack" grid) and side bags (the pack-selector list).</summary>
    public void Populate()
    {
        uint p = _playerGuid();
        var contents = _objects.GetContents(p);

        _contentsGrid?.Flush();
        _containerList?.Flush();

        foreach (var guid in contents)
        {
            var item = _objects.Get(guid);
            if (item is null) continue;
            bool isBag = item.Type.HasFlag(ItemType.Container) || item.ItemsCapacity > 0;
            var list = isBag ? _containerList : _contentsGrid;
            if (list is null) continue;
            uint tex = _iconIds(item.Type, item.IconId, item.IconUnderlayId,
                                item.IconOverlayId, item.Effects);
            var cell = new UiItemSlot { SpriteResolve = list.SpriteResolve };
            cell.SetItem(guid, tex);
            list.AddItem(cell);
        }

        // Main-pack cell: the player's own container. Icon = placeholder until a backpack
        // RenderSurface DID is pinned (spec §3 / divergence row). Bind the guid so a future
        // click can select it.
        if (_topContainer is not null)
        {
            _topContainer.Flush();
            var main = new UiItemSlot { SpriteResolve = _topContainer.SpriteResolve };
            main.SetItem(p, 0u);
            _topContainer.AddItem(main);
        }
    }

    /// <summary>Detach event handlers (idempotent).</summary>
    public void Dispose()
    {
        _objects.ObjectAdded   -= OnObjectChanged;
        _objects.ObjectMoved   -= OnObjectMoved;
        _objects.ObjectRemoved -= OnObjectChanged;
        _objects.ObjectUpdated -= OnObjectChanged;
    }
}
  • Step 4: Run test to verify it passes

Run: dotnet test tests/AcDream.App.Tests/AcDream.App.Tests.csproj --filter "FullyQualifiedName~InventoryControllerTests" Expected: PASS (3 cases). (Note: UiItemList is constructed with one default cell; Flush() then AddItem per item gives exact counts — the default cell is flushed away.)

  • 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-B — InventoryController bind + grid population (loose/side-bag partition)"

Task 5: InventoryController — burden meter + captions

Drive the vertical burden meter + % text + the two captions.

Files:

  • Modify: src/AcDream.App/UI/Layout/InventoryController.cs

  • Modify: tests/AcDream.App.Tests/UI/Layout/InventoryControllerTests.cs

  • Step 1: Write the failing test

Append to InventoryControllerTests.cs:

    [Fact]
    public void Burden_meter_fill_and_percent_from_load()
    {
        var (layout, _, _, _, meter, burdenText, _, _) = BuildLayout();
        var objects = new ClientObjectTable();
        // Str 100 → capacity 15000. Carried 7500 → load 0.5 → fill 0.1667, "50%".
        objects.AddOrUpdate(new ClientObject { ObjectId = 0xA, ContainerId = Player, Burden = 7500 });
        Bind(layout, objects, strength: 100);

        Assert.Equal(0.16667f, meter.Fill() ?? -1f, 3);
        Assert.True(meter.Vertical);
        // The % text caption child reads "50%".
        Assert.Contains("50%", CaptionText(burdenText));
    }

    [Fact]
    public void Captions_render_known_strings()
    {
        var (layout, _, _, _, _, _, burdenCap, contentsCap) = BuildLayout();
        Bind(layout, new ClientObjectTable());
        Assert.Contains("Burden", CaptionText(burdenCap));
        Assert.Contains("Contents of Backpack", CaptionText(contentsCap));
    }

    // Reads the text of the UiText caption child attached by the controller.
    private static string CaptionText(UiElement host)
    {
        foreach (var c in host.Children)
            if (c is UiText t)
            {
                var lines = t.LinesProvider();
                if (lines.Count > 0) return lines[0].Text;
            }
        return "";
    }

If UiElement.Children is not publicly enumerable, expose a read-only view or use the existing accessor the other controllers' tests use (check SelectedObjectController tests). The host.Children enumeration mirrors UiElement.AddChild usage in SelectedObjectController.

  • Step 2: Run test to verify it fails

Run: dotnet test tests/AcDream.App.Tests/AcDream.App.Tests.csproj --filter "FullyQualifiedName~InventoryControllerTests" Expected: FAIL — meter Fill not driven; no caption children.

  • Step 3: Add burden + captions to InventoryController

Add fields + wire into the constructor (after the grid setup, before Populate()), and the helper methods. Add using System.Numerics; and using System.Collections.Generic;.

Fields:

    private readonly Func<int?> _strength;
    private readonly UiMeter? _burdenMeter;
    private float _burdenFill;
    private int   _burdenPercent;
    private static readonly Vector4 CaptionColor = new(1f, 1f, 1f, 1f);

In the constructor, set _strength = strength;, then after the list setup:

        _burdenMeter = layout.FindElement(BurdenMeterId) as UiMeter;
        if (_burdenMeter is not null)
        {
            _burdenMeter.Vertical = true;            // 11x58 vertical bar
            _burdenMeter.FillFromBottom = true;      // retail direction 4 (visual-confirmed)
            _burdenMeter.Fill = () => _burdenFill;
        }

        // Captions: attach a centered UiText child carrying the known string. "Contents of
        // Backpack" + "%d%%" are procedural in retail (gm3DItemsUI/gmBackpackUI PostInit/
        // SetLoadLevel); "Burden" is the dat label. (Caption pass, spec §5.)
        AttachCaption(layout.FindElement(BurdenCaptionId),   () => "Burden", datFont);
        AttachCaption(layout.FindElement(ContentsCaptionId), () => "Contents of Backpack", datFont);
        AttachCaption(layout.FindElement(BurdenTextId),      () => _burdenPercent + "%", datFont);

        RefreshBurden();

Also subscribe to Strength changes — but LocalPlayerState lives in Core and the controller only gets Func<int?> strength; recompute burden on item events (already wired) is sufficient for the live path. (Strength rarely changes mid-session; the next item event refreshes it. A dedicated attribute subscription is wired at the GameWindow call site in Task 6 if needed.)

Add helpers:

    private void AttachCaption(UiElement? host, Func<string> text, UiDatFont? datFont)
    {
        if (host is null) return;
        var label = new UiText
        {
            Left = 0f, Top = 0f, Width = host.Width, Height = host.Height,
            Anchors = AnchorEdges.Left | AnchorEdges.Top | AnchorEdges.Right,
            Centered = true, DatFont = datFont, ClickThrough = true,
            AcceptsFocus = false, IsEditControl = false, CapturesPointerDrag = false,
            LinesProvider = () =>
            {
                var s = text();
                return string.IsNullOrEmpty(s)
                    ? System.Array.Empty<UiText.Line>()
                    : new[] { new UiText.Line(s, CaptionColor) };
            },
        };
        host.AddChild(label);
    }

    /// <summary>Recompute the burden fill + percent. Port of CACQualities::InqLoad
    /// (decomp 0x0058f130) → gmBackpackUI::SetLoadLevel (0x004a6ea0). currentBurden:
    /// player wire EncumbranceVal (PropertyInt 5) if present, else the carried-Burden sum.</summary>
    private void RefreshBurden()
    {
        uint p = _playerGuid();
        int str = _strength() ?? 10;                       // InqAttribute default 0xa
        int aug = _objects.Get(p)?.Properties.GetInt(EncumbranceAugProperty) ?? 0;
        int capacity = BurdenMath.EncumbranceCapacity(str, aug);

        int? wire = _objects.Get(p)?.Properties.Ints.TryGetValue(EncumbranceValProperty, out var ev) == true
            ? ev : (int?)null;
        int burden = wire ?? _objects.SumCarriedBurden(p);

        float load = BurdenMath.LoadRatio(capacity, burden);
        _burdenFill    = BurdenMath.LoadToFill(load);
        _burdenPercent = BurdenMath.LoadToPercent(load);
    }

    // ACE PropertyInt ids read by retail InqLoad (decomp 0x0058f130: InqInt(5)/InqInt(0xe6)).
    private const uint EncumbranceValProperty = 5u;    // total carried burden
    private const uint EncumbranceAugProperty = 0xE6u; // carry-capacity augmentation

Finally, call RefreshBurden() from Populate() (so item changes refresh the bar). Add at the end of Populate():

        RefreshBurden();
  • Step 4: Run test to verify it passes

Run: dotnet test tests/AcDream.App.Tests/AcDream.App.Tests.csproj --filter "FullyQualifiedName~InventoryControllerTests" Expected: PASS (5 cases). Run the whole App suite to confirm no regression: Run: dotnet test tests/AcDream.App.Tests/AcDream.App.Tests.csproj Expected: PASS.

  • 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-B — InventoryController burden meter (InqLoad port) + captions"

Task 6: Wire InventoryController into GameWindow

Files:

  • Modify: src/AcDream.App/Rendering/GameWindow.cs (field ~607; inventory-init block ~2149-2158)

  • Step 1: Add the controller field

Near the other UI controller fields (e.g. after _selectedObjectController; grep private … _selectedObjectController for the exact spot), add:

    private AcDream.App.UI.Layout.InventoryController? _inventoryController;
  • Step 2: Bind it in the inventory-init block

In GameWindow.cs, the block at ~2149 currently ends with RegisterWindow(...) + a Console.WriteLine. Insert the bind BEFORE the Console.WriteLine("[D.2b-B] retail inventory window …"), using the same iconComposer / vitalsDatFont / Objects already in scope (mirroring the toolbar bind at 2013):

                _uiHost.RegisterWindow(AcDream.App.UI.WindowNames.Inventory, inventoryWindow);

                // Phase D.2b-B — populate the inventory from ClientObjectTable: the
                // "Contents of Backpack" grid + the pack-selector strip + the burden meter +
                // captions. Analogue of gmInventoryUI/gmBackpackUI/gm3DItemsUI::PostInit.
                _inventoryController = AcDream.App.UI.Layout.InventoryController.Bind(
                    invLayout, Objects,
                    playerGuid: () => _playerServerGuid,
                    iconIds: (type, icon, under, over, effects) => iconComposer.GetIcon(type, icon, under, over, effects),
                    strength: () => (int?)LocalPlayer.GetAttribute(
                        AcDream.Core.Player.LocalPlayerState.AttributeKind.Strength)?.Current,
                    datFont: vitalsDatFont);

                Console.WriteLine("[D.2b-B] retail inventory window from LayoutDesc importer (0x21000023).");
  • Step 3: Build

Run: dotnet build src/AcDream.App/AcDream.App.csproj -c Debug Expected: Build succeeded. 0 Error(s).

  • Step 4: Full build + test

Run: dotnet build AcDream.slnx -c Debug then dotnet test AcDream.slnx -c Debug Expected: all green.

  • Step 5: Commit
git add src/AcDream.App/Rendering/GameWindow.cs
git commit -m "feat(ui): D.2b-B — wire InventoryController into the inventory-init block"

Task 7: Divergence register + roadmap/issues + memory

Files:

  • Modify: docs/architecture/retail-divergence-register.md

  • Modify: docs/ISSUES.md and/or docs/plans/2026-04-11-roadmap.md (if a row tracks B-Controller)

  • Step 1: Add divergence rows

Append rows to docs/architecture/retail-divergence-register.md (match the table's existing column format; use the next AP-NN ids):

  • AP — inventory burden currentBurden summed client-side (ClientObjectTable.SumCarriedBurden) when the player's wire EncumbranceVal (PropertyInt 5) is absent; retail reads the server value via CACQualities::InqLoad. Risk: drift from coins/untracked items. Retire when B-Wire parses EncumbranceVal. (InventoryController.RefreshBurden)

  • AP — capacity augmentation (PropertyInt 0xE6) not tracked → bonus=0 → un-augmented Str×150. Risk: augmented chars read slightly low. (BurdenMath.EncumbranceCapacity caller)

  • AP — burden meter orientation/direction set from geometry + default bottom-up (UiMeter.FillFromBottom=true) rather than retail's m_eDirection from LayoutDesc property 0x6f. Risk: wrong fill direction (visual-confirmed). (InventoryController / UiMeter.Vertical)

  • AP — main-pack m_topContainer cell uses placeholder/empty icon, not a weenie-driven icon (the main pack ≡ the player, no item IconId). Risk: blank main-pack cell. (InventoryController.Populate)

  • Step 2: Update ISSUES/roadmap

If docs/ISSUES.md has a B-Controller line, move it to "Recently closed" with the final commit SHA; note B-Wire / B-Drag / Sub-phase C remain open. Otherwise add a one-line "Recently closed" entry.

  • Step 3: Commit
git add docs/architecture/retail-divergence-register.md docs/ISSUES.md docs/plans/2026-04-11-roadmap.md
git commit -m "docs(D.2b-B): divergence rows + issues/roadmap for inventory population"

Task 8: Visual verification (acceptance gate — user)

Not a code task. Build green, launch with ACDREAM_RETAIL_UI=1, press F12, and confirm with the user:

  • Pack items show as icons in the "Contents of Backpack" grid (6 cols).
  • Side bags (if any) appear in the right strip; the main-pack cell is present.
  • The burden bar fills vertically with the right sprites; the fill direction (bottom-up) looks right; the % reads sanely.
  • The "Burden" + "Contents of Backpack" captions render.
  • No #145 z-order regression on chat + toolbar (the deferred eyeball — chat is provably safe; the toolbar now honors ZLevel).

Two visual-confirm unknowns to settle here (not guessed in code): the cell pitch (32px → 6 cols is the clean fit) and the fill direction (bottom-up = retail dir 4). If either is wrong, the fix is a one-line change (ContentsColumns/CellPx or FillFromBottom).

After the user confirms, update memory/project_d2b_retail_ui.md (or the claude-memory mirror) with a B-Controller "SHIPPED" note + the key facts (burden formula port; BuildMeter already-correct finding; the four AP rows).


Self-Review

Spec coverage: §1 element map → Task 4/5 ids. §2 architecture (controller, inline bind) → Task 4/6. §3 population (3D grid 6-col, side bags, top cell) → Task 4. §4 burden (formula + vertical + data source) → Task 1/3/5. §5 captions → Task 5. §6 divergence rows → Task 7. §7 testing (bind/populate/partition/burden golden/vertical/caption/rebuild) → Tasks 1-5. §8 visual → Task 8. §9 acceptance → all. ✓ No gaps.

Placeholder scan: All steps have concrete code/commands. The two deferred values (main-pack icon DID, property-0x6f read) are explicit, register-documented fallbacks, not placeholders. The one soft spot — UiElement.Children public enumeration in the Task 5 test — has an inline note to verify against the existing controller-test pattern. ✓

Type consistency: BurdenMath.EncumbranceCapacity/LoadRatio/LoadToFill/LoadToPercent (Task 1) used in Task 5 RefreshBurden. ClientObjectTable.SumCarriedBurden (Task 2) used in Task 5. UiMeter.Vertical/FillFromBottom/ComputeVFillRect (Task 3) used in Task 5 + Task 3 tests. InventoryController.Bind(layout, objects, playerGuid, iconIds, strength, datFont) signature identical in Task 4 def, Task 4/5 tests, Task 6 call site. UiItemSlot.SetItem(guid, tex) + UiItemList.Flush/AddItem/Columns/CellWidth/CellHeight/GetNumUIItems/GetItem match the shipped UiItemList.cs. ✓