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>
This commit is contained in:
parent
ad27d1395a
commit
bfdab5b9e0
1 changed files with 151 additions and 0 deletions
|
|
@ -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 <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.
|
||||
Loading…
Add table
Add a link
Reference in a new issue