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

976 lines
43 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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
/// <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**
```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
/// <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**
```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
/// <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`:
```csharp
/// <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:
```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
/// <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**
```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<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`:
```csharp
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**
```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<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:
```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<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:
```csharp
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()`:
```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`. ✓