From 0e273ff2ac65f3be89c6c80926c193a708ba06db Mon Sep 17 00:00:00 2001 From: Erik Date: Sun, 21 Jun 2026 08:31:21 +0200 Subject: [PATCH] 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) --- ...6-06-21-d2b-inventory-controller-design.md | 265 ++++++++++++++++++ 1 file changed, 265 insertions(+) create mode 100644 docs/superpowers/specs/2026-06-21-d2b-inventory-controller-design.md diff --git a/docs/superpowers/specs/2026-06-21-d2b-inventory-controller-design.md b/docs/superpowers/specs/2026-06-21-d2b-inventory-controller-design.md new file mode 100644 index 00000000..79cd75c3 --- /dev/null +++ b/docs/superpowers/specs/2026-06-21-d2b-inventory-controller-design.md @@ -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 playerGuid, // _playerServerGuid + Func iconIds, // IconComposer.GetIcon + Func 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`, 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