acdream/docs/superpowers/plans/2026-06-22-d2b-container-switching.md
Erik 24a0b84162 docs(D.2b): container-switching implementation plan
8-task TDD plan: ReplaceContents (Core) -> ViewContents full-replace
wiring (Core.Net) -> SendUse wrapper -> UiItemSlot triangle/square
overlays -> InventoryController _openContainer/_selectedItem +
click roles + indicators -> GameWindow callback wiring -> divergence
register -> full-suite + visual gate. Exact code per step.

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

819 lines
39 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 — 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
/// <summary>
/// Replace a container's entire membership with <paramref name="guids"/> (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.
/// </summary>
public void ReplaceContents(uint containerId, IReadOnlyList<uint> guids)
{
ArgumentNullException.ThrowIfNull(guids);
if (containerId == 0) return;
var keep = new HashSet<uint>(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) <noreply@anthropic.com>"
```
---
## 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) <noreply@anthropic.com>"
```
---
## 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
/// <summary>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.</summary>
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) <noreply@anthropic.com>"
```
---
## 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
/// <summary>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.</summary>
public bool IsOpenContainer { get; set; }
/// <summary>Open-container triangle sprite (element 0x10000450 on the container prototype
/// 0x1000033F). Configurable; guard id != 0 before resolving.</summary>
public uint OpenContainerSprite { get; set; } = 0x06005D9Cu;
/// <summary>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.</summary>
public bool Selected { get; set; }
/// <summary>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).</summary>
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) <noreply@anthropic.com>"
```
---
## 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<uint>? uses = null, List<uint>? 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<uint>();
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<uint>();
var closes = new List<uint>();
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<uint>();
var closes = new List<uint>();
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<uint>());
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<uint>();
var closes = new List<uint>();
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<uint>? _sendUse;
private readonly Action<uint>? _sendNoLongerViewing;
```
(c) Add ctor parameters (append to the existing private ctor parameter list, after `mainPackEmptySprite`):
```csharp
uint mainPackEmptySprite,
Action<uint>? sendUse,
Action<uint>? 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<uint>? sendUse = null,
Action<uint>? 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();
}
/// <summary>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.</summary>
private uint EffectiveOpen() => _openContainer != 0 ? _openContainer : _playerGuid();
/// <summary>Add a populated cell wired to its click role: container cell → open+select,
/// item cell → select-only.</summary>
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 });
/// <summary>Select an item (panel-wide green square) without changing the open container or
/// touching the wire. Retail: UIElement_ItemList::ItemList_SetSelectedItem (0x004e2fe0).</summary>
private void SelectItem(uint guid)
{
if (guid == 0) return;
_selectedItem = guid;
ApplyIndicators();
}
/// <summary>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.</summary>
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
}
/// <summary>Stamp the open-container triangle + selected-item square onto every cell per the
/// retail keying (item.itemID == openContainerId / selectedItemId).</summary>
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
/// <summary>The open container's display name for the contents caption.</summary>
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) <noreply@anthropic.com>"
```
---
## 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) <noreply@anthropic.com>"
```
---
## 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) <noreply@anthropic.com>"
```
---
## 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<uint>)` consistent T1↔T2. `SendUse(uint)` T3↔T6. `IsOpenContainer`/`OpenContainerSprite`/`Selected`/`SelectedSprite` consistent T4↔T5. `Bind(..., Action<uint>? sendUse, Action<uint>? 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.