diff --git a/docs/superpowers/specs/2026-06-22-d2b-container-switching-design.md b/docs/superpowers/specs/2026-06-22-d2b-container-switching-design.md new file mode 100644 index 00000000..9d32c455 --- /dev/null +++ b/docs/superpowers/specs/2026-06-22-d2b-container-switching-design.md @@ -0,0 +1,151 @@ +# D.2b inventory container-switching — design + +**Date:** 2026-06-22 +**Branch:** `claude/hopeful-maxwell-214a12` +**Phase:** D.2b retail-UI inventory arc — container-switching (the first inventory feature with a live two-way wire round-trip). +**Brainstorm:** this doc. Handoff that scoped it: `docs/research/2026-06-22-container-switching-handoff.md`. SSOT: `claude-memory/project_d2b_retail_ui.md`. + +--- + +## Goal + +Click a side bag in the inventory's pack-selector column → the contents grid shows **that bag's** contents (opened server-side via a `Use → ViewContents` round-trip), with the open bag marked by the retail **open-container triangle** and the selected cell marked by the retail **selected-item square**. Click the main-pack cell → back to the main pack. + +This is the first inventory feature that **talks to the server and consumes the reply** (a `Use 0x0036` → `ViewContents 0x0196` round-trip), a step up from the read-only display work so far. + +## Scope + +**In:** +1. `_openContainer` state in `InventoryController`; the contents grid shows `GetContents(_openContainer)`. +2. Click a side-bag cell → open it: `SendUse(bagGuid)`; on switching away from a previously-open side bag, `SendNoLongerViewingContents(prevGuid)`. +3. `ViewContents 0x0196` consumed as a **full REPLACE** of the container's membership (consumes the `GameEventWiring.cs:256` TODO), not the current additive merge. +4. Click the main-pack cell (`0x100001C9`) → `_openContainer = player`; the grid shows the main pack again. +5. **Open-container triangle** `0x06005D9C` on the cell whose `ItemId == _openContainer`. +6. **Selected-item square** `0x06004D21` on the cell whose `ItemId == _selectedItem` — panel-wide (one selection across grid + column), applied **uniformly** to grid items AND bag cells. **Visual-only** this phase. +7. Caption `0x100001C5` follows the open container ("Contents of Backpack" → "Contents of "). + +**Out (deferred, with reasons):** +- **Wiring the green square to the selected-object bar** (toolbar name/info strip) or to global 3D-world selection — the square is *visual-only* this phase. The selection→selected-object-bar integration is its own follow-up (it's a selection-system concern, not container-switching). +- **Preferred-pack auto-fit** (opening a pack makes it the auto-add target). Noted in `reference_retail_inventory_paperdoll`; out of the display-only first cut. +- **Drag INTO a side bag** (that's B-Drag) and **double-click-to-use** an inventory item (interaction work). +- The side-bag column scrollbar `0x100001CB` (inert — 7 fit). + +## Retail anchors (decomp + wire, all verified this session) + +| Concern | Retail anchor | Fact | +|---|---|---| +| Open a container | `Use 0x0036` (`InteractRequests.BuildUse`) | Use-on-container opens it server-side; ACE replies `ViewContents`. | +| Contents reply | `GameEventViewContents` (ACE) | `containerGuid, count, [guid, containerType]×count`. Entries written `OrderBy(PlacementPosition)` — **no explicit slot field; list order encodes the slot.** Our `ParseViewContents` (8 bytes/entry) is correct. | +| Close a view | `NoLongerViewingContents 0x0195` (`WorldSession.SendNoLongerViewingContents`) | Sent when switching away from an open side bag. | +| Open-container indicator | `UIElement_ItemList::UpdateOpenContainerIndicator` `0x004e3070` → `SetOpenContainerState` `0x004e1200` | shows element `0x10000450` (sprite `0x06005D9C`) on the cell where `item.itemID == openContainerId`. | +| Selected-item indicator | `UIElement_ItemList::ItemList_SetSelectedItem` `0x004e2fe0` → `SetSelectedState` `0x004e1240` | shows element `0x10000342` (sprite `0x06004D21`, pixel-confirmed green/yellow frame) on the cell where `item.itemID == selectedItemId`. Uniform across item + container cells. | + +**Indicator reconciliation (user-confirmed retail model — axiom):** the two indicators are orthogonal and can co-occur on one cell. Triangle = "this is the *open* backpack (its contents fill the grid)". Square = "this item is *selected*" — a backpack is just an item, so a clicked bag gets both. Side-bag cells use the 36×36 container prototype `0x1000033F` (triangle child only, no square child) — so the square is drawn as a procedural overlay (see Components / divergence). + +## Components + +### 1. `ClientObjectTable.ReplaceContents` (Core — new) + +``` +public void ReplaceContents(uint containerId, IReadOnlyList guids) +``` + +Full-replace the container's membership so `GetContents(containerId)` returns exactly `guids` in order: +- **Detach** each current member NOT in the new set: `ContainerId = 0`, `Reindex`, fire `ObjectMoved(o, containerId, 0)`. (It left the container server-side.) +- **Record** each new guid in order: create-if-absent, set `ContainerId = containerId` and `ContainerSlot = index` (reconstructs ACE's `PlacementPosition`), `Reindex`, fire `ObjectAdded` (new) or `ObjectMoved` (existing). containerType is not needed by the grid (dropped). + +Mirrors the existing `RecordMembership`/`Reindex` machinery; the only new behavior is the authoritative-snapshot semantics (flush-then-record). + +### 2. `GameEventWiring` ViewContents handler (Core.Net) + +Replace the additive `foreach … RecordMembership` (`:260`–`:266`) with one `items.ReplaceContents(p.Value.ContainerGuid, [entry.Guid …])`. Delete the `:256` TODO. This is the authoritative full snapshot per ACE. + +### 3. `WorldSession.SendUse` (Core.Net — new) + +``` +public void SendUse(uint targetGuid) // NextGameActionSequence + BuildUse + SendGameAction +``` + +A thin wire wrapper (mirrors `SendAddShortcut` etc.). Distinct from `GameWindow.SendUse` (the world-interaction path with autowalk/close-range hold) — opening a pack in your own inventory needs no movement, just the wire. + +### 4. `UiItemSlot` overlays (App) + +Two new procedural overlays in `OnDraw`, modeled exactly on the existing `DragAccept` overlay (resolve via `SpriteResolve`, **guard `id != 0` before resolving** per `feedback_ui_resolve_zero_magenta`): +- `bool IsOpenContainer` + `uint OpenContainerSprite = 0x06005D9C` → triangle. +- `bool Selected` + `uint SelectedSprite = 0x06004D21` → square. + +Draw order in `OnDraw`: icon/empty → digits → open-container triangle → selected square → drag-accept (transient, stays on top). `UiItemSlot` is already a behavioral leaf that paints DragAccept/digits procedurally, so drawing these procedurally (rather than via prototype state elements) is consistent — and it lets the square render on a 36×36 bag cell whose container prototype lacks the `m_elem_Icon_Selected` child. + +### 5. `InventoryController` (App) + +- **State:** `uint _openContainer` (default `= _playerGuid()`), `uint _selectedItem` (default 0). +- **`Populate` split:** + - Side-bag column (`_containerList`) + main-pack cell (`_topContainer`): always the **player's** bags (constant across switches; only indicators move). Unchanged logic + padding (AP-52). + - Contents grid (`_contentsGrid`): the **open container's** loose items — `GetContents(_openContainer)`, `if (isBag) continue` (bags live in the column; a side bag has no sub-bags so this is a no-op when a bag is open), equipped excluded. Pad to the open container's `ItemsCapacity` (player 102 / side bag 24). + - Caption `0x100001C5`: `"Contents of " + (_openContainer == player ? "Backpack" : Get(_openContainer)?.Name ?? "Backpack")`. + - Ends with `ApplyIndicators()`. +- **Click wiring** (set per-cell in `Populate`, the loop knows the cell's role): + - Grid item cell → `SelectItem(guid)`: `_selectedItem = guid`; `ApplyIndicators()` (square moves; no wire, no repopulate). + - Container cell (side bag or main-pack) → `OpenContainer(guid)`: + - `_selectedItem = guid` (the bag is also selected). + - If `guid != _openContainer`: if the *previous* `_openContainer` was a side bag (`!= player && != 0`), `sendNoLongerViewing(prev)`; set `_openContainer = guid`; if `guid != player`, `sendUse(guid)`; `Populate()` (repaint grid for the new container + indicators). Else (same container) `ApplyIndicators()`. + - Empty cell (`ItemId == 0`) → no-op. +- **`ApplyIndicators()`:** for every cell in the three lists, `cell.Selected = ItemId != 0 && ItemId == _selectedItem`; `cell.IsOpenContainer = ItemId != 0 && ItemId == _openContainer`. (Light: toggles bools on existing cells; no Flush.) +- **`Concerns`** += `|| o.ContainerId == _openContainer` — so the `ViewContents`-driven membership changes (which fire `ObjectAdded`/`Moved` for the open container's items) trigger a `Populate()` that fills the grid when the reply lands. + +### 6. `GameWindow` wiring (App) + +`InventoryController.Bind` gains two optional callbacks (default null, so existing tests/callers compile): +- `sendUse: g => _liveSession?.SendUse(g)` +- `sendNoLongerViewing: g => _liveSession?.SendNoLongerViewingContents(g)` + +Wired at the existing bind site (`GameWindow.cs:2231`), mirroring the toolbar's `sendAddShortcut`/`sendRemoveShortcut`. + +## Data flow + +``` +click side-bag cell + → _selectedItem = bag (square), _openContainer = bag (triangle) + → switching from another side bag? SendNoLongerViewingContents(prev) [0x0195] + → SendUse(bag) [0x0036] + → Populate(): grid = GetContents(bag) (EMPTY initially) + indicators painted + ───────────── server ───────────── + → ViewContents(bag, [items]) [0x0196] + → ReplaceContents(bag, guids) → ObjectAdded/Moved + → Concerns(o.ContainerId == _openContainer) → Populate() → grid fills +``` + +Robust to lazy-load either way: if the bag's contents were already known, the grid fills on the first `Populate()`; if they arrive only on open (the expected retail behavior), the `Concerns` extension fills it when `ViewContents` lands. + +## Divergence register + +- **Retire AP-56** (both indicators now drawn) and the container-switch clause of **AP-53** (side-pack contents now shown; keep/reword the capacity-default part). +- **Add** (same commit): + - The open-container triangle + selected-item square are drawn as **procedural `UiItemSlot` overlays** keyed by controller state (`_openContainer`/`_selectedItem`), reproducing the retail *keying* (`item.itemID == openContainerId / selectedItemId`) but not via the dat prototype's `m_elem_Icon_OpenContainer`/`m_elem_Icon_Selected` state elements + `SetOpenContainerState`/`SetSelectedState` calls. Lets the square render on the 36×36 container cell (whose prototype lacks the square child). Outcome matches retail. + - Inventory item **selection is panel-local and visual-only** — not wired to the selected-object bar (`SelectedObjectController`) or to global 3D-world selection. (Deferred to the selection follow-up.) + +## Test plan + +- **Core (`ClientObjectTable.Tests`):** `ReplaceContents` — records a fresh list in order; a second replace with a *smaller* list detaches the dropped guids (`GetContents` shrinks, dropped items `ContainerId == 0`); ordering follows list index. +- **Core.Net (`GameEventWiring`/parse tests):** `ViewContents` → full replace (a second smaller `ViewContents` shrinks membership — proves it's not the old additive merge). +- **App (`InventoryControllerTests`):** extend the existing fake-layout harness (`BuildLayout`/`Bind`) — add optional `sendUse`/`sendNoLongerViewing` capture lambdas. Cases: + - default `_openContainer` = player; grid shows player contents (existing tests still pass). + - click a side-bag cell → `sendUse(bag)` fired; grid shows `GetContents(bag)`; caption follows the bag name. + - switch bag A → bag B → `sendNoLongerViewing(A)` then `sendUse(B)`. + - click main-pack cell after a bag → `sendNoLongerViewing(bag)`, **no** `sendUse`; grid back to player. + - indicators: `IsOpenContainer` on the open cell, `Selected` on the selected cell; select-only grid click moves the square, leaves `_openContainer` + grid unchanged, sends no wire. +- **App (`UiItemSlot`):** overlay state — `Selected`/`IsOpenContainer` gate the draw; `id != 0` guard. + +Run the **full** suite at the phase boundary (not just filtered batches — the B-Wire process lesson). + +## Acceptance criteria + +- [ ] Decomp anchors cited in code comments (the `0x004exxxx` functions + sprite ids + the ACE wire ref). +- [ ] Divergence register updated in the same commit (AP-56 retired, AP-53 reworded, new rows added). +- [ ] `dotnet build` + full `dotnet test` green. +- [ ] **Visual gate (the oracle):** F12 → click a side bag → grid swaps to its contents; the open bag shows the triangle; the clicked cell shows the green/yellow square; click another bag → grid + indicators follow; click the main-pack cell → back to the main pack. WireMCP capture of `127.0.0.1:9000` confirms the `Use 0x0036 → ViewContents 0x0196` round-trip (and settles lazy-load-on-open). +- [ ] SSOT (`project_d2b_retail_ui.md`) + roadmap updated. + +## Open verification (at the live gate, not a blocker) + +Whether a side bag's contents are pre-known or arrive only on open — confirmed by the WireMCP/log capture at the gate. The design works either way; this just documents the observed behavior + retires the handoff's open question.