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