diff --git a/docs/superpowers/plans/2026-06-21-d2b-inventory-controller.md b/docs/superpowers/plans/2026-06-21-d2b-inventory-controller.md
new file mode 100644
index 00000000..0194deee
--- /dev/null
+++ b/docs/superpowers/plans/2026-06-21-d2b-inventory-controller.md
@@ -0,0 +1,976 @@
+# 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`:
+
+```csharp
+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):
+
+```csharp
+ /// Augmentation bonus-burden per aug rank (retail EncumbranceCapacity, 0x1e).
+ public const int AugBurdenPerRank = 30;
+ /// Max augmentation bonus-burden contribution per Strength (retail clamp 0x96).
+ public const int AugBurdenCap = 150;
+
+ ///
+ /// Retail EncumbranceSystem::EncumbranceCapacity(strength, aug)
+ /// (named-retail decomp 256393, 0x004fcc00):
+ /// strength <= 0 ? 0 : strength*150 + clamp(aug*30, 0, 150)*strength.
+ ///
+ 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;
+ }
+
+ ///
+ /// Retail EncumbranceSystem::Load(capacity, burden) (decomp 256413,
+ /// 0x004fcc40): the encumbrance ratio burden / capacity
+ /// (1.0 = at capacity, up to ~3.0). The decompiler mangled the FP divide to
+ /// return burden; the cap <= 0 guard + single divide is unambiguous.
+ /// Returns 0 when capacity <= 0 (no-data).
+ ///
+ public static float LoadRatio(int capacity, int burden)
+ => capacity <= 0 ? 0f : (float)burden / capacity;
+
+ ///
+ /// Retail gmBackpackUI::SetLoadLevel bar fill (decomp 176542,
+ /// 0x004a6ea6): clamp(load * 0.3333…, 0, 1) — the bar is 1/3 full at
+ /// 100% capacity, full at 300%.
+ ///
+ public static float LoadToFill(float load)
+ {
+ float fill = load / 3f;
+ if (fill < 0f) return 0f;
+ return fill > 1f ? 1f : fill;
+ }
+
+ ///
+ /// Retail gmBackpackUI::SetLoadLevel percent text (decomp 176576): after
+ /// arg2 = load/3, the text is floor(arg2 * 300) = floor(load * 100),
+ /// NOT clamped (reads up to 300%+).
+ ///
+ 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**
+
+```bash
+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`:
+
+```csharp
+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`):
+
+```csharp
+ ///
+ /// Σ Burden over every object carried by : 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 EncumbranceVal (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.
+ ///
+ 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**
+
+```bash
+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`:
+
+```csharp
+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`):
+
+```csharp
+ /// Vertical orientation (retail m_eDirection 2/4). Default false =
+ /// horizontal (direction 1). The burden bar is the only vertical meter today.
+ public bool Vertical { get; set; }
+
+ /// 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.
+ public bool FillFromBottom { get; set; } = true;
+```
+
+(b) Add the testable rect helper next to `ComputeFillRect`:
+
+```csharp
+ /// Clamp to [0,1] and return the vertical fill rect
+ /// (local px). true → the fill occupies the bottom
+ /// h*pct px (retail direction 4); false → the top (direction 2).
+ 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:
+
+```csharp
+ 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:
+
+```csharp
+ 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`:
+
+```csharp
+ /// Draws a single-tile vertical bar slice. The track (
+ /// false) spans the full height; the fill spans px from the
+ /// bottom () 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.
+ private void DrawVBar(UiRenderContext ctx, Func 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**
+
+```bash
+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):
+
+```csharp
+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
+ {
+ [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`:
+
+```csharp
+using System;
+using AcDream.Core.Items;
+
+namespace AcDream.App.UI.Layout;
+
+///
+/// Binds the imported gmInventoryUI tree (LayoutDesc 0x21000023) and populates it from
+/// . 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).
+///
+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 _playerGuid;
+ private readonly Func _iconIds;
+
+ private readonly UiItemList? _contentsGrid;
+ private readonly UiItemList? _containerList;
+ private readonly UiItemList? _topContainer;
+
+ private InventoryController(
+ ImportedLayout layout,
+ ClientObjectTable objects,
+ Func playerGuid,
+ Func iconIds,
+ Func 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 playerGuid,
+ Func iconIds,
+ Func 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(); }
+
+ /// True if the object is in (or wielded by) the player — i.e. a rebuild is warranted.
+ 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
+ }
+
+ /// 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).
+ 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);
+ }
+ }
+
+ /// Detach event handlers (idempotent).
+ 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**
+
+```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-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`:
+
+```csharp
+ [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:
+```csharp
+ private readonly Func _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:
+```csharp
+ _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 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:
+```csharp
+ private void AttachCaption(UiElement? host, Func 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()
+ : new[] { new UiText.Line(s, CaptionColor) };
+ },
+ };
+ host.AddChild(label);
+ }
+
+ /// 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.
+ 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()`:
+```csharp
+ 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**
+
+```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-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:
+
+```csharp
+ 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):
+
+```csharp
+ _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**
+
+```bash
+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**
+
+```bash
+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`. ✓