acdream/docs/superpowers/specs/2026-06-21-d2b-inventory-controller-design.md
Erik 0e273ff2ac 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>
2026-06-21 08:31:21 +02:00

265 lines
15 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 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.