diff --git a/docs/superpowers/plans/2026-06-22-d2b-container-switching.md b/docs/superpowers/plans/2026-06-22-d2b-container-switching.md new file mode 100644 index 00000000..a959700c --- /dev/null +++ b/docs/superpowers/plans/2026-06-22-d2b-container-switching.md @@ -0,0 +1,819 @@ +# D.2b inventory container-switching — Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Click a side bag in the inventory pack-selector → the grid shows that bag's contents (opened via a `Use 0x0036` → `ViewContents 0x0196` full-replace round-trip), the open bag shows the retail triangle, the selected cell shows the retail green/yellow square; click the main-pack cell → back. + +**Architecture:** Add an authoritative full-replace path to the Core object table (`ReplaceContents`), reroute the `ViewContents` wiring through it, add a thin `WorldSession.SendUse` wire wrapper, give `UiItemSlot` two procedural indicator overlays (modeled on the existing DragAccept overlay), and give `InventoryController` `_openContainer`/`_selectedItem` state with per-cell click roles (grid→select, container→open+select). `GameWindow` injects the two send callbacks. + +**Tech Stack:** C# .NET 10, xUnit. Layers: `AcDream.Core` (object table), `AcDream.Core.Net` (wire + wiring), `AcDream.App` (UI widgets + controller + window). + +**Spec:** `docs/superpowers/specs/2026-06-22-d2b-container-switching-design.md`. Decomp anchors: `UpdateOpenContainerIndicator 0x004e3070`/`SetOpenContainerState 0x004e1200` (triangle `0x06005D9C`); `ItemList_SetSelectedItem 0x004e2fe0`/`SetSelectedState 0x004e1240` (square `0x06004D21`). Wire: ACE `GameEventViewContents.cs` (entries `OrderBy(PlacementPosition)`, no slot field → `ContainerSlot = index`). + +**Build/test commands** (run from repo root `C:\Users\erikn\source\repos\acdream\.claude\worktrees\hopeful-maxwell-214a12`): +- One project's tests: `dotnet test tests/AcDream.Core.Tests/AcDream.Core.Tests.csproj` +- Full suite at the phase boundary: `dotnet test` + +--- + +## Task 1: `ClientObjectTable.ReplaceContents` (full-replace membership) + +**Files:** +- Modify: `src/AcDream.Core/Items/ClientObjectTable.cs` (add a method after `GetContents`, ~line 316) +- Test: `tests/AcDream.Core.Tests/Items/ClientObjectTableTests.cs` (add after `GetContents_UnknownContainer_Empty`) + +- [ ] **Step 1: Write the failing tests** + +Add to `ClientObjectTableTests.cs`: + +```csharp +[Fact] +public void ReplaceContents_RecordsAllInListOrder() +{ + var table = new ClientObjectTable(); + table.ReplaceContents(0xC9u, new uint[] { 0xA01u, 0xA02u, 0xA03u }); + Assert.Equal(new[] { 0xA01u, 0xA02u, 0xA03u }, table.GetContents(0xC9u)); + Assert.Equal(0xC9u, table.Get(0xA01u)!.ContainerId); +} + +[Fact] +public void ReplaceContents_SecondSmallerList_DetachesDropped() // the full-REPLACE semantics (vs the old additive merge) +{ + var table = new ClientObjectTable(); + table.ReplaceContents(0xC9u, new uint[] { 0xA01u, 0xA02u, 0xA03u }); + table.ReplaceContents(0xC9u, new uint[] { 0xA02u }); // A01 + A03 removed server-side + Assert.Equal(new[] { 0xA02u }, table.GetContents(0xC9u)); + Assert.Equal(0u, table.Get(0xA01u)!.ContainerId); // detached, not lingering + Assert.Equal(0u, table.Get(0xA03u)!.ContainerId); + Assert.Equal(0xC9u, table.Get(0xA02u)!.ContainerId); +} + +[Fact] +public void ReplaceContents_ReordersToNewListOrder() +{ + var table = new ClientObjectTable(); + table.ReplaceContents(0xC9u, new uint[] { 0xA01u, 0xA02u }); + table.ReplaceContents(0xC9u, new uint[] { 0xA02u, 0xA01u }); + Assert.Equal(new[] { 0xA02u, 0xA01u }, table.GetContents(0xC9u)); +} + +[Fact] +public void ReplaceContents_ZeroContainer_NoOp() +{ + var table = new ClientObjectTable(); + table.ReplaceContents(0u, new uint[] { 0xA01u }); + Assert.Empty(table.GetContents(0u)); + Assert.Null(table.Get(0xA01u)); +} +``` + +- [ ] **Step 2: Run the tests to verify they fail** + +Run: `dotnet test tests/AcDream.Core.Tests/AcDream.Core.Tests.csproj --filter "FullyQualifiedName~ReplaceContents"` +Expected: FAIL — `'ClientObjectTable' does not contain a definition for 'ReplaceContents'` (compile error). + +- [ ] **Step 3: Implement `ReplaceContents`** + +In `ClientObjectTable.cs`, immediately after the `GetContents` method (~line 316), add: + +```csharp + /// + /// Replace a container's entire membership with (in order) — the + /// authoritative full snapshot the server sends in ViewContents (0x0196) when you open a + /// container. Members no longer present are detached (ContainerId → 0); new members are + /// recorded with ContainerSlot = list index. ACE writes ViewContents entries + /// OrderBy(PlacementPosition) with NO explicit slot field (GameEventViewContents.cs), so the + /// list order IS the slot order. Fires ObjectAdded/ObjectMoved so bound UI repaints. + /// Retail consumer: ClientUISystem::OnViewContents. + /// + public void ReplaceContents(uint containerId, IReadOnlyList guids) + { + ArgumentNullException.ThrowIfNull(guids); + if (containerId == 0) return; + + var keep = new HashSet(guids); + + // Detach prior members no longer present (they left the container server-side). GetContents + // returns a snapshot, so mutating the index inside the loop is safe. + foreach (var old in GetContents(containerId)) + { + if (keep.Contains(old)) continue; + if (!_objects.TryGetValue(old, out var o) || o.ContainerId != containerId) continue; + o.ContainerId = 0; + Reindex(o, containerId); + ObjectMoved?.Invoke(o, containerId, 0); + } + + // Record new members in order; ContainerSlot = index reconstructs the server PlacementPosition. + for (int i = 0; i < guids.Count; i++) + { + uint g = guids[i]; + bool existed = _objects.TryGetValue(g, out var obj); + if (!existed || obj is null) + { + obj = new ClientObject { ObjectId = g }; + _objects[g] = obj; + } + uint oldContainer = obj.ContainerId; + obj.ContainerId = containerId; + obj.ContainerSlot = i; + Reindex(obj, oldContainer); + if (!existed) ObjectAdded?.Invoke(obj); + else ObjectMoved?.Invoke(obj, oldContainer, containerId); + } + } +``` + +- [ ] **Step 4: Run the tests to verify they pass** + +Run: `dotnet test tests/AcDream.Core.Tests/AcDream.Core.Tests.csproj --filter "FullyQualifiedName~ReplaceContents"` +Expected: PASS (4 tests). + +- [ ] **Step 5: Commit** + +```bash +git add src/AcDream.Core/Items/ClientObjectTable.cs tests/AcDream.Core.Tests/Items/ClientObjectTableTests.cs +git commit -m "feat(D.2b): ClientObjectTable.ReplaceContents — authoritative full-replace for ViewContents + +Co-Authored-By: Claude Opus 4.8 (1M context) " +``` + +--- + +## Task 2: route `ViewContents` wiring through `ReplaceContents` + +**Files:** +- Modify: `src/AcDream.Core.Net/GameEventWiring.cs:253-266` (the ViewContents handler) +- Test: `tests/AcDream.Core.Net.Tests/GameEventWiringTests.cs` (add after `WireAll_ViewContents_RecordsMembershipInClientObjectTable`) + +- [ ] **Step 1: Write the failing test** + +Add to `GameEventWiringTests.cs` (the existing `WireAll_ViewContents_RecordsMembershipInClientObjectTable` stays — it still passes under replace): + +```csharp +[Fact] +public void WireAll_ViewContents_IsFullReplace_DropsRemovedItems() +{ + var (d, items, _, _, _) = MakeAll(); + + // First view of container 0xC9: two items. + DispatchViewContents(d, 0x500000C9u, new uint[] { 0x50000A01u, 0x50000A02u }); + Assert.Equal(2, items.GetContents(0x500000C9u).Count); + + // Second view: only one item — the other was removed server-side. Additive merge would keep both. + DispatchViewContents(d, 0x500000C9u, new uint[] { 0x50000A02u }); + Assert.Equal(new[] { 0x50000A02u }, items.GetContents(0x500000C9u)); + Assert.Equal(0u, items.Get(0x50000A01u)!.ContainerId); +} + +private static void DispatchViewContents(GameEventDispatcher d, uint containerGuid, uint[] itemGuids) +{ + byte[] payload = new byte[8 + itemGuids.Length * 8]; + BinaryPrimitives.WriteUInt32LittleEndian(payload.AsSpan(0), containerGuid); + BinaryPrimitives.WriteUInt32LittleEndian(payload.AsSpan(4), (uint)itemGuids.Length); + for (int i = 0; i < itemGuids.Length; i++) + { + BinaryPrimitives.WriteUInt32LittleEndian(payload.AsSpan(8 + i * 8), itemGuids[i]); + BinaryPrimitives.WriteUInt32LittleEndian(payload.AsSpan(12 + i * 8), 0u); // containerType + } + var env = GameEventEnvelope.TryParse(WrapEnvelope(GameEventType.ViewContents, payload)); + d.Dispatch(env!.Value); +} +``` + +- [ ] **Step 2: Run the test to verify it fails** + +Run: `dotnet test tests/AcDream.Core.Net.Tests/AcDream.Core.Net.Tests.csproj --filter "FullyQualifiedName~ViewContents"` +Expected: FAIL — `WireAll_ViewContents_IsFullReplace_DropsRemovedItems` asserts `GetContents` has 1 entry, but the current additive `RecordMembership` loop leaves both (count 2) and `0x50000A01` keeps `ContainerId == 0xC9`. + +- [ ] **Step 3: Replace the handler body** + +In `GameEventWiring.cs`, replace the block at `:253-266` (the comment + handler) with: + +```csharp + // ViewContents (0x0196) — the server's AUTHORITATIVE full contents list for a container you + // opened (Use 0x0036). Treat it as a full REPLACE: flush the container's prior membership and + // record the new entries in order (ContainerSlot = index — ACE writes entries + // OrderBy(PlacementPosition) with no slot field). The container-open UI (InventoryController) + // repaints the grid off the resulting ObjectAdded/Moved events. Retail: ClientUISystem::OnViewContents. + dispatcher.Register(GameEventType.ViewContents, e => + { + var p = GameEvents.ParseViewContents(e.Payload.Span); + if (p is null) return; + var guids = new uint[p.Value.Items.Count]; + for (int i = 0; i < guids.Length; i++) guids[i] = p.Value.Items[i].Guid; + items.ReplaceContents(p.Value.ContainerGuid, guids); + }); +``` + +- [ ] **Step 4: Run the test to verify it passes** + +Run: `dotnet test tests/AcDream.Core.Net.Tests/AcDream.Core.Net.Tests.csproj --filter "FullyQualifiedName~ViewContents"` +Expected: PASS (both the existing membership test and the new full-replace test). + +- [ ] **Step 5: Commit** + +```bash +git add src/AcDream.Core.Net/GameEventWiring.cs tests/AcDream.Core.Net.Tests/GameEventWiringTests.cs +git commit -m "feat(D.2b): ViewContents wiring is a full REPLACE (consumes the GameEventWiring TODO) + +Co-Authored-By: Claude Opus 4.8 (1M context) " +``` + +--- + +## Task 3: `WorldSession.SendUse` (thin wire wrapper) + +**Files:** +- Modify: `src/AcDream.Core.Net/WorldSession.cs` (add after `SendNoLongerViewingContents`, ~line 1216) + +**Test note:** no new unit test. The wire contract (`Use 0x0036` + target guid) is already locked by `tests/AcDream.Core.Net.Tests/Messages/InteractRequestsTests.cs::BuildUse_WritesOpcode0x0036AndTarget`. `SendUse` is a trivial pass-through that mirrors the adjacent, test-free wrappers (`SendNoLongerViewingContents`, `SendDropItem`, `SendAddShortcut`) — there is no send-capture seam on `WorldSession` to assert against, and the round-trip is confirmed at the WireMCP visual gate (Task 8). + +- [ ] **Step 1: Add the wrapper** + +In `WorldSession.cs`, directly after `SendNoLongerViewingContents` (~line 1216), add: + +```csharp + /// Send Use (0x0036) — open/use an object by guid (e.g. open a container in your + /// inventory). A direct wire send: opening a pack you already hold needs no autowalk, unlike + /// GameWindow.SendUse (the world-interaction path). Retail: CM_Physics::Event_Use. + public void SendUse(uint targetGuid) + { + uint seq = NextGameActionSequence(); + SendGameAction(InteractRequests.BuildUse(seq, targetGuid)); + } +``` + +(`InteractRequests` is in `AcDream.Core.Net.Messages`; confirm the `using AcDream.Core.Net.Messages;` is present at the top of `WorldSession.cs` — it is, used by the other Send wrappers.) + +- [ ] **Step 2: Verify it compiles** + +Run: `dotnet build src/AcDream.Core.Net/AcDream.Core.Net.csproj` +Expected: Build succeeded. + +- [ ] **Step 3: Commit** + +```bash +git add src/AcDream.Core.Net/WorldSession.cs +git commit -m "feat(D.2b): WorldSession.SendUse — thin Use 0x0036 wire wrapper for container-open + +Co-Authored-By: Claude Opus 4.8 (1M context) " +``` + +--- + +## Task 4: `UiItemSlot` indicator overlays (triangle + square) + +**Files:** +- Modify: `src/AcDream.App/UI/UiItemSlot.cs` (add properties near `DragAcceptSprite` ~line 36; add draw in `OnDraw` ~line 226) +- Test: `tests/AcDream.App.Tests/UI/UiItemSlotTests.cs` (add after the DefaultEmptySprite test) + +- [ ] **Step 1: Write the failing tests** + +Add to `UiItemSlotTests.cs`: + +```csharp +// ── Container-switching indicator overlays (decomp SetOpenContainerState 0x004e1200 / +// SetSelectedState 0x004e1240) ─────────────────────────────────────────────────────── +[Fact] +public void Selected_defaultsFalse() => Assert.False(new UiItemSlot().Selected); + +[Fact] +public void IsOpenContainer_defaultsFalse() => Assert.False(new UiItemSlot().IsOpenContainer); + +[Fact] +public void SelectedSprite_default_isGreenYellowSquare() + => Assert.Equal(0x06004D21u, new UiItemSlot().SelectedSprite); + +[Fact] +public void OpenContainerSprite_default_isTriangle() + => Assert.Equal(0x06005D9Cu, new UiItemSlot().OpenContainerSprite); +``` + +- [ ] **Step 2: Run the tests to verify they fail** + +Run: `dotnet test tests/AcDream.App.Tests/AcDream.App.Tests.csproj --filter "FullyQualifiedName~UiItemSlotTests"` +Expected: FAIL — `'UiItemSlot' does not contain a definition for 'Selected'` (compile error). + +- [ ] **Step 3: Add the properties** + +In `UiItemSlot.cs`, after the `DragRejectSprite` property (~line 39), add: + +```csharp + /// True when this cell is the OPEN container (its contents fill the grid). Draws the + /// open-container triangle. Port of UIElement_ItemList::UpdateOpenContainerIndicator + /// (0x004e3070) → SetOpenContainerState (0x004e1200): shown when item.itemID == openContainerId. + public bool IsOpenContainer { get; set; } + /// Open-container triangle sprite (element 0x10000450 on the container prototype + /// 0x1000033F). Configurable; guard id != 0 before resolving. + public uint OpenContainerSprite { get; set; } = 0x06005D9Cu; + + /// True when this cell is the SELECTED item. Draws the green/yellow selection square. + /// Port of UIElement_ItemList::ItemList_SetSelectedItem (0x004e2fe0) → SetSelectedState + /// (0x004e1240): shown when item.itemID == selectedItemId. Uniform across item + container cells. + public bool Selected { get; set; } + /// Selected-item square sprite (element 0x10000342 on the 32×32 item prototype + /// 0x10000341; pixel-confirmed green/yellow frame). Drawn as a procedural overlay so it renders + /// on the 36×36 container cell too (whose prototype lacks the square child). + public uint SelectedSprite { get; set; } = 0x06004D21u; +``` + +- [ ] **Step 4: Add the draw calls** + +In `UiItemSlot.cs` `OnDraw`, immediately BEFORE the drag-rollover block (the `if (_dragAccept != DragAcceptState.None …`, ~line 226), add: + +```csharp + // Open-container triangle (retail SetOpenContainerState 0x004e1200) + selected-item square + // (retail SetSelectedState 0x004e1240). Drawn procedurally like the digit/drag overlays; + // guard id != 0 BEFORE resolving (feedback_ui_resolve_zero_magenta). Triangle under the + // square; both under the transient drag overlay below. + if (IsOpenContainer && SpriteResolve is not null && OpenContainerSprite != 0) + { + var (tex, _, _) = SpriteResolve(OpenContainerSprite); + if (tex != 0) + ctx.DrawSprite(tex, 0f, 0f, Width, Height, 0f, 0f, 1f, 1f, Vector4.One); + } + if (Selected && SpriteResolve is not null && SelectedSprite != 0) + { + var (tex, _, _) = SpriteResolve(SelectedSprite); + if (tex != 0) + ctx.DrawSprite(tex, 0f, 0f, Width, Height, 0f, 0f, 1f, 1f, Vector4.One); + } +``` + +- [ ] **Step 5: Run the tests to verify they pass** + +Run: `dotnet test tests/AcDream.App.Tests/AcDream.App.Tests.csproj --filter "FullyQualifiedName~UiItemSlotTests"` +Expected: PASS. + +- [ ] **Step 6: Commit** + +```bash +git add src/AcDream.App/UI/UiItemSlot.cs tests/AcDream.App.Tests/UI/UiItemSlotTests.cs +git commit -m "feat(D.2b): UiItemSlot open-container triangle + selected-item square overlays + +Co-Authored-By: Claude Opus 4.8 (1M context) " +``` + +--- + +## Task 5: `InventoryController` container-switching + indicators + +**Files:** +- Modify: `src/AcDream.App/UI/Layout/InventoryController.cs` (state, `Bind` signature, `Populate`, click handlers, `ApplyIndicators`, `Concerns`, caption) +- Test: `tests/AcDream.App.Tests/UI/Layout/InventoryControllerTests.cs` + +- [ ] **Step 1: Extend the test `Bind` helper + write the failing tests** + +In `InventoryControllerTests.cs`, replace the private `Bind` helper (lines 53-57) with: + +```csharp + private static InventoryController Bind(ImportedLayout layout, ClientObjectTable objects, + int? strength = 100, List? uses = null, List? closes = null) + => InventoryController.Bind(layout, objects, () => Player, + iconIds: (_, _, _, _, _) => 0u, // no real GL upload in tests + strength: () => strength, datFont: null, + sendUse: uses is null ? null : g => uses.Add(g), + sendNoLongerViewing: closes is null ? null : g => closes.Add(g)); + + // Seed a side bag (a container) in the player's pack, plus optionally its own contents. + private static void SeedBag(ClientObjectTable t, uint bag, int slot, int itemsCapacity = 24) + { + t.AddOrUpdate(new ClientObject { ObjectId = bag, Type = ItemType.Container, ItemsCapacity = itemsCapacity }); + t.MoveItem(bag, Player, slot); + } +``` + +Then add these tests at the end of the class (before the closing brace): + +```csharp + [Fact] + public void ClickSideBag_sendsUse_andSwapsGridToBagContents() + { + var (layout, grid, containers, _, _, _, _, _) = BuildLayout(); + var objects = new ClientObjectTable(); + SeedContained(objects, 0xA, Player, slot: 0); // a loose main-pack item + SeedBag(objects, 0xC, slot: 1); // side bag (player slot 1) + SeedContained(objects, 0xB1, 0xC, slot: 0); // a thing already known to be in the bag + var uses = new List(); + Bind(layout, objects, uses: uses); + + // The side-bag column's first cell holds the bag guid; click it. + containers.GetItem(0)!.Clicked!(); + + Assert.Contains(0xCu, uses); // Use(bag) sent + Assert.Equal(0xB1u, grid.GetItem(0)!.ItemId); // grid swapped to the bag's contents + Assert.Equal(24, grid.GetNumUIItems()); // padded to the bag's ItemsCapacity + } + + [Fact] + public void ClickMainPackCell_afterBag_closesBag_returnsToMainPack() + { + var (layout, grid, containers, top, _, _, _, _) = BuildLayout(); + var objects = new ClientObjectTable(); + SeedContained(objects, 0xA, Player, slot: 0); // loose main-pack item + SeedBag(objects, 0xC, slot: 1); + SeedContained(objects, 0xB1, 0xC, slot: 0); + var uses = new List(); + var closes = new List(); + var ctrl = Bind(layout, objects, uses: uses, closes: closes); + + containers.GetItem(0)!.Clicked!(); // open the bag + top.GetItem(0)!.Clicked!(); // click the main-pack cell + + Assert.Contains(0xCu, closes); // NoLongerViewingContents(bag) sent + Assert.DoesNotContain(Player, uses); // no Use for the main pack + Assert.Equal(0xAu, grid.GetItem(0)!.ItemId); // grid back to the main pack + Assert.Equal(102, grid.GetNumUIItems()); + } + + [Fact] + public void SwitchBetweenTwoBags_closesPrevious_opensNext() + { + var (layout, _, containers, _, _, _, _, _) = BuildLayout(); + var objects = new ClientObjectTable(); + SeedBag(objects, 0xC1, slot: 0); + SeedBag(objects, 0xC2, slot: 1); + var uses = new List(); + var closes = new List(); + Bind(layout, objects, uses: uses, closes: closes); + + containers.GetItem(0)!.Clicked!(); // open bag A (column index 0 → 0xC1) + containers.GetItem(1)!.Clicked!(); // open bag B (column index 1 → 0xC2) + + Assert.Equal(new[] { 0xC1u, 0xC2u }, uses.ToArray()); // Use(A) then Use(B) + Assert.Equal(new[] { 0xC1u }, closes.ToArray()); // close A when switching to B (not the main pack) + } + + [Fact] + public void OpenBag_marksTriangleAndSquare_onTheBagCell() + { + var (layout, _, containers, _, _, _, _, _) = BuildLayout(); + var objects = new ClientObjectTable(); + SeedBag(objects, 0xC, slot: 0); + Bind(layout, objects, uses: new List()); + + containers.GetItem(0)!.Clicked!(); + + Assert.True(containers.GetItem(0)!.IsOpenContainer); // triangle on the open bag + Assert.True(containers.GetItem(0)!.Selected); // square — the bag is also the selected item + } + + [Fact] + public void ClickGridItem_movesSquareOnly_noWire_keepsOpenContainer() + { + var (layout, grid, _, top, _, _, _, _) = BuildLayout(); + var objects = new ClientObjectTable(); + SeedContained(objects, 0xA, Player, slot: 0); + var uses = new List(); + var closes = new List(); + Bind(layout, objects, uses: uses, closes: closes); + + grid.GetItem(0)!.Clicked!(); // select the loose item + + Assert.True(grid.GetItem(0)!.Selected); // square on the selected grid item + Assert.True(top.GetItem(0)!.IsOpenContainer); // main pack still the open container (unchanged) + Assert.Empty(uses); // selection sends no wire + Assert.Empty(closes); + } + + [Fact] + public void Default_mainPackCell_isOpenContainer() + { + var (layout, _, _, top, _, _, _, _) = BuildLayout(); + var objects = new ClientObjectTable(); + Bind(layout, objects); + Assert.True(top.GetItem(0)!.IsOpenContainer); // default open container = main pack + } +``` + +Note: `BuildLayout()` returns `top` as the 4th tuple element (the `_topContainer` UiItemList); the existing tests discard it with `_`. The new tests above bind it where needed. + +- [ ] **Step 2: Run the tests to verify they fail** + +Run: `dotnet test tests/AcDream.App.Tests/AcDream.App.Tests.csproj --filter "FullyQualifiedName~InventoryControllerTests"` +Expected: FAIL — `InventoryController.Bind` has no `sendUse`/`sendNoLongerViewing` parameters (compile error). + +- [ ] **Step 3: Add controller state + new ctor/Bind parameters** + +In `InventoryController.cs`: + +(a) Add constant near `MainPackSlots` (~line 39): +```csharp + private const int SidePackSlots = 24; // standard side-pack capacity (reference_retail_inventory_paperdoll) +``` + +(b) Add fields near `_burdenPercent` (~line 52): +```csharp + private uint _openContainer; // 0 = the main pack (the player); else the open side bag's guid + private uint _selectedItem; // 0 = none; the panel-wide selected item (green square) + private readonly Action? _sendUse; + private readonly Action? _sendNoLongerViewing; +``` + +(c) Add ctor parameters (append to the existing private ctor parameter list, after `mainPackEmptySprite`): +```csharp + uint mainPackEmptySprite, + Action? sendUse, + Action? sendNoLongerViewing) +``` +and assign them at the top of the ctor body (after `_strength = strength;`): +```csharp + _sendUse = sendUse; + _sendNoLongerViewing = sendNoLongerViewing; +``` + +(d) Add the same two parameters to the public `Bind` (after `mainPackEmptySprite = 0u`), and pass them through: +```csharp + uint mainPackEmptySprite = 0u, + Action? sendUse = null, + Action? sendNoLongerViewing = null) + => new InventoryController(layout, objects, playerGuid, iconIds, strength, datFont, + contentsEmptySprite, sideBagEmptySprite, mainPackEmptySprite, + sendUse, sendNoLongerViewing); +``` + +- [ ] **Step 4: Replace `Populate` + add the new private methods** + +Replace the entire `Populate` method body with: + +```csharp + public void Populate() + { + uint p = _playerGuid(); + uint open = EffectiveOpen(); + + _contentsGrid?.Flush(); + _containerList?.Flush(); + + // Side-bag column: ALWAYS the player's bags (constant across container switches; only the + // open/selected indicators move). Equipped items never appear here. + foreach (var guid in _objects.GetContents(p)) + { + var item = _objects.Get(guid); + if (item is null || item.CurrentlyEquippedLocation != EquipMask.None) continue; + bool isBag = item.Type.HasFlag(ItemType.Container) || item.ItemsCapacity > 0; + if (isBag) AddCell(_containerList, guid, isContainer: true); + } + + // Contents grid: the OPEN container's loose items. (Bags live in the column; a side bag has + // no sub-bags, so the isBag skip is a no-op when a bag is open.) + foreach (var guid in _objects.GetContents(open)) + { + var item = _objects.Get(guid); + if (item is null || item.CurrentlyEquippedLocation != EquipMask.None) continue; + bool isBag = item.Type.HasFlag(ItemType.Container) || item.ItemsCapacity > 0; + if (!isBag) AddCell(_contentsGrid, guid, isContainer: false); + } + + // Pad the grid to the OPEN container's capacity (main pack 102 / side bag 24). + if (_contentsGrid is not null) + { + int cap = _objects.Get(open)?.ItemsCapacity ?? 0; + int slots = cap > 0 ? cap : (open == p ? MainPackSlots : SidePackSlots); + while (_contentsGrid.GetNumUIItems() < slots) AddEmptyCell(_contentsGrid); + } + + // Pad the side-bag column to capacity, clamped to the 7-slot column (AP-52). + if (_containerList is not null) + { + int capacity = _objects.Get(p)?.ContainersCapacity ?? 0; + int bags = _containerList.GetNumUIItems(); + int slots = capacity > 0 ? capacity : SideBagSlots; + slots = Math.Max(slots, bags); + slots = Math.Min(slots, SideBagSlots); + while (_containerList.GetNumUIItems() < slots) AddEmptyCell(_containerList); + } + + // Main-pack cell: the player's own container — clicking it opens/selects the main pack. + if (_topContainer is not null) + { + _topContainer.Flush(); + var main = new UiItemSlot { SpriteResolve = _topContainer.SpriteResolve }; + main.SetItem(p, 0u); + main.Clicked = () => OpenContainer(p); + _topContainer.AddItem(main); + } + + ApplyIndicators(); + RefreshBurden(); + } + + /// The effective open container — the explicit one, or the player (main pack) by default. + /// Resolved live (not cached at ctor) so a late-arriving player guid is handled. + private uint EffectiveOpen() => _openContainer != 0 ? _openContainer : _playerGuid(); + + /// Add a populated cell wired to its click role: container cell → open+select, + /// item cell → select-only. + private void AddCell(UiItemList? list, uint guid, bool isContainer) + { + if (list is null) return; + var item = _objects.Get(guid); + uint tex = item is null ? 0u + : _iconIds(item.Type, item.IconId, item.IconUnderlayId, item.IconOverlayId, item.Effects); + var cell = new UiItemSlot { SpriteResolve = list.SpriteResolve }; + cell.SetItem(guid, tex); + if (isContainer) cell.Clicked = () => OpenContainer(guid); + else cell.Clicked = () => SelectItem(guid); + list.AddItem(cell); + } + + private static void AddEmptyCell(UiItemList list) + => list.AddItem(new UiItemSlot { SpriteResolve = list.SpriteResolve }); + + /// Select an item (panel-wide green square) without changing the open container or + /// touching the wire. Retail: UIElement_ItemList::ItemList_SetSelectedItem (0x004e2fe0). + private void SelectItem(uint guid) + { + if (guid == 0) return; + _selectedItem = guid; + ApplyIndicators(); + } + + /// Open a container (side bag or main pack): it becomes both the selected item and the + /// open container. Sends Use to open a side bag server-side (ViewContents arrives async), and + /// NoLongerViewingContents when switching away from a previously-open side bag. Retail: + /// gmBackpackUI container-selection + CM_Physics::Event_Use. + private void OpenContainer(uint guid) + { + if (guid == 0) return; + _selectedItem = guid; // the container cell is also the selected item + uint open = EffectiveOpen(); + if (guid == open) { ApplyIndicators(); return; } // already open — just move the square + + uint p = _playerGuid(); + if (open != p && open != 0) + _sendNoLongerViewing?.Invoke(open); // close the previously-open side bag + _openContainer = guid; + if (guid != p) + _sendUse?.Invoke(guid); // open the side bag (ViewContents will land) + Populate(); // repaint the grid for the new open container + } + + /// Stamp the open-container triangle + selected-item square onto every cell per the + /// retail keying (item.itemID == openContainerId / selectedItemId). + private void ApplyIndicators() + { + SetIndicators(_contentsGrid); + SetIndicators(_containerList); + SetIndicators(_topContainer); + } + + private void SetIndicators(UiItemList? list) + { + if (list is null) return; + uint open = EffectiveOpen(); + for (int i = 0; i < list.GetNumUIItems(); i++) + { + var cell = list.GetItem(i); + if (cell is null) continue; + cell.Selected = cell.ItemId != 0 && cell.ItemId == _selectedItem; + cell.IsOpenContainer = cell.ItemId != 0 && cell.ItemId == open; + } + } +``` + +- [ ] **Step 5: Extend `Concerns` + the caption** + +(a) In `Concerns`, add the open-container clause. Replace the return with: +```csharp + uint p = _playerGuid(); + return o.ObjectId == p // the player object IS the burden source + || o.ContainerId == p || o.WielderId == p + || o.ContainerId == EffectiveOpen() // the OPEN container's contents drive the grid + || (o.ContainerId != 0 && _objects.Get(o.ContainerId)?.ContainerId == p); // 2-deep +``` + +(b) In the ctor, change the contents-caption attach (currently `() => "Contents of Backpack"`) to follow the open container: +```csharp + AttachCaption(layout.FindElement(ContentsCaptionId), () => "Contents of " + OpenContainerName(), datFont); +``` +and add the helper near `EffectiveOpen`: +```csharp + /// The open container's display name for the contents caption. + private string OpenContainerName() + { + uint open = EffectiveOpen(); + return open == _playerGuid() ? "Backpack" : (_objects.Get(open)?.Name ?? "Backpack"); + } +``` + +- [ ] **Step 6: Run the tests to verify they pass** + +Run: `dotnet test tests/AcDream.App.Tests/AcDream.App.Tests.csproj --filter "FullyQualifiedName~InventoryControllerTests"` +Expected: PASS (the 6 new tests + all existing InventoryController tests). + +- [ ] **Step 7: Commit** + +```bash +git add src/AcDream.App/UI/Layout/InventoryController.cs tests/AcDream.App.Tests/UI/Layout/InventoryControllerTests.cs +git commit -m "feat(D.2b): InventoryController container-switching + open/selected indicators + +Co-Authored-By: Claude Opus 4.8 (1M context) " +``` + +--- + +## Task 6: wire the send callbacks in `GameWindow` + +**Files:** +- Modify: `src/AcDream.App/Rendering/GameWindow.cs:2231-2241` (the `InventoryController.Bind` call) + +**Test note:** `GameWindow` is integration-wired, not unit-tested; verified by build + the visual gate (Task 8). + +- [ ] **Step 1: Add the two callbacks to the Bind call** + +In `GameWindow.cs`, in the `InventoryController.Bind(...)` call (~line 2231), append two arguments after `mainPackEmptySprite: mainPackEmpty`: + +```csharp + contentsEmptySprite: contentsEmpty, + sideBagEmptySprite: sideBagEmpty, + mainPackEmptySprite: mainPackEmpty, + sendUse: g => _liveSession?.SendUse(g), + sendNoLongerViewing: g => _liveSession?.SendNoLongerViewingContents(g)); +``` + +- [ ] **Step 2: Verify it compiles** + +Run: `dotnet build src/AcDream.App/AcDream.App.csproj` +Expected: Build succeeded. + +- [ ] **Step 3: Commit** + +```bash +git add src/AcDream.App/Rendering/GameWindow.cs +git commit -m "feat(D.2b): wire InventoryController container-open callbacks to the live session + +Co-Authored-By: Claude Opus 4.8 (1M context) " +``` + +--- + +## Task 7: divergence register + docs + +**Files:** +- Modify: `docs/architecture/retail-divergence-register.md` (retire AP-56; reword AP-53; add new rows) + +- [ ] **Step 1: Retire AP-56** + +Delete the AP-56 row (line ~158) — both indicators are now drawn. + +- [ ] **Step 2: Reword AP-53** + +Change AP-53's text from "the grid always shows the main pack (container-switch to a side pack's 24 not wired)" / "selecting a side pack doesn't show its 24-slot contents yet" to note that container-switching is now wired and the row covers only the capacity *default* (102/24 when `ItemsCapacity`/`ContainersCapacity` is absent). + +- [ ] **Step 3: Add the new rows** (use the next free AP-numbers after the current max; e.g. AP-57/AP-58): + +- A row: the open-container triangle (`0x06005D9C`) + selected-item square (`0x06004D21`) are drawn as **procedural `UiItemSlot` overlays** keyed by controller state (`_openContainer`/`_selectedItem`), reproducing the retail keying (`item.itemID == openContainerId / selectedItemId` per `UpdateOpenContainerIndicator 0x004e3070` / `ItemList_SetSelectedItem 0x004e2fe0`) but not via the dat prototype's `m_elem_Icon_OpenContainer`/`m_elem_Icon_Selected` state elements + `SetOpenContainerState`/`SetSelectedState` calls. File: `src/AcDream.App/UI/UiItemSlot.cs`, `src/AcDream.App/UI/Layout/InventoryController.cs`. Risk: a cell that should show an indicator via a dat-state path retail uses but we don't would be missed; outcome currently matches retail (the 36×36 container prototype lacks the square child, so the procedural overlay is the only way to show the square on a selected bag). +- A row: inventory item **selection is panel-local + visual-only** — not wired to the selected-object bar (`SelectedObjectController`) or to global 3D-world selection. File: `src/AcDream.App/UI/Layout/InventoryController.cs`. Risk: selecting an inventory item doesn't populate the selected-object strip as retail does; deferred to the selection follow-up. + +- [ ] **Step 4: Commit** + +```bash +git add docs/architecture/retail-divergence-register.md +git commit -m "docs(D.2b): divergence register — retire AP-56, reword AP-53, add overlay/selection rows + +Co-Authored-By: Claude Opus 4.8 (1M context) " +``` + +--- + +## Task 8: full-suite gate + visual verification + +**Files:** none (verification only). + +- [ ] **Step 1: Full build + full test suite** + +Run: `dotnet build` then `dotnet test` +Expected: Build succeeded; ALL tests pass (run the FULL suite, not a filtered subset — the B-Wire process lesson: a filtered batch hid a cross-file regression). + +- [ ] **Step 2: Launch the client (plain `dotnet run`, DO NOT pre-kill any running client)** + +If a rebuild is locked by a running client, ASK the user to close it — do not Stop-Process. Launch (PowerShell, background, per CLAUDE.md): + +```powershell +$env:ACDREAM_DAT_DIR="$env:USERPROFILE\Documents\Asheron's Call"; $env:ACDREAM_LIVE="1"; $env:ACDREAM_TEST_HOST="127.0.0.1"; $env:ACDREAM_TEST_PORT="9000"; $env:ACDREAM_TEST_USER="testaccount"; $env:ACDREAM_TEST_PASS="testpassword"; $env:ACDREAM_RETAIL_UI="1" +dotnet run --project src\AcDream.App\AcDream.App.csproj --no-build -c Debug 2>&1 | Tee-Object -FilePath "launch.log" +``` + +- [ ] **Step 3: Capture the Use → ViewContents round-trip** (settles lazy-load-on-open) + +While the client is in-world with F12 open, capture loopback with WireMCP (`capture_packets` on the `127.0.0.1:9000` conversation) and/or grep `launch.log` for the ViewContents handler, while the user clicks a side bag. Confirm a `Use 0x0036` goes out and a `ViewContents 0x0196` comes back, and note whether the bag's contents arrived only on open. + +- [ ] **Step 4: Visual gate (the oracle — user confirms)** + +The user, with F12 open: clicks a side bag → grid swaps to its contents; the open bag shows the triangle; the clicked cell shows the green/yellow square; clicks another bag → grid + indicators follow + the previous bag is closed; clicks the main-pack cell → grid returns to the main pack. Iterate on any sprite/positioning mismatch (the visual gate is the oracle for WHICH sprite, per the empty-slot-art lesson). + +- [ ] **Step 5: Update SSOT + roadmap after the gate passes** + +Append a "container-switching SHIPPED" entry to `claude-memory/project_d2b_retail_ui.md` (key facts + any DO-NOT-RETRY from the gate), update the roadmap/milestones if a milestone marker moved, and record the lazy-load finding. Commit. + +--- + +## Self-review + +**Spec coverage:** ReplaceContents (T1) ↔ spec §Components.1; ViewContents full-replace (T2) ↔ §Components.2; SendUse (T3) ↔ §Components.3; UiItemSlot overlays (T4) ↔ §Components.4; InventoryController state/Populate/clicks/indicators/Concerns/caption (T5) ↔ §Components.5; GameWindow wiring (T6) ↔ §Components.6; divergence rows (T7) ↔ §Divergence register; full suite + visual gate + lazy-load capture (T8) ↔ §Acceptance + §Open verification. All spec sections covered. + +**Placeholder scan:** every code step shows complete code; no TBD/TODO; the only "as appropriate" is T7's next-free-AP-number, which is mechanical (read the current max in the register). + +**Type consistency:** `ReplaceContents(uint, IReadOnlyList)` consistent T1↔T2. `SendUse(uint)` T3↔T6. `IsOpenContainer`/`OpenContainerSprite`/`Selected`/`SelectedSprite` consistent T4↔T5. `Bind(..., Action? sendUse, Action? sendNoLongerViewing)` consistent T5↔T6 (test helper)↔T6 (GameWindow). `EffectiveOpen()`/`OpenContainer()`/`SelectItem()`/`ApplyIndicators()`/`SetIndicators()`/`AddCell()`/`AddEmptyCell()`/`OpenContainerName()` all defined in T5. `SidePackSlots` const defined T5 step 3a.