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

39 KiB
Raw Blame History

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 0x0036ViewContents 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:

[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:

    /// <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
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):

[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:

        // 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
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:

    /// <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
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:

// ── 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:

    /// <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:

        // 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
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:

    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):

    [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):

    private const int   SidePackSlots = 24;     // standard side-pack capacity (reference_retail_inventory_paperdoll)

(b) Add fields near _burdenPercent (~line 52):

    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):

        uint mainPackEmptySprite,
        Action<uint>? sendUse,
        Action<uint>? sendNoLongerViewing)

and assign them at the top of the ctor body (after _strength = strength;):

        _sendUse            = sendUse;
        _sendNoLongerViewing = sendNoLongerViewing;

(d) Add the same two parameters to the public Bind (after mainPackEmptySprite = 0u), and pass them through:

        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:

    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:

        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:

        AttachCaption(layout.FindElement(ContentsCaptionId), () => "Contents of " + OpenContainerName(), datFont);

and add the helper near EffectiveOpen:

    /// <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
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:

                    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
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

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):

$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.