docs(D.2b-B): InventoryController (B-Controller) design spec

Read-only population of the gmInventoryUI tree: bind 0x21000023 by id,
fill the 'Contents of Backpack' grid + the right-strip pack-selector from
ClientObjectTable, drive the vertical burden meter, render the Type-0
captions. Faithful selector + full faithful burden meter (per brainstorm).

Ported AC algorithms with decomp anchors: CACQualities::InqLoad (0x0058f130),
EncumbranceSystem::EncumbranceCapacity (0x004fcc00) / Load (0x004fcc40),
gmBackpackUI::SetLoadLevel (0x004a6ea0), UIElement_Meter::DrawChildren
(0x0046fbd0) / Initialize (0x0046f7b0, m_eDirection from property 0x6f).
Key finding: BuildMeter's single-image sprite assignment is already correct
(meter-own=track, child=fill); only vertical fill is new.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Erik 2026-06-21 08:31:21 +02:00
parent 4e23a7b9bc
commit 0e273ff2ac

View file

@ -0,0 +1,265 @@
# 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<uint> playerGuid, // _playerServerGuid
Func<ItemType,uint,uint,uint,uint,uint> iconIds, // IconComposer.GetIcon
Func<int?> 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<uint>`, 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<H`
geometry + default B→T, vs retail's `m_eDirection` from property `0x6f`.
- **AP** — main-pack `m_topContainer` icon is a fixed/placeholder backpack art, not a
weenie-driven icon (the main pack ≡ the player, which has no item IconId).
## 7. Testing (dat-free conformance)
`tests/AcDream.App.Tests/UI/Layout/InventoryControllerTests.cs` (+ a burden-math test):
- **Bind** — given a synthetic `ImportedLayout` (built via `BuildFromInfos` from ElementInfo
fixtures, or a hand-built tree) with the 7 ids, `Bind` finds the lists/meter and does not
throw on missing ids.
- **Grid population** — N loose items in `GetContents(player)` → the 3D grid has N cells with
the right guids; M side bags → `m_containerList` has M cells; partition correctness
(container vs loose).
- **Burden golden values** (Str=100 → max=15000): burden 0 → load 0 → fill 0 / "0%"; 7500 →
0.5 → 0.1667 / "50%"; 15000 → 1.0 → 0.3333 / "100%"; 45000 → 3.0 → 1.0 (clamped) / "300%";
60000 → 4.0 → 1.0 (clamped) / "400%". Assert `fill` and `%text` separately.
- **Capacity**`EncumbranceCapacity(100, 0)=15000`; `(100, 3)=15000+min(90,150)·100=24000`;
`(100, 10)=15000+min(300→150)·100=30000`; `(0, x)=0`.
- **Vertical fill rect**`UiMeter` vertical mode: fraction 0.5 of an 11×58 bar fills the
correct half along the fill direction.
- **Caption** — the caption `UiText` children carry the expected strings.
- **Rebuild** — firing `ObjectAdded` for a player-pack item re-runs `Populate` and the new cell
appears; an unrelated guid does not.
Reuse the existing Layout test fixtures (`vitals_2100006C.json` pattern). A real-dat smoke
check of `Import(0x21000023)` + `Bind` is a nice-to-have before wiring (not gating).
## 8. Visual verification (final launch — folded with the z-order eyeball)
Launch with `ACDREAM_RETAIL_UI=1`, F12: confirm (a) pack items show in the "Contents of
Backpack" grid, (b) side bags (if any) + main-pack cell in the right strip, (c) the burden bar
fills vertically the right direction with the right sprites and the `%` reads sanely, (d) the
"Burden"/"Contents of Backpack" captions render, AND (e) chat + toolbar show no #145 z-order
regression. Items (c) direction and (a) cell pitch are the two visual-confirm unknowns.
## 9. Acceptance criteria
- [ ] `InventoryController` binds `0x21000023`'s tree by id; build + tests green.
- [ ] 3D-items grid populates from `GetContents(player)` loose items; right strip from side
bags + main pack.
- [ ] Burden meter renders vertically with the ported formula; `%` text correct; golden tests
pass.
- [ ] Captions render.
- [ ] Every ported AC algorithm cites its decomp anchor in comments; divergence rows added.
- [ ] Visual verification (with the z-order eyeball) by the user.
## 10. Out of scope (explicit non-goals → later sub-phases)
- Container **switching** (click a pack-selector cell → re-point the 3D grid). → interactivity.
- Inventory cell as a **drag source** (`SourceKind==Inventory`). → B-Drag.
- Wire gaps (`DropItem`/`GetAndWieldItem`/`ViewContents`/`SetStackSize`/player `EncumbranceVal`
parse). → B-Wire.
- Paperdoll doll + per-slot equip art (`0x10000032` UiItemSlot registration). → Sub-phase C.
- Scrolling the contents grid past 3 rows.