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

13 KiB
Raw Blame History

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 0x0036ViewContents 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 0x004e3070SetOpenContainerState 0x004e1200 shows element 0x10000450 (sprite 0x06005D9C) on the cell where item.itemID == openContainerId.
Selected-item indicator UIElement_ItemList::ItemList_SetSelectedItem 0x004e2fe0SetSelectedState 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.