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>
13 KiB
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:
_openContainerstate inInventoryController; the contents grid showsGetContents(_openContainer).- Click a side-bag cell → open it:
SendUse(bagGuid); on switching away from a previously-open side bag,SendNoLongerViewingContents(prevGuid). ViewContents 0x0196consumed as a full REPLACE of the container's membership (consumes theGameEventWiring.cs:256TODO), not the current additive merge.- Click the main-pack cell (
0x100001C9) →_openContainer = player; the grid shows the main pack again. - Open-container triangle
0x06005D9Con the cell whoseItemId == _openContainer. - Selected-item square
0x06004D21on the cell whoseItemId == _selectedItem— panel-wide (one selection across grid + column), applied uniformly to grid items AND bag cells. Visual-only this phase. - Caption
0x100001C5follows 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<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, fireObjectMoved(o, containerId, 0). (It left the container server-side.) - Record each new guid in order: create-if-absent, set
ContainerId = containerIdandContainerSlot = index(reconstructs ACE'sPlacementPosition),Reindex, fireObjectAdded(new) orObjectMoved(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). Populatesplit:- 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'sItemsCapacity(player 102 / side bag 24). - Caption
0x100001C5:"Contents of " + (_openContainer == player ? "Backpack" : Get(_openContainer)?.Name ?? "Backpack"). - Ends with
ApplyIndicators().
- Side-bag column (
- 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_openContainerwas a side bag (!= player && != 0),sendNoLongerViewing(prev); set_openContainer = guid; ifguid != player,sendUse(guid);Populate()(repaint grid for the new container + indicators). Else (same container)ApplyIndicators().
- Empty cell (
ItemId == 0) → no-op.
- Grid item cell →
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 theViewContents-driven membership changes (which fireObjectAdded/Movedfor the open container's items) trigger aPopulate()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
UiItemSlotoverlays keyed by controller state (_openContainer/_selectedItem), reproducing the retail keying (item.itemID == openContainerId / selectedItemId) but not via the dat prototype'sm_elem_Icon_OpenContainer/m_elem_Icon_Selectedstate elements +SetOpenContainerState/SetSelectedStatecalls. 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.)
- The open-container triangle + selected-item square are drawn as procedural
Test plan
- Core (
ClientObjectTable.Tests):ReplaceContents— records a fresh list in order; a second replace with a smaller list detaches the dropped guids (GetContentsshrinks, dropped itemsContainerId == 0); ordering follows list index. - Core.Net (
GameEventWiring/parse tests):ViewContents→ full replace (a second smallerViewContentsshrinks membership — proves it's not the old additive merge). - App (
InventoryControllerTests): extend the existing fake-layout harness (BuildLayout/Bind) — add optionalsendUse/sendNoLongerViewingcapture lambdas. Cases:- default
_openContainer= player; grid shows player contents (existing tests still pass). - click a side-bag cell →
sendUse(bag)fired; grid showsGetContents(bag); caption follows the bag name. - switch bag A → bag B →
sendNoLongerViewing(A)thensendUse(B). - click main-pack cell after a bag →
sendNoLongerViewing(bag), nosendUse; grid back to player. - indicators:
IsOpenContaineron the open cell,Selectedon the selected cell; select-only grid click moves the square, leaves_openContainer+ grid unchanged, sends no wire.
- default
- App (
UiItemSlot): overlay state —Selected/IsOpenContainergate the draw;id != 0guard.
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
0x004exxxxfunctions + sprite ids + the ACE wire ref). - Divergence register updated in the same commit (AP-56 retired, AP-53 reworded, new rows added).
dotnet build+ fulldotnet testgreen.- 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:9000confirms theUse 0x0036 → ViewContents 0x0196round-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.