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>
15 KiB
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:
- the "Contents of Backpack" grid with the player's pack items,
- the right-strip pack-selector (main-pack cell + side-bag container icons),
- the vertical burden meter + the
%text, - 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):
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)ORItemsCapacity > 0. - loose item = everything else.
Then:
- 3D-items grid
0x100001C6—Columns=6,CellWidth=CellHeight=32(192÷32=6 cols × 96÷32=3 rows; cell templateUIItem 0x21000037= 32×32).Flush(), thenAddItem(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 (the0x100001C7gutter is the scrollbar). - m_containerList
0x100001CA—Columns=1, 32px cells. One cell per side bag, sameSetItem. 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 attribute0x69(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:
- player
ClientObject's PropertyInt 5:objects.Get(playerGuid())?.Properties.GetInt(5)— populated iff PD/0x02CEcarried it (B-Wire guarantees this later). - fallback: client-side sum of carried
Burdenover the player's possession subtree (GetContents(player)+ recurse side bags + items withWielderId == player). - 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(orDirection) flag, - a
DrawVBarthat drawsBackTileover the full rect thenFrontTileclipped toHeight × fractionalong 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 viaSetText, 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
currentBurdencomputed client-side (Σ carried Burden) when the wireEncumbranceVal(PropertyInt 5) is absent; retail reads the server-maintained value. Retire when B-Wire parses it. - AP — capacity augmentation (
aug, PropertyInt0xE6) not tracked →bonus = 0→ un-augmentedstr×150. Buffed/augmented chars read slightly high. - AP (only if dir not read from
0x6f) — burden meter orientation inferred fromW<Hgeometry + default B→T, vs retail'sm_eDirectionfrom property0x6f. - AP — main-pack
m_topContainericon 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 viaBuildFromInfosfrom ElementInfo fixtures, or a hand-built tree) with the 7 ids,Bindfinds 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_containerListhas 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
filland%textseparately. - 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 —
UiMetervertical mode: fraction 0.5 of an 11×58 bar fills the correct half along the fill direction. - Caption — the caption
UiTextchildren carry the expected strings. - Rebuild — firing
ObjectAddedfor a player-pack item re-runsPopulateand 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
InventoryControllerbinds0x21000023'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/playerEncumbranceValparse). → B-Wire. - Paperdoll doll + per-slot equip art (
0x10000032UiItemSlot registration). → Sub-phase C. - Scrolling the contents grid past 3 rows.