acdream/docs/superpowers/specs/2026-06-22-d2b-container-switching-design.md
Erik bfdab5b9e0 docs(D.2b): container-switching design spec
Brainstorm output for inventory container-switching. Key decision from
the brainstorm: the handoff conflated two orthogonal retail mechanisms —
the open-container TRIANGLE (0x06005D9C, UpdateOpenContainerIndicator) and
the selected-item SQUARE (0x06004D21, ItemList_SetSelectedItem). User
confirmed both are real (a bag is just an item, so it gets the square too)
and chose to ship both this phase, the square uniform across grid+bags and
visual-only (no selected-object-bar wiring yet).

Pins the ViewContents full-REPLACE (ACE writes entries OrderBy
PlacementPosition, no slot field -> ContainerSlot=index), the Use 0x0036 ->
ViewContents 0x0196 round-trip, and the 6 touched files.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 14:15:42 +02:00

151 lines
13 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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 <BagName>").
**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<uint> 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.