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

15 KiB
Raw Blame History

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 0x10000031DatWidgetFactory 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) OR ItemsCapacity > 0.
  • loose item = everything else.

Then:

  • 3D-items grid 0x100001C6Columns=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 0x100001CAColumns=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 0x0600121DBackTile/track; child 0x0600121CFrontTile/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.
  • CapacityEncumbranceCapacity(100, 0)=15000; (100, 3)=15000+min(90,150)·100=24000; (100, 10)=15000+min(300→150)·100=30000; (0, x)=0.
  • Vertical fill rectUiMeter 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.