# D.2b Sub-phase B-Controller — inventory population design **Date:** 2026-06-21 **Phase:** D.2b retail-UI engine → core panels → Sub-phase B (inventory) → **B-Controller** **Branch:** `claude/hopeful-maxwell-214a12` **Predecessor:** B-Grid (sub-window mount + `UiItemList` grid mode) + #145 (ZLevel z-order) — shipped. Handoff: `docs/research/2026-06-21-d2b-bgrid-shipped-bcontroller-next-handoff.md`. ## 0. Goal Make the F12 inventory window show the player's live contents. Bind the imported `gmInventoryUI` (`LayoutDesc 0x21000023`) tree by element id and populate it from `ClientObjectTable`: 1. the **"Contents of Backpack" grid** with the player's pack items, 2. the **right-strip pack-selector** (main-pack cell + side-bag container icons), 3. the **vertical burden meter** + the `%` text, 4. the **Type-0 captions** ("Burden", "Contents of Backpack"). This is the `gmInventoryUI::PostInit` / `gmBackpackUI::PostInit` / `gm3DItemsUI::PostInit` analogue. **Read-only**: no container switching, no drag-as-source, no wield/drop wire (those are B-Wire / B-Drag / Sub-phase C). Both scope calls were taken by the user during brainstorming: **faithful pack-selector** (populate the right strip too) and **full faithful burden meter** (vertical fill + ported `InqLoad` formula). ## 1. The inventory tree (confirmed by dat dump + decomp) `gmInventoryUI` `0x21000023` (300×362) is a frame nesting three sub-windows (mounted by B-Grid's sub-window mount; all reachable via `ImportedLayout.FindElement`): | Sub-window | LayoutDesc | Mounted at | Size | This phase | |---|---|---|---|---| | paperdoll | `0x21000024` | `0x100001CD` | 224×214 | **Sub-phase C** (not here) | | backpack strip | `0x21000022` | `0x100001CE` | 61×339 | **here** | | 3D-items ("Contents of Backpack") | `0x21000021` | `0x100001CF` | 234×120 | **here** | ### Elements this controller binds | Id | Role | Resolved widget | Source | |---|---|---|---| | `0x100001C6` | 3D-items **contents grid** (192×96) | `UiItemList` (grid) | `gm3DItemsUI::PostInit` m_itemList `DynamicCast(0x10000031)` (decomp 176734-176742) | | `0x100001C5` | "Contents of Backpack" caption (192×15) | Type-0 → caption pass | `gm3DItemsUI::PostInit` m_contentsText, `SetText` 176745 | | `0x100001CA` | backpack **m_containerList** (36×252, 1 col) | `UiItemList` (grid) | `gmBackpackUI::PostInit` `DynamicCast(0x10000031)` (176621-176629) | | `0x100001C9` | backpack **m_topContainer** (36×36, 1 cell) | `UiItemList` (single) | `gmBackpackUI::PostInit` (176612-176620) | | `0x100001D9` | **burden meter** (vertical 11×58, Type 7) | `UiMeter` | `gmBackpackUI::PostInit` `DynamicCast(7)` (176601-176610) | | `0x100001D8` | burden **`%` text** (36×15) | Type-0 → caption pass | `gmBackpackUI` m_burdenText, `SetText "%d%%"` (176583) | | `0x100001D7` | "Burden" caption (36×15) | Type-0 → caption pass | dat | The three list elements (`0x100001C6/C9/CA`) are Type-0 in the dat, inheriting `BaseElement 0x10000339 / BaseLayoutId 0x2100003D` (the `UIElement_ItemList` prototype) → `ElementReader.Merge` resolves them to Type `0x10000031` → `DatWidgetFactory` builds them as `UiItemList`. Already verified reachable by id through the mount. ## 2. Architecture New `src/AcDream.App/UI/Layout/InventoryController.cs`, mirroring `ToolbarController` / `VitalsController` (Code-Structure Rule 1 — no new feature body in `GameWindow`): ```csharp public sealed class InventoryController { public static InventoryController Bind( ImportedLayout invLayout, ClientObjectTable objects, Func playerGuid, // _playerServerGuid Func iconIds, // IconComposer.GetIcon Func strength, // LocalPlayerState Strength.Current UiDatFont? datFont); public void Dispose(); // unsubscribe (controllers currently don't, but // the inventory rebuild is event-driven — be tidy) } ``` Bound **inline in GameWindow's existing inventory-init block** (the `invLayout` local from B-Grid, `GameWindow.cs` ~2141), right after `RegisterWindow(WindowNames.Inventory, …)` — exactly where `ToolbarController.Bind(toolbarLayout, …)` sits for the toolbar. Lifecycle: `Bind` find-by-id binds all elements, subscribes to `objects.ObjectAdded/ObjectMoved/ObjectRemoved` and `LocalPlayerState` attribute/`Changed` events, then calls `Populate()` once. Each subscription calls `Populate()` (filtered: only when the changed object is in the player's possession subtree, mirroring `ToolbarController.Populate`'s `IsShortcutGuid` gate). Missing elements are skipped silently (partial-layout tolerance, like `SelectedObjectController`). ## 3. Population (`Populate()`) Reads `var contents = objects.GetContents(playerGuid())` (→ `IReadOnlyList`, slot-ordered). Partition each guid's `ClientObject`: - **side bag** = `Type.HasFlag(ItemType.Container)` OR `ItemsCapacity > 0`. - **loose item** = everything else. Then: - **3D-items grid `0x100001C6`** — `Columns=6`, `CellWidth=CellHeight=32` (192÷32=6 cols × 96÷32=3 rows; cell template `UIItem 0x21000037` = 32×32). `Flush()`, then `AddItem(new UiItemSlot{…})` + `cell.SetItem(guid, iconIds(item.Type, item.IconId, item.IconUnderlayId, item.IconOverlayId, item.Effects))` per **loose item**. (Pitch 32 vs 36 → **visual-confirm**; 192/32=6 is the clean fit and matches the handoff "6 cols" note.) Overflow past 3 rows is clipped by the panel for now; scroll = follow-up (the `0x100001C7` gutter is the scrollbar). - **m_containerList `0x100001CA`** — `Columns=1`, 32px cells. One cell per **side bag**, same `SetItem`. Empty on a char with no side bags (correct). - **m_topContainer `0x100001C9`** — single cell = the **main pack**. Icon: a fixed backpack RenderSurface DID (pin from the decomp / a known UI icon; if none found, leave the empty-slot art and file a follow-up — do NOT invent a guid). Bound object id = `playerGuid()` (the main pack ≡ the player container). Reuse the shipped `UiItemList` grid mode (`Columns`/`CellWidth`/`CellHeight`/`Flush`/`AddItem`) and `UiItemSlot.SetItem(guid, tex)` + `IconComposer.GetIcon`. **Do not rebuild the spine.** ## 4. Burden meter (faithful vertical port) ### 4.1 Retail formula (ported, with anchors) `gmBackpackUI::SetLoadLevel(double load)` (decomp 176536, `0x004a6ea0`): - `fill = clamp(load × 0.3333…, 0, 1)` → pushed to the meter as float attribute `0x69` (`UIElement::SetAttribute_Float`, 176565-176573). - `%` text = `floor((load×⅓) × 300) = floor(load × 100)` formatted `"%d%%"` → `UIElement_Text::SetText(m_burdenText)` (176576-176583). `load = CACQualities::InqLoad()` (decomp 409756, `0x0058f130`): ``` strength = InqAttribute(1) // primary attr 1 = Strength, default 10 aug = InqInt(0xE6) // capacity augmentation, default 0 capacity = EncumbranceSystem::EncumbranceCapacity(strength, aug) burden = InqInt(5) // PropertyInt 5 = EncumbranceVal (total carried) load = EncumbranceSystem::Load(capacity, burden) ``` `EncumbranceSystem::EncumbranceCapacity(str, aug)` (decomp 256393, `0x004fcc00`): ``` if str <= 0: return 0 bonus = clamp(aug × 30, 0, 150) // 0x1e=30, cap 0x96=150 return str × 150 + bonus × str // = str × (150 + bonus) ``` `EncumbranceSystem::Load(capacity, burden)` (decomp 256413, `0x004fcc40`): the decompiler mangled the FP body to `if cap<=0 return cap; return burden`; the structure (a divide-by-zero guard + a single divide) is unambiguously `load = burden / capacity` — the encumbrance ratio (1.0 = at capacity, up to ~3.0). **Cross-ref:** matches acdream's existing `BurdenMath.ComputeMax = 150×str + str×bonus` (`ClientObject.cs:215`) and the r06 research doc ("maxBurden = 150 × Strength + Strength × bonusBurden; carry limit 3 × maxBurden"). ACE was not checked out in this worktree; the decomp + existing port + r06 doc are three corroborating sources. **acdream port:** `maxBurden = BurdenMath.ComputeMax(strength, bonus)` (reuse; `bonus = clamp(aug×30,0,150)`, `aug` from player PropertyInt `0xE6`, default 0 → `str×150`). `load = burden / maxBurden`. `fill = clamp(load/3, 0, 1)`. `%text = floor(load×100)`. ### 4.2 Burden data source acdream does **not** track the player's wire `EncumbranceVal` today (WorldSession parses only per-item Burden). Priority: 1. player `ClientObject`'s PropertyInt 5: `objects.Get(playerGuid())?.Properties.GetInt(5)` — populated iff PD/`0x02CE` carried it (B-Wire guarantees this later). 2. fallback: client-side sum of carried `Burden` over the player's possession subtree (`GetContents(player)` + recurse side bags + items with `WielderId == player`). 3. neither → `currentBurden = 0` → bar empty / "0%" (correct "no data" state). `strength` from `LocalPlayerState.GetAttribute(Strength)?.Current` (default 10 if absent, matching `InqAttribute`'s `0xa` default). **Divergence row** if (2) is used (client recompute vs server EncumbranceVal — same quantity). Drive the meter via `UiMeter.Fill = () => fill` and a `Label`/caption provider for the `%` text, recomputed on `ObjectAdded/Moved/Removed` + `LocalPlayerState.Changed/AttributeChanged`. ### 4.3 Vertical fill (the one `UiMeter` change) Per `UIElement_Meter::DrawChildren` (decomp 123574, `0x0046fbd0`): the meter draws its **own DirectState media as the track (full)** and the **`m_pcChildImage` child clipped to attribute `0x69` (the fill fraction)**. So `BuildMeter`'s existing single-image assignment is **already correct** (meter-own `0x0600121D` → `BackTile`/track; child `0x0600121C` → `FrontTile`/fill). **No sprite-role change.** The clip axis/direction is `m_eDirection` (read from LayoutDesc property `0x6f` at `UIElement_Meter::Initialize`, decomp 123334-123336): `1`=L→R, `2`=T→B, `3`=R→L, `4`=B→T. `UiMeter` is hardcoded `1`. Add: - a `UiMeter.Vertical` (or `Direction`) flag, - a `DrawVBar` that draws `BackTile` over the full rect then `FrontTile` clipped to `Height × fraction` along the fill direction. The burden child sprite (11×61) ≈ the bar (11×58) — effectively single-sprite, no 3-slice tiling needed. Direction source: read property `0x6f` if `ElementReader` surfaces it; **else** infer vertical from `Width < Height` and default **B→T (dir 4)** — the AC convention for a vertical resource bar — with a **visual-confirm** and an AP register row for the geometry-inference. (Reading `0x6f` is preferred; decide in the plan based on how cheaply `ElementReader` can expose it.) ## 5. Captions (Type-0 text) Controller **caption pass** (not a factory Type-0→`UiText` promotion — that would risk other panels' inert Type-0 elements). For each caption id, attach a centered `UiText` child carrying the known string, mirroring `SelectedObjectController`'s name-overlay attach and `VitalsController`'s number overlay: - `0x100001D7` → "Burden" - `0x100001C5` → "Contents of Backpack" (procedural in retail via `SetText`, 176743-176745) - `0x100001D8` → the live `%` text (`floor(load×100)` + "%"), updated with the meter. Caption strings are hardcoded with their decomp citation (retail sets `0x100001C5`/`0x100001D8` procedurally; `0x100001D7` "Burden" is the dat element's label). Font = the retail dat font (`datFont`), color/justification per the Type-0 base (left/normal). If the dat string for `0x100001D7` is trivially readable from the layout, prefer it over the hardcode. ## 6. Divergence register rows (add in the impl commit) - **AP** — burden `currentBurden` computed client-side (Σ carried Burden) when the wire `EncumbranceVal` (PropertyInt 5) is absent; retail reads the server-maintained value. Retire when B-Wire parses it. - **AP** — capacity augmentation (`aug`, PropertyInt `0xE6`) not tracked → `bonus = 0` → un-augmented `str×150`. Buffed/augmented chars read slightly high. - **AP** (only if dir not read from `0x6f`) — burden meter orientation inferred from `W