acdream/docs/superpowers/plans/2026-06-21-d2b-inventory-wire.md
Erik ca94d1d2f6 docs(D.2b-B): B-Wire implementation plan
16 bite-sized TDD tasks for the inventory wire pass: ClientObjectTable
UpsertProperties/UpdateStackSize (Core); PrivateUpdatePropertyInt 0x02CD,
SetStackSize 0x0197, InventoryRemoveObject 0x0024 parsers; ParseViewContents
0x0196; 0x0022 4th-field + 0x00A0 error fixes; DropItem/GetAndWieldItem/
NoLongerViewingContents builders + Send wrappers; PD player-property delivery
+ GameEvent registration; WorldSession dispatch; ObjectTableWiring apply-all-
ints + player-int route + stack/remove; GameWindow + InventoryController
wiring; divergence/ISSUES/roadmap/memory bookkeeping. Each pure parser/builder
/table-method is TDD'd; glue follows the codebase convention (dispatcher-
routed GameEvents unit-tested via the GameEventWiringTests harness, top-level
GameMessages build+run-verified). Plan derived from reading every call site.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-21 18:41:43 +02:00

59 KiB
Raw Blame History

D.2b-B B-Wire — inventory wire layer 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: Deliver the player's server-authoritative properties (so the burden bar reads the wire EncumbranceVal), fix two latent inbound-parse bugs, and add the missing inventory builders/parsers — completing the inventory wire layer so B-Drag and container-open are unblocked.

Architecture: All wire code is in AcDream.Core.Net (pure data, no GL). New pure parsers/builders/table-methods are TDD'd directly. The glue (WorldSession's GameMessage switch, ObjectTableWiring subscriptions, the GameEventWiring PD handler, GameWindow call sites) follows the codebase's existing convention: dispatcher-routed GameEvents are unit-tested through the GameEventWiringTests harness; top-level GameMessages (no test seam on WorldSession) are wired and verified by build + the parser tests + the live run.

Tech Stack: C# / .NET 10, xUnit, System.Buffers.Binary.BinaryPrimitives. Spec: docs/superpowers/specs/2026-06-21-d2b-inventory-wire-design.md. Oracle field orders: docs/research/2026-06-16-inventory-deep-dive.md §4 (ACE file:line + holtburger fixtures cited per format).

Mandatory per-format workflow: before porting each wire format, grep docs/research/named-retail/acclient_2013_pseudo_c.txt for the client symbol + confirm the field order against the cited ACE writer. The golden bytes in each test below are derived from those confirmed orders; if grep-named reveals a discrepancy, fix the one test + impl and note it.

Opcodes (pinned against references/ACE/.../GameMessageOpcode.cs + GameActionType.cs):

  • PrivateUpdatePropertyInt = 0x02CD (top-level GameMessage, no guid)
  • SetStackSize = 0x0197 (top-level GameMessage, UIQueue)
  • InventoryRemoveObject = 0x0024 (top-level GameMessage, UIQueue)
  • ViewContents = 0x0196 (GameEvent, inside 0xF7B0)
  • C→S GameActions: GetAndWieldItem = 0x001A, DropItem = 0x001B, NoLongerViewingContents = 0x0195

Namespace caution: SetStackSize 0x0197 / InventoryRemoveObject 0x0024 are GameMessage opcodes (WorldSession top-level switch, like 0x02CE). ViewContents 0x0196 is a GameEvent eventType (dispatcher path). Adjacent numbers, different dispatchers — don't cross them.


Task 1: ClientObjectTable.UpsertProperties (Core)

The PD handler needs to apply the player's property bundle even if the player's ClientObject doesn't exist yet (PD may arrive before the player's CreateObject). UpdateProperties no-ops on an unknown object; add a create-if-absent variant.

Files:

  • Modify: src/AcDream.Core/Items/ClientObjectTable.cs (add method after UpdateProperties, ~line 164)

  • Test: tests/AcDream.Core.Tests/Items/ClientObjectTableUpdateTests.cs (create)

  • Step 1: Write the failing test

Create tests/AcDream.Core.Tests/Items/ClientObjectTableUpdateTests.cs:

using AcDream.Core.Items;
using Xunit;

namespace AcDream.Core.Tests.Items;

public sealed class ClientObjectTableUpdateTests
{
    [Fact]
    public void UpsertProperties_unknownObject_createsThenMerges()
    {
        var t = new ClientObjectTable();
        bool added = false;
        t.ObjectAdded += _ => added = true;

        var bundle = new PropertyBundle();
        bundle.Ints[5] = 1234;     // EncumbranceVal

        t.UpsertProperties(0x50000001u, bundle);

        var o = t.Get(0x50000001u);
        Assert.NotNull(o);
        Assert.Equal(1234, o!.Properties.Ints[5]);
        Assert.True(added);
    }

    [Fact]
    public void UpsertProperties_existingObject_mergesAndFiresUpdated()
    {
        var t = new ClientObjectTable();
        t.AddOrUpdate(new ClientObject { ObjectId = 0x50000001u });
        bool updated = false;
        t.ObjectUpdated += _ => updated = true;

        var bundle = new PropertyBundle();
        bundle.Ints[5] = 99;
        t.UpsertProperties(0x50000001u, bundle);

        Assert.Equal(99, t.Get(0x50000001u)!.Properties.Ints[5]);
        Assert.True(updated);
    }
}
  • Step 2: Run test to verify it fails

Run: dotnet test tests/AcDream.Core.Tests/AcDream.Core.Tests.csproj --filter "FullyQualifiedName~ClientObjectTableUpdateTests" Expected: FAIL — compile error, ClientObjectTable has no UpsertProperties.

  • Step 3: Write minimal implementation

In src/AcDream.Core/Items/ClientObjectTable.cs, immediately after the UpdateProperties method (the one ending at ~line 164), add:

    /// <summary>
    /// Apply a <see cref="PropertyBundle"/> patch, creating the object if it does not
    /// exist yet. Used for the local player's own properties from PlayerDescription
    /// (0x0013), which may arrive BEFORE the player's CreateObject — unlike
    /// <see cref="UpdateProperties"/>, which no-ops on an unknown object. Fires
    /// ObjectAdded on create, else ObjectUpdated.
    /// </summary>
    public void UpsertProperties(uint guid, PropertyBundle incoming)
    {
        ArgumentNullException.ThrowIfNull(incoming);
        bool existed = _objects.TryGetValue(guid, out var item);
        if (!existed || item is null)
        {
            item = new ClientObject { ObjectId = guid };
            _objects[guid] = item;
        }
        foreach (var kv in incoming.Ints)        item.Properties.Ints[kv.Key] = kv.Value;
        foreach (var kv in incoming.Int64s)      item.Properties.Int64s[kv.Key] = kv.Value;
        foreach (var kv in incoming.Bools)       item.Properties.Bools[kv.Key] = kv.Value;
        foreach (var kv in incoming.Floats)      item.Properties.Floats[kv.Key] = kv.Value;
        foreach (var kv in incoming.Strings)     item.Properties.Strings[kv.Key] = kv.Value;
        foreach (var kv in incoming.DataIds)     item.Properties.DataIds[kv.Key] = kv.Value;
        foreach (var kv in incoming.InstanceIds) item.Properties.InstanceIds[kv.Key] = kv.Value;
        if (!existed) ObjectAdded?.Invoke(item); else ObjectUpdated?.Invoke(item);
    }
  • Step 4: Run test to verify it passes

Run: dotnet test tests/AcDream.Core.Tests/AcDream.Core.Tests.csproj --filter "FullyQualifiedName~ClientObjectTableUpdateTests" Expected: PASS (2 tests).

  • Step 5: Commit
git add src/AcDream.Core/Items/ClientObjectTable.cs tests/AcDream.Core.Tests/Items/ClientObjectTableUpdateTests.cs
git commit -m "feat(core): D.2b-B B-Wire — ClientObjectTable.UpsertProperties (create-if-absent)"

Task 2: ClientObjectTable.UpdateStackSize (Core)

SetStackSize (0x0197) updates a stack's count + value. Add the typed apply method (the parser + glue come later).

Files:

  • Modify: src/AcDream.Core/Items/ClientObjectTable.cs (add after UpdateIntProperty, ~line 180)

  • Test: tests/AcDream.Core.Tests/Items/ClientObjectTableUpdateTests.cs (append)

  • Step 1: Write the failing test

Append to ClientObjectTableUpdateTests.cs (inside the class):

    [Fact]
    public void UpdateStackSize_knownObject_setsFieldsAndFiresUpdated()
    {
        var t = new ClientObjectTable();
        t.AddOrUpdate(new ClientObject { ObjectId = 0x600u, StackSize = 1, Value = 5 });
        bool updated = false;
        t.ObjectUpdated += _ => updated = true;

        bool ok = t.UpdateStackSize(0x600u, stackSize: 25, value: 125);

        Assert.True(ok);
        Assert.Equal(25, t.Get(0x600u)!.StackSize);
        Assert.Equal(125, t.Get(0x600u)!.Value);
        Assert.True(updated);
    }

    [Fact]
    public void UpdateStackSize_unknownObject_returnsFalse()
        => Assert.False(new ClientObjectTable().UpdateStackSize(0xDEADu, 1, 1));
  • Step 2: Run test to verify it fails

Run: dotnet test tests/AcDream.Core.Tests/AcDream.Core.Tests.csproj --filter "FullyQualifiedName~ClientObjectTableUpdateTests" Expected: FAIL — no UpdateStackSize.

  • Step 3: Write minimal implementation

In src/AcDream.Core/Items/ClientObjectTable.cs, after UpdateIntProperty (~line 180), add:

    /// <summary>
    /// Apply a SetStackSize (0x0197) update: set the object's StackSize + Value and
    /// fire ObjectUpdated so bound widgets refresh the quantity overlay. False if the
    /// object is unknown. Retail: ACCWeenieObject::ServerSaysSetStackSize (0x0058...).
    /// </summary>
    public bool UpdateStackSize(uint guid, int stackSize, int value)
    {
        if (!_objects.TryGetValue(guid, out var item)) return false;
        item.StackSize = stackSize;
        item.Value = value;
        ObjectUpdated?.Invoke(item);
        return true;
    }
  • Step 4: Run test to verify it passes

Run: dotnet test tests/AcDream.Core.Tests/AcDream.Core.Tests.csproj --filter "FullyQualifiedName~ClientObjectTableUpdateTests" Expected: PASS (4 tests).

  • Step 5: Commit
git add src/AcDream.Core/Items/ClientObjectTable.cs tests/AcDream.Core.Tests/Items/ClientObjectTableUpdateTests.cs
git commit -m "feat(core): D.2b-B B-Wire — ClientObjectTable.UpdateStackSize"

Task 3: PrivateUpdatePropertyInt (0x02CD) parser (Core.Net)

Live burden updates ride 0x02CD — the player's own object, no guid. Mirror PublicUpdatePropertyInt.cs.

Files:

  • Create: src/AcDream.Core.Net/Messages/PrivateUpdatePropertyInt.cs

  • Test: tests/AcDream.Core.Net.Tests/Messages/PrivateUpdatePropertyIntTests.cs

  • Step 1: Write the failing test

Create tests/AcDream.Core.Net.Tests/Messages/PrivateUpdatePropertyIntTests.cs:

using System.Buffers.Binary;
using AcDream.Core.Net.Messages;
using Xunit;

namespace AcDream.Core.Net.Tests.Messages;

public sealed class PrivateUpdatePropertyIntTests
{
    // 0x02CD body: opcode(4) + seq(1) + property(4) + value(4) = 13 bytes. NO guid.
    private static byte[] Build(uint property, int value, byte seq = 1, uint opcode = 0x02CDu)
    {
        var b = new byte[13];
        BinaryPrimitives.WriteUInt32LittleEndian(b.AsSpan(0), opcode);
        b[4] = seq;
        BinaryPrimitives.WriteUInt32LittleEndian(b.AsSpan(5), property);
        BinaryPrimitives.WriteInt32LittleEndian(b.AsSpan(9), value);
        return b;
    }

    [Fact]
    public void TryParse_encumbranceVal_returnsPropValue()
    {
        var p = PrivateUpdatePropertyInt.TryParse(Build(property: 5u, value: 1500));
        Assert.NotNull(p);
        Assert.Equal(5u, p!.Value.Property);
        Assert.Equal(1500, p.Value.Value);
    }

    [Fact]
    public void TryParse_wrongOpcode_returnsNull()
        => Assert.Null(PrivateUpdatePropertyInt.TryParse(Build(5, 1, opcode: 0x02CEu)));

    [Fact]
    public void TryParse_truncated_returnsNull()
        => Assert.Null(PrivateUpdatePropertyInt.TryParse(new byte[12]));
}
  • Step 2: Run test to verify it fails

Run: dotnet test tests/AcDream.Core.Net.Tests/AcDream.Core.Net.Tests.csproj --filter "FullyQualifiedName~PrivateUpdatePropertyIntTests" Expected: FAIL — PrivateUpdatePropertyInt does not exist.

  • Step 3: Write minimal implementation

Create src/AcDream.Core.Net/Messages/PrivateUpdatePropertyInt.cs:

using System;
using System.Buffers.Binary;

namespace AcDream.Core.Net.Messages;

/// <summary>
/// Inbound <c>PrivateUpdatePropertyInt (0x02CD)</c> — the server updates one
/// <c>PropertyInt</c> on the player's OWN object. Unlike the sibling
/// <see cref="PublicUpdatePropertyInt"/> (0x02CE), this carries NO guid (it
/// implicitly targets the local player). Burden (<c>EncumbranceVal</c>, PropertyInt 5)
/// changes ride this opcode on every pick-up / drop.
///
/// <para>Wire layout (ACE <c>GameMessagePrivateUpdatePropertyInt</c>):</para>
/// <code>
///   u32 opcode = 0x02CD
///   u8  sequence    // single byte (ByteSequence) — not honored, latest-wins (DR-4)
///   u32 property    // PropertyInt enum
///   i32 value
/// </code>
/// </summary>
public static class PrivateUpdatePropertyInt
{
    public const uint Opcode = 0x02CDu;

    public readonly record struct Parsed(uint Property, int Value);

    /// <summary>Parse a raw 0x02CD body. Returns null on opcode mismatch / truncation.</summary>
    public static Parsed? TryParse(ReadOnlySpan<byte> body)
    {
        if (body.Length < 13) return null;          // 4 + 1 + 4 + 4
        if (BinaryPrimitives.ReadUInt32LittleEndian(body) != Opcode) return null;
        int pos = 4;
        pos += 1;                                   // sequence byte (not honored)
        uint prop = BinaryPrimitives.ReadUInt32LittleEndian(body[pos..]); pos += 4;
        int value = BinaryPrimitives.ReadInt32LittleEndian(body[pos..]);
        return new Parsed(prop, value);
    }
}
  • Step 4: Run test to verify it passes

Run: dotnet test tests/AcDream.Core.Net.Tests/AcDream.Core.Net.Tests.csproj --filter "FullyQualifiedName~PrivateUpdatePropertyIntTests" Expected: PASS (3 tests).

  • Step 5: Commit
git add src/AcDream.Core.Net/Messages/PrivateUpdatePropertyInt.cs tests/AcDream.Core.Net.Tests/Messages/PrivateUpdatePropertyIntTests.cs
git commit -m "feat(net): D.2b-B B-Wire — PrivateUpdatePropertyInt (0x02CD) parser"

Task 4: SetStackSize (0x0197) parser (Core.Net)

Top-level GameMessage. Wire layout (ACE GameMessageSetStackSize.cs, size hint 17 ⇒ byte seq): opcode(4) + u8 seq + guid(4) + stackSize(4) + value(4).

Files:

  • Create: src/AcDream.Core.Net/Messages/SetStackSize.cs

  • Test: tests/AcDream.Core.Net.Tests/Messages/SetStackSizeTests.cs

  • Step 1: Write the failing test

Create tests/AcDream.Core.Net.Tests/Messages/SetStackSizeTests.cs:

using System.Buffers.Binary;
using AcDream.Core.Net.Messages;
using Xunit;

namespace AcDream.Core.Net.Tests.Messages;

public sealed class SetStackSizeTests
{
    private static byte[] Build(uint guid, int stackSize, int value, byte seq = 1, uint opcode = 0x0197u)
    {
        var b = new byte[17];
        BinaryPrimitives.WriteUInt32LittleEndian(b.AsSpan(0), opcode);
        b[4] = seq;
        BinaryPrimitives.WriteUInt32LittleEndian(b.AsSpan(5), guid);
        BinaryPrimitives.WriteInt32LittleEndian(b.AsSpan(9), stackSize);
        BinaryPrimitives.WriteInt32LittleEndian(b.AsSpan(13), value);
        return b;
    }

    [Fact]
    public void TryParse_validStack_returnsGuidStackValue()
    {
        var p = SetStackSize.TryParse(Build(0x50000A01u, stackSize: 25, value: 125));
        Assert.NotNull(p);
        Assert.Equal(0x50000A01u, p!.Value.Guid);
        Assert.Equal(25, p.Value.StackSize);
        Assert.Equal(125, p.Value.Value);
    }

    [Fact]
    public void TryParse_wrongOpcode_returnsNull()
        => Assert.Null(SetStackSize.TryParse(Build(1, 1, 1, opcode: 0x0196u)));

    [Fact]
    public void TryParse_truncated_returnsNull()
        => Assert.Null(SetStackSize.TryParse(new byte[16]));
}
  • Step 2: Run test to verify it fails

Run: dotnet test tests/AcDream.Core.Net.Tests/AcDream.Core.Net.Tests.csproj --filter "FullyQualifiedName~SetStackSizeTests" Expected: FAIL — SetStackSize does not exist.

  • Step 3: Write minimal implementation

Create src/AcDream.Core.Net/Messages/SetStackSize.cs:

using System;
using System.Buffers.Binary;

namespace AcDream.Core.Net.Messages;

/// <summary>
/// Inbound <c>SetStackSize (0x0197)</c> — a top-level GameMessage (UIQueue group),
/// NOT a GameEvent. The server updates a stack's count + value after a merge / split.
/// Client consumer: ACCWeenieObject::ServerSaysSetStackSize.
///
/// <para>Wire layout (ACE <c>GameMessageSetStackSize.cs</c>, size hint 17):</para>
/// <code>
///   u32 opcode = 0x0197
///   u8  sequence    // ByteSequence (UpdatePropertyInt) — not honored
///   u32 guid
///   i32 stackSize
///   i32 value
/// </code>
/// </summary>
public static class SetStackSize
{
    public const uint Opcode = 0x0197u;

    public readonly record struct Parsed(uint Guid, int StackSize, int Value);

    /// <summary>Parse a raw 0x0197 body. Returns null on opcode mismatch / truncation.</summary>
    public static Parsed? TryParse(ReadOnlySpan<byte> body)
    {
        if (body.Length < 17) return null;          // 4 + 1 + 4 + 4 + 4
        if (BinaryPrimitives.ReadUInt32LittleEndian(body) != Opcode) return null;
        int pos = 4;
        pos += 1;                                   // sequence byte (not honored)
        uint guid = BinaryPrimitives.ReadUInt32LittleEndian(body[pos..]); pos += 4;
        int stack = BinaryPrimitives.ReadInt32LittleEndian(body[pos..]); pos += 4;
        int value = BinaryPrimitives.ReadInt32LittleEndian(body[pos..]);
        return new Parsed(guid, stack, value);
    }
}
  • Step 4: Run test to verify it passes

Run: dotnet test tests/AcDream.Core.Net.Tests/AcDream.Core.Net.Tests.csproj --filter "FullyQualifiedName~SetStackSizeTests" Expected: PASS (3 tests).

  • Step 5: Commit
git add src/AcDream.Core.Net/Messages/SetStackSize.cs tests/AcDream.Core.Net.Tests/Messages/SetStackSizeTests.cs
git commit -m "feat(net): D.2b-B B-Wire — SetStackSize (0x0197) parser"

Task 5: InventoryRemoveObject (0x0024) parser (Core.Net)

Top-level GameMessage. Wire layout (ACE GameMessageInventoryRemoveObject.cs, size hint 8): opcode(4) + guid(4).

Files:

  • Create: src/AcDream.Core.Net/Messages/InventoryRemoveObject.cs

  • Test: tests/AcDream.Core.Net.Tests/Messages/InventoryRemoveObjectTests.cs

  • Step 1: Write the failing test

Create tests/AcDream.Core.Net.Tests/Messages/InventoryRemoveObjectTests.cs:

using System.Buffers.Binary;
using AcDream.Core.Net.Messages;
using Xunit;

namespace AcDream.Core.Net.Tests.Messages;

public sealed class InventoryRemoveObjectTests
{
    private static byte[] Build(uint guid, uint opcode = 0x0024u)
    {
        var b = new byte[8];
        BinaryPrimitives.WriteUInt32LittleEndian(b.AsSpan(0), opcode);
        BinaryPrimitives.WriteUInt32LittleEndian(b.AsSpan(4), guid);
        return b;
    }

    [Fact]
    public void TryParse_valid_returnsGuid()
    {
        var p = InventoryRemoveObject.TryParse(Build(0x50000A07u));
        Assert.NotNull(p);
        Assert.Equal(0x50000A07u, p!.Value.Guid);
    }

    [Fact]
    public void TryParse_wrongOpcode_returnsNull()
        => Assert.Null(InventoryRemoveObject.TryParse(Build(1, opcode: 0x0023u)));

    [Fact]
    public void TryParse_truncated_returnsNull()
        => Assert.Null(InventoryRemoveObject.TryParse(new byte[7]));
}
  • Step 2: Run test to verify it fails

Run: dotnet test tests/AcDream.Core.Net.Tests/AcDream.Core.Net.Tests.csproj --filter "FullyQualifiedName~InventoryRemoveObjectTests" Expected: FAIL — InventoryRemoveObject does not exist.

  • Step 3: Write minimal implementation

Create src/AcDream.Core.Net/Messages/InventoryRemoveObject.cs:

using System;
using System.Buffers.Binary;

namespace AcDream.Core.Net.Messages;

/// <summary>
/// Inbound <c>InventoryRemoveObject (0x0024)</c> — a top-level GameMessage (UIQueue),
/// NOT a GameEvent. The server tells the client an object left its inventory view
/// (given away / sold / destroyed). The client drops it from object maintenance.
///
/// <para>Wire layout (ACE <c>GameMessageInventoryRemoveObject.cs</c>, size hint 8):</para>
/// <code>
///   u32 opcode = 0x0024
///   u32 guid
/// </code>
/// </summary>
public static class InventoryRemoveObject
{
    public const uint Opcode = 0x0024u;

    public readonly record struct Parsed(uint Guid);

    /// <summary>Parse a raw 0x0024 body. Returns null on opcode mismatch / truncation.</summary>
    public static Parsed? TryParse(ReadOnlySpan<byte> body)
    {
        if (body.Length < 8) return null;           // 4 + 4
        if (BinaryPrimitives.ReadUInt32LittleEndian(body) != Opcode) return null;
        uint guid = BinaryPrimitives.ReadUInt32LittleEndian(body[4..]);
        return new Parsed(guid);
    }
}
  • Step 4: Run test to verify it passes

Run: dotnet test tests/AcDream.Core.Net.Tests/AcDream.Core.Net.Tests.csproj --filter "FullyQualifiedName~InventoryRemoveObjectTests" Expected: PASS (3 tests).

  • Step 5: Commit
git add src/AcDream.Core.Net/Messages/InventoryRemoveObject.cs tests/AcDream.Core.Net.Tests/Messages/InventoryRemoveObjectTests.cs
git commit -m "feat(net): D.2b-B B-Wire — InventoryRemoveObject (0x0024) parser"

Task 6: GameEvents.ParseViewContents (0x0196) (Core.Net)

GameEvent (payload = envelope stripped). Layout (ACE GameEventViewContents.cs): containerGuid(4), count(4), [guid(4), containerType(4)] × count.

Files:

  • Modify: src/AcDream.Core.Net/Messages/GameEvents.cs (add near ParsePutObjInContainer, ~line 359)

  • Test: tests/AcDream.Core.Net.Tests/Messages/GameEventsInventoryTests.cs (create)

  • Step 1: Write the failing test

Create tests/AcDream.Core.Net.Tests/Messages/GameEventsInventoryTests.cs:

using System.Buffers.Binary;
using AcDream.Core.Net.Messages;
using Xunit;

namespace AcDream.Core.Net.Tests.Messages;

public sealed class GameEventsInventoryTests
{
    [Fact]
    public void ParseViewContents_twoEntries_returnsContainerAndItems()
    {
        // containerGuid + count(2) + 2×{guid, containerType}
        var b = new byte[4 + 4 + 2 * 8];
        BinaryPrimitives.WriteUInt32LittleEndian(b.AsSpan(0), 0x500000C9u); // containerGuid
        BinaryPrimitives.WriteUInt32LittleEndian(b.AsSpan(4), 2u);          // count
        BinaryPrimitives.WriteUInt32LittleEndian(b.AsSpan(8), 0x50000A01u); // guid 1
        BinaryPrimitives.WriteUInt32LittleEndian(b.AsSpan(12), 0u);         // type 1 (item)
        BinaryPrimitives.WriteUInt32LittleEndian(b.AsSpan(16), 0x50000A02u);// guid 2
        BinaryPrimitives.WriteUInt32LittleEndian(b.AsSpan(20), 1u);         // type 2 (container)

        var p = GameEvents.ParseViewContents(b);
        Assert.NotNull(p);
        Assert.Equal(0x500000C9u, p!.Value.ContainerGuid);
        Assert.Equal(2, p.Value.Items.Count);
        Assert.Equal(0x50000A01u, p.Value.Items[0].Guid);
        Assert.Equal(0u, p.Value.Items[0].ContainerType);
        Assert.Equal(0x50000A02u, p.Value.Items[1].Guid);
        Assert.Equal(1u, p.Value.Items[1].ContainerType);
    }

    [Fact]
    public void ParseViewContents_zeroCount_returnsEmptyList()
    {
        var b = new byte[8];
        BinaryPrimitives.WriteUInt32LittleEndian(b.AsSpan(0), 0x500000C9u);
        BinaryPrimitives.WriteUInt32LittleEndian(b.AsSpan(4), 0u);
        var p = GameEvents.ParseViewContents(b);
        Assert.NotNull(p);
        Assert.Empty(p!.Value.Items);
    }

    [Fact]
    public void ParseViewContents_truncated_returnsNull()
        => Assert.Null(GameEvents.ParseViewContents(new byte[4]));
}
  • Step 2: Run test to verify it fails

Run: dotnet test tests/AcDream.Core.Net.Tests/AcDream.Core.Net.Tests.csproj --filter "FullyQualifiedName~GameEventsInventoryTests.ParseViewContents" Expected: FAIL — no ParseViewContents / no ViewContents record.

  • Step 3: Write minimal implementation

In src/AcDream.Core.Net/Messages/GameEvents.cs, after ParsePutObjInContainer (~line 359), add:

    /// <summary>0x0196 ViewContents: full contents list of a container you opened.
    /// Layout (ACE GameEventViewContents.cs): containerGuid, count, [guid, containerType]×count.
    /// Client consumer: ClientUISystem::OnViewContents (PackableList&lt;ContentProfile&gt;).</summary>
    public readonly record struct ViewContentsEntry(uint Guid, uint ContainerType);
    public readonly record struct ViewContents(uint ContainerGuid, System.Collections.Generic.IReadOnlyList<ViewContentsEntry> Items);

    public static ViewContents? ParseViewContents(ReadOnlySpan<byte> payload)
    {
        if (payload.Length < 8) return null;
        uint containerGuid = BinaryPrimitives.ReadUInt32LittleEndian(payload);
        uint count = BinaryPrimitives.ReadUInt32LittleEndian(payload.Slice(4));
        int pos = 8;
        if ((long)payload.Length - pos < (long)count * 8) return null;
        var items = new ViewContentsEntry[count];
        for (int i = 0; i < count; i++)
        {
            uint guid = BinaryPrimitives.ReadUInt32LittleEndian(payload.Slice(pos)); pos += 4;
            uint type = BinaryPrimitives.ReadUInt32LittleEndian(payload.Slice(pos)); pos += 4;
            items[i] = new ViewContentsEntry(guid, type);
        }
        return new ViewContents(containerGuid, items);
    }
  • Step 4: Run test to verify it passes

Run: dotnet test tests/AcDream.Core.Net.Tests/AcDream.Core.Net.Tests.csproj --filter "FullyQualifiedName~GameEventsInventoryTests.ParseViewContents" Expected: PASS (3 tests).

  • Step 5: Commit
git add src/AcDream.Core.Net/Messages/GameEvents.cs tests/AcDream.Core.Net.Tests/Messages/GameEventsInventoryTests.cs
git commit -m "feat(net): D.2b-B B-Wire — ParseViewContents (0x0196)"

Task 7: Fix ParsePutObjInContainer 4th field (Core.Net)

0x0022 carries a 4th containerType u32 that the current parser drops.

Files:

  • Modify: src/AcDream.Core.Net/Messages/GameEvents.cs (the InventoryPutObjInContainer record + ParsePutObjInContainer, ~lines 346-359)

  • Test: tests/AcDream.Core.Net.Tests/Messages/GameEventsInventoryTests.cs (append)

  • Step 1: Write the failing test

Append to GameEventsInventoryTests.cs (inside the class):

    [Fact]
    public void ParsePutObjInContainer_readsAllFourFields()
    {
        var b = new byte[16];
        BinaryPrimitives.WriteUInt32LittleEndian(b.AsSpan(0), 0x50000A01u); // itemGuid
        BinaryPrimitives.WriteUInt32LittleEndian(b.AsSpan(4), 0x500000C9u); // containerGuid
        BinaryPrimitives.WriteUInt32LittleEndian(b.AsSpan(8), 3u);          // placement
        BinaryPrimitives.WriteUInt32LittleEndian(b.AsSpan(12), 1u);         // containerType

        var p = GameEvents.ParsePutObjInContainer(b);
        Assert.NotNull(p);
        Assert.Equal(0x50000A01u, p!.Value.ItemGuid);
        Assert.Equal(0x500000C9u, p.Value.ContainerGuid);
        Assert.Equal(3u, p.Value.Placement);
        Assert.Equal(1u, p.Value.ContainerType);
    }
  • Step 2: Run test to verify it fails

Run: dotnet test tests/AcDream.Core.Net.Tests/AcDream.Core.Net.Tests.csproj --filter "FullyQualifiedName~GameEventsInventoryTests.ParsePutObjInContainer" Expected: FAIL — InventoryPutObjInContainer record has no ContainerType.

  • Step 3: Write minimal implementation

In src/AcDream.Core.Net/Messages/GameEvents.cs, replace the InventoryPutObjInContainer record + parser (~lines 346-359) with:

    /// <summary>0x0022 InventoryPutObjInContainer: server puts item into container slot.
    /// 4 fields (ACE GameEventItemServerSaysContainId.cs): itemGuid, containerGuid,
    /// placement, containerType. ContainerType (0=item,1=container,2=foci) confirmed
    /// vs holtburger events.rs fixture (slot=3 type=1).</summary>
    public readonly record struct InventoryPutObjInContainer(
        uint ItemGuid,
        uint ContainerGuid,
        uint Placement,
        uint ContainerType);

    public static InventoryPutObjInContainer? ParsePutObjInContainer(ReadOnlySpan<byte> payload)
    {
        if (payload.Length < 16) return null;
        return new InventoryPutObjInContainer(
            BinaryPrimitives.ReadUInt32LittleEndian(payload),
            BinaryPrimitives.ReadUInt32LittleEndian(payload.Slice(4)),
            BinaryPrimitives.ReadUInt32LittleEndian(payload.Slice(8)),
            BinaryPrimitives.ReadUInt32LittleEndian(payload.Slice(12)));
    }

(The GameEventWiring caller at ~line 246 uses .ItemGuid/.ContainerGuid/.Placement — unaffected by the added field.)

  • Step 4: Run test to verify it passes

Run: dotnet test tests/AcDream.Core.Net.Tests/AcDream.Core.Net.Tests.csproj --filter "FullyQualifiedName~GameEventsInventoryTests.ParsePutObjInContainer" Expected: PASS.

  • Step 5: Commit
git add src/AcDream.Core.Net/Messages/GameEvents.cs tests/AcDream.Core.Net.Tests/Messages/GameEventsInventoryTests.cs
git commit -m "fix(net): D.2b-B B-Wire — ParsePutObjInContainer reads containerType (4th field)"

Task 8: Fix ParseInventoryServerSaveFailed to read the error (Core.Net)

0x00A0 carries itemGuid, weenieError; the current parser returns only the guid. Change to a record. (Parser is currently unwired — no caller breaks.)

Files:

  • Modify: src/AcDream.Core.Net/Messages/GameEvents.cs (ParseInventoryServerSaveFailed, ~line 377)

  • Test: tests/AcDream.Core.Net.Tests/Messages/GameEventsInventoryTests.cs (append)

  • Step 1: Write the failing test

Append to GameEventsInventoryTests.cs:

    [Fact]
    public void ParseInventoryServerSaveFailed_readsGuidAndError()
    {
        var b = new byte[8];
        BinaryPrimitives.WriteUInt32LittleEndian(b.AsSpan(0), 0x50000A01u); // itemGuid
        BinaryPrimitives.WriteUInt32LittleEndian(b.AsSpan(4), 0x0Au);       // weenieError

        var p = GameEvents.ParseInventoryServerSaveFailed(b);
        Assert.NotNull(p);
        Assert.Equal(0x50000A01u, p!.Value.ItemGuid);
        Assert.Equal(0x0Au, p.Value.WeenieError);
    }
  • Step 2: Run test to verify it fails

Run: dotnet test tests/AcDream.Core.Net.Tests/AcDream.Core.Net.Tests.csproj --filter "FullyQualifiedName~GameEventsInventoryTests.ParseInventoryServerSaveFailed" Expected: FAIL — current return type is uint?, has no .ItemGuid/.WeenieError.

  • Step 3: Write minimal implementation

In src/AcDream.Core.Net/Messages/GameEvents.cs, replace ParseInventoryServerSaveFailed (~line 377) with:

    /// <summary>0x00A0 InventoryServerSaveFailed: revert a speculative local inventory op.
    /// (itemGuid, weenieError) — ACE GameEventInventoryServerSaveFailed.cs; holtburger
    /// events.rs:147 reads both fields.</summary>
    public readonly record struct InventoryServerSaveFailed(uint ItemGuid, uint WeenieError);

    public static InventoryServerSaveFailed? ParseInventoryServerSaveFailed(ReadOnlySpan<byte> payload)
    {
        if (payload.Length < 8) return null;
        return new InventoryServerSaveFailed(
            BinaryPrimitives.ReadUInt32LittleEndian(payload),
            BinaryPrimitives.ReadUInt32LittleEndian(payload.Slice(4)));
    }
  • Step 4: Run test to verify it passes

Run: dotnet test tests/AcDream.Core.Net.Tests/AcDream.Core.Net.Tests.csproj --filter "FullyQualifiedName~GameEventsInventoryTests.ParseInventoryServerSaveFailed" Expected: PASS.

  • Step 5: Commit
git add src/AcDream.Core.Net/Messages/GameEvents.cs tests/AcDream.Core.Net.Tests/Messages/GameEventsInventoryTests.cs
git commit -m "fix(net): D.2b-B B-Wire — ParseInventoryServerSaveFailed reads weenieError"

Task 9: New C→S builders + WorldSession.Send* wrappers (Core.Net)

DropItem 0x001B (itemGuid), GetAndWieldItem 0x001A (itemGuid, equipMask), NoLongerViewingContents 0x0195 (containerGuid). All ride the 0xF7B1 GameAction envelope.

Files:

  • Modify: src/AcDream.Core.Net/Messages/InventoryActions.cs (add opcodes + 3 builders)

  • Modify: src/AcDream.Core.Net/WorldSession.cs (add 3 Send* wrappers after SendRemoveShortcut, ~line 1157)

  • Test: tests/AcDream.Core.Net.Tests/Messages/InventoryActionsTests.cs (append)

  • Step 1: Write the failing test

Append to tests/AcDream.Core.Net.Tests/Messages/InventoryActionsTests.cs (inside the existing class):

    [Fact]
    public void BuildDropItem_layout()
    {
        byte[] b = InventoryActions.BuildDropItem(seq: 7, itemGuid: 0x50000A01u);
        Assert.Equal(16, b.Length);
        Assert.Equal(0xF7B1u, BinaryPrimitives.ReadUInt32LittleEndian(b.AsSpan(0)));
        Assert.Equal(7u,      BinaryPrimitives.ReadUInt32LittleEndian(b.AsSpan(4)));
        Assert.Equal(0x001Bu, BinaryPrimitives.ReadUInt32LittleEndian(b.AsSpan(8)));
        Assert.Equal(0x50000A01u, BinaryPrimitives.ReadUInt32LittleEndian(b.AsSpan(12)));
    }

    [Fact]
    public void BuildGetAndWieldItem_layout()
    {
        byte[] b = InventoryActions.BuildGetAndWieldItem(seq: 7, itemGuid: 0x50000A01u, equipMask: 0x02u);
        Assert.Equal(20, b.Length);
        Assert.Equal(0xF7B1u, BinaryPrimitives.ReadUInt32LittleEndian(b.AsSpan(0)));
        Assert.Equal(7u,      BinaryPrimitives.ReadUInt32LittleEndian(b.AsSpan(4)));
        Assert.Equal(0x001Au, BinaryPrimitives.ReadUInt32LittleEndian(b.AsSpan(8)));
        Assert.Equal(0x50000A01u, BinaryPrimitives.ReadUInt32LittleEndian(b.AsSpan(12)));
        Assert.Equal(0x02u,   BinaryPrimitives.ReadUInt32LittleEndian(b.AsSpan(16)));
    }

    [Fact]
    public void BuildNoLongerViewingContents_layout()
    {
        byte[] b = InventoryActions.BuildNoLongerViewingContents(seq: 7, containerGuid: 0x500000C9u);
        Assert.Equal(16, b.Length);
        Assert.Equal(0xF7B1u, BinaryPrimitives.ReadUInt32LittleEndian(b.AsSpan(0)));
        Assert.Equal(7u,      BinaryPrimitives.ReadUInt32LittleEndian(b.AsSpan(4)));
        Assert.Equal(0x0195u, BinaryPrimitives.ReadUInt32LittleEndian(b.AsSpan(8)));
        Assert.Equal(0x500000C9u, BinaryPrimitives.ReadUInt32LittleEndian(b.AsSpan(12)));
    }

(If InventoryActionsTests.cs lacks using System.Buffers.Binary;, add it.)

  • Step 2: Run test to verify it fails

Run: dotnet test tests/AcDream.Core.Net.Tests/AcDream.Core.Net.Tests.csproj --filter "FullyQualifiedName~InventoryActionsTests.Build" Expected: FAIL — builders don't exist.

  • Step 3: Write minimal implementation

In src/AcDream.Core.Net/Messages/InventoryActions.cs, add the opcode constants (after TeleToPoiOpcode, ~line 24):

    public const uint GetAndWieldItemOpcode         = 0x001Au;
    public const uint DropItemOpcode                = 0x001Bu;
    public const uint NoLongerViewingContentsOpcode = 0x0195u;

And add the three builders (before the closing brace of the class):

    /// <summary>Drop an item on the ground. ACE GameActionDropItem.Handle reads 1 u32.</summary>
    public static byte[] BuildDropItem(uint seq, uint itemGuid)
    {
        byte[] body = new byte[16];
        BinaryPrimitives.WriteUInt32LittleEndian(body,            GameActionEnvelope);
        BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(4),  seq);
        BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(8),  DropItemOpcode);
        BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(12), itemGuid);
        return body;
    }

    /// <summary>Equip an item from inventory onto the doll. holtburger actions.rs
    /// GetAndWieldItemActionData = (itemGuid, equipMask).</summary>
    public static byte[] BuildGetAndWieldItem(uint seq, uint itemGuid, uint equipMask)
    {
        byte[] body = new byte[20];
        BinaryPrimitives.WriteUInt32LittleEndian(body,            GameActionEnvelope);
        BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(4),  seq);
        BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(8),  GetAndWieldItemOpcode);
        BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(12), itemGuid);
        BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(16), equipMask);
        return body;
    }

    /// <summary>Close a side-pack / ground-container view. holtburger actions.rs = (containerGuid).</summary>
    public static byte[] BuildNoLongerViewingContents(uint seq, uint containerGuid)
    {
        byte[] body = new byte[16];
        BinaryPrimitives.WriteUInt32LittleEndian(body,            GameActionEnvelope);
        BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(4),  seq);
        BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(8),  NoLongerViewingContentsOpcode);
        BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(12), containerGuid);
        return body;
    }

In src/AcDream.Core.Net/WorldSession.cs, after SendRemoveShortcut (~line 1157), add:

    /// <summary>Send DropItem (0x001B) — drop an item on the ground.</summary>
    public void SendDropItem(uint itemGuid)
    {
        uint seq = NextGameActionSequence();
        SendGameAction(InventoryActions.BuildDropItem(seq, itemGuid));
    }

    /// <summary>Send GetAndWieldItem (0x001A) — equip an item to an equip slot.</summary>
    public void SendGetAndWieldItem(uint itemGuid, uint equipMask)
    {
        uint seq = NextGameActionSequence();
        SendGameAction(InventoryActions.BuildGetAndWieldItem(seq, itemGuid, equipMask));
    }

    /// <summary>Send NoLongerViewingContents (0x0195) — close a container view.</summary>
    public void SendNoLongerViewingContents(uint containerGuid)
    {
        uint seq = NextGameActionSequence();
        SendGameAction(InventoryActions.BuildNoLongerViewingContents(seq, containerGuid));
    }
  • Step 4: Run test to verify it passes

Run: dotnet test tests/AcDream.Core.Net.Tests/AcDream.Core.Net.Tests.csproj --filter "FullyQualifiedName~InventoryActionsTests.Build" Expected: PASS (3 new tests).

  • Step 5: Commit
git add src/AcDream.Core.Net/Messages/InventoryActions.cs src/AcDream.Core.Net/WorldSession.cs tests/AcDream.Core.Net.Tests/Messages/InventoryActionsTests.cs
git commit -m "feat(net): D.2b-B B-Wire — DropItem/GetAndWieldItem/NoLongerViewingContents builders + Send wrappers"

Task 10: Login PD player-property delivery (Core.Net)

GameEventWiring.WireAll gains an optional Func<uint>? playerGuid; the PD handler upserts the player's parsed PropertyBundle into the player ClientObject. TDD via the dispatcher harness.

Files:

  • Modify: src/AcDream.Core.Net/GameEventWiring.cs (add param ~line 68; add upsert in the PD handler ~line 408)

  • Test: tests/AcDream.Core.Net.Tests/GameEventWiringTests.cs (append)

  • Step 1: Write the failing test

Append to GameEventWiringTests.cs (inside the class):

    [Fact]
    public void WireAll_PlayerDescription_UpsertsPlayerPropertiesIntoClientObject()
    {
        // PD with a PropertyInt32 table carrying EncumbranceVal(5)=1500. The handler
        // must land it in the player ClientObject so the burden bar reads the wire value.
        const uint playerGuid = 0x50000001u;
        var dispatcher = new GameEventDispatcher();
        var items = new ClientObjectTable();
        GameEventWiring.WireAll(dispatcher, items, new CombatState(), new Spellbook(),
            new ChatLog(), playerGuid: () => playerGuid);

        var sb = new MemoryStream();
        using var w = new BinaryWriter(sb);
        w.Write(0x00000001u); // propertyFlags = PropertyInt32
        w.Write(0x52u);       // weenieType
        // int table: u16 count, u16 buckets, then key/val pairs
        w.Write((ushort)1);   // count
        w.Write((ushort)8);   // buckets (ignored)
        w.Write(5u);          // key = EncumbranceVal
        w.Write(1500u);       // val
        // vector + has_health (no vector blocks)
        w.Write(0u);          // vectorFlags = None
        w.Write(0u);          // has_health
        // strict trailer
        w.Write(0u);          // option_flags = None
        w.Write(0u);          // options1
        w.Write(0u);          // legacy hotbar count
        w.Write(0u);          // spellbook_filters
        w.Write(0u);          // inventory count
        w.Write(0u);          // equipped count

        var env = GameEventEnvelope.TryParse(WrapEnvelope(GameEventType.PlayerDescription, sb.ToArray()));
        dispatcher.Dispatch(env!.Value);

        var player = items.Get(playerGuid);
        Assert.NotNull(player);
        Assert.Equal(1500, player!.Properties.Ints[5]);
    }
  • Step 2: Run test to verify it fails

Run: dotnet test tests/AcDream.Core.Net.Tests/AcDream.Core.Net.Tests.csproj --filter "FullyQualifiedName~GameEventWiringTests.WireAll_PlayerDescription_UpsertsPlayerProperties" Expected: FAIL — WireAll has no playerGuid parameter.

  • Step 3: Write minimal implementation

In src/AcDream.Core.Net/GameEventWiring.cs, add a new optional parameter at the END of the WireAll parameter list (after onShortcuts, ~line 68):

        Action<IReadOnlyList<PlayerDescriptionParser.ShortcutEntry>>? onShortcuts = null,
        // B-Wire: the local player's server guid. When provided, the PD handler upserts
        // the player's own PropertyBundle (EncumbranceVal etc.) into the player ClientObject.
        Func<uint>? playerGuid = null)

In the PD handler, immediately after the if (p is null) return; guard (~line 290), add:

            // B-Wire: deliver the player's OWN properties to the player ClientObject.
            // (PD's "membership manifest" rule is about ITEMS, whose data comes from
            // CreateObject; the player's own stats legitimately come from PD.) Upsert
            // because PD can arrive before the player's CreateObject. Retires AP-48/AP-49.
            if (playerGuid is not null)
                items.UpsertProperties(playerGuid(), p.Value.Properties);
  • Step 4: Run test to verify it passes

Run: dotnet test tests/AcDream.Core.Net.Tests/AcDream.Core.Net.Tests.csproj --filter "FullyQualifiedName~GameEventWiringTests" Expected: PASS (all GameEventWiring tests, including the new one).

  • Step 5: Commit
git add src/AcDream.Core.Net/GameEventWiring.cs tests/AcDream.Core.Net.Tests/GameEventWiringTests.cs
git commit -m "feat(net): D.2b-B B-Wire — PlayerDescription delivers player properties to ClientObject"

Task 11: Register ViewContents (membership) + unwired inventory GameEvents (Core.Net)

Register ViewContents (apply membership — testable), and the existing-but-unwired InventoryPutObjectIn3D (0x019A) (unparent to world), InventoryServerSaveFailed (0x00A0) (log), CloseGroundContainer (0x0052) (log).

Files:

  • Modify: src/AcDream.Core.Net/GameEventWiring.cs (add registrations after the InventoryPutObjInContainer registration, ~line 248)

  • Test: tests/AcDream.Core.Net.Tests/GameEventWiringTests.cs (append)

  • Step 1: Write the failing test

Append to GameEventWiringTests.cs:

    [Fact]
    public void WireAll_ViewContents_RecordsMembershipInClientObjectTable()
    {
        var (d, items, _, _, _) = MakeAll();

        // containerGuid 0xC9 + 2 entries
        byte[] payload = new byte[8 + 2 * 8];
        BinaryPrimitives.WriteUInt32LittleEndian(payload.AsSpan(0), 0x500000C9u);
        BinaryPrimitives.WriteUInt32LittleEndian(payload.AsSpan(4), 2u);
        BinaryPrimitives.WriteUInt32LittleEndian(payload.AsSpan(8), 0x50000A01u);
        BinaryPrimitives.WriteUInt32LittleEndian(payload.AsSpan(12), 0u);
        BinaryPrimitives.WriteUInt32LittleEndian(payload.AsSpan(16), 0x50000A02u);
        BinaryPrimitives.WriteUInt32LittleEndian(payload.AsSpan(20), 1u);

        var env = GameEventEnvelope.TryParse(WrapEnvelope(GameEventType.ViewContents, payload));
        d.Dispatch(env!.Value);

        Assert.Equal(0x500000C9u, items.Get(0x50000A01u)!.ContainerId);
        Assert.Equal(0x500000C9u, items.Get(0x50000A02u)!.ContainerId);
    }

    [Fact]
    public void WireAll_InventoryPutObjectIn3D_UnparentsFromContainer()
    {
        var (d, items, _, _, _) = MakeAll();
        items.RecordMembership(0x50000A01u, containerId: 0x500000C9u);

        byte[] payload = new byte[4];
        BinaryPrimitives.WriteUInt32LittleEndian(payload, 0x50000A01u);
        var env = GameEventEnvelope.TryParse(WrapEnvelope(GameEventType.InventoryPutObjectIn3D, payload));
        d.Dispatch(env!.Value);

        Assert.Equal(0u, items.Get(0x50000A01u)!.ContainerId);
    }
  • Step 2: Run test to verify it fails

Run: dotnet test tests/AcDream.Core.Net.Tests/AcDream.Core.Net.Tests.csproj --filter "FullyQualifiedName~GameEventWiringTests.WireAll_ViewContents|FullyQualifiedName~GameEventWiringTests.WireAll_InventoryPutObjectIn3D" Expected: FAIL — these events aren't registered, so nothing changes (the Get returns null / ContainerId unchanged).

  • Step 3: Write minimal implementation

In src/AcDream.Core.Net/GameEventWiring.cs, after the InventoryPutObjInContainer registration block (the one ending ~line 248), add:

        // B-Wire: ViewContents (0x0196) — the server's full contents list for a
        // container you opened. Record membership for each entry so the table is
        // correct before the container-open UI mounts the second item list.
        dispatcher.Register(GameEventType.ViewContents, e =>
        {
            var p = GameEvents.ParseViewContents(e.Payload.Span);
            if (p is null) return;
            foreach (var entry in p.Value.Items)
                items.RecordMembership(entry.Guid, containerId: p.Value.ContainerGuid);
        });

        // B-Wire: InventoryPutObjectIn3D (0x019A) — server confirms an item dropped
        // to the world. Unparent it from its container (it's now a ground object) so
        // the inventory grid drops the cell; the object itself survives.
        dispatcher.Register(GameEventType.InventoryPutObjectIn3D, e =>
        {
            var guid = GameEvents.ParsePutObjectIn3D(e.Payload.Span);
            if (guid is not null) items.MoveItem(guid.Value, newContainerId: 0u);
        });

        // B-Wire: InventoryServerSaveFailed (0x00A0) — rollback of a speculative move.
        // acdream does not do speculative moves yet (read-only inventory); parse + log
        // so the wire is correct. B-Drag wires the rollback behavior. (Divergence note.)
        dispatcher.Register(GameEventType.InventoryServerSaveFailed, e =>
        {
            var p = GameEvents.ParseInventoryServerSaveFailed(e.Payload.Span);
            if (p is not null)
                Console.WriteLine($"[B-Wire] InventoryServerSaveFailed guid=0x{p.Value.ItemGuid:X8} err=0x{p.Value.WeenieError:X}");
        });

        // B-Wire: CloseGroundContainer (0x0052) — server closed a ground-container
        // view. No table change (the view is UI-only, wired in container-open); log.
        dispatcher.Register(GameEventType.CloseGroundContainer, e =>
        {
            var guid = GameEvents.ParseCloseGroundContainer(e.Payload.Span);
            if (guid is not null)
                Console.WriteLine($"[B-Wire] CloseGroundContainer guid=0x{guid.Value:X8}");
        });
  • Step 4: Run test to verify it passes

Run: dotnet test tests/AcDream.Core.Net.Tests/AcDream.Core.Net.Tests.csproj --filter "FullyQualifiedName~GameEventWiringTests" Expected: PASS (all GameEventWiring tests).

  • Step 5: Commit
git add src/AcDream.Core.Net/GameEventWiring.cs tests/AcDream.Core.Net.Tests/GameEventWiringTests.cs
git commit -m "feat(net): D.2b-B B-Wire — register ViewContents + InventoryPutObjectIn3D/SaveFailed/CloseGroundContainer"

Task 12: WorldSession dispatch + events for the 3 top-level GameMessages (Core.Net)

Add events + switch cases for PrivateUpdatePropertyInt (0x02CD), SetStackSize (0x0197), InventoryRemoveObject (0x0024). Glue — no test seam on the switch; verified by build + the parser tests + the live run (per the codebase convention documented in ObjectTableWiringTests).

Files:

  • Modify: src/AcDream.Core.Net/WorldSession.cs (add events near ObjectIntPropertyUpdated ~line 196; add switch cases after the PublicUpdatePropertyInt branch ~line 960)

  • Step 1: Add the events

In src/AcDream.Core.Net/WorldSession.cs, after the ObjectIntPropertyUpdated event (~line 196), add:

    /// <summary>Payload for <see cref="PlayerIntPropertyUpdated"/>: a PropertyInt change on
    /// the player's OWN object (from PrivateUpdatePropertyInt 0x02CD — no guid on the wire).</summary>
    public readonly record struct PlayerIntPropertyUpdate(uint Property, int Value);

    /// <summary>Fires when the session parses a PrivateUpdatePropertyInt (0x02CD) — one
    /// PropertyInt updated on the player. B-Wire routes EncumbranceVal (5) to the burden bar.</summary>
    public event Action<PlayerIntPropertyUpdate>? PlayerIntPropertyUpdated;

    /// <summary>Payload for <see cref="StackSizeUpdated"/>: SetStackSize (0x0197) — a stack's
    /// count + value after a merge / split.</summary>
    public readonly record struct StackSizeUpdate(uint Guid, int StackSize, int Value);

    /// <summary>Fires when the session parses a SetStackSize (0x0197) top-level GameMessage.</summary>
    public event Action<StackSizeUpdate>? StackSizeUpdated;

    /// <summary>Fires when the session parses an InventoryRemoveObject (0x0024) — the guid left
    /// the player's inventory view.</summary>
    public event Action<uint>? InventoryObjectRemoved;
  • Step 2: Add the switch cases

In src/AcDream.Core.Net/WorldSession.cs, immediately after the else if (op == PublicUpdatePropertyInt.Opcode) branch (the one ending ~line 960), add:

            else if (op == PrivateUpdatePropertyInt.Opcode)
            {
                var p = PrivateUpdatePropertyInt.TryParse(body);
                if (p is not null)
                    PlayerIntPropertyUpdated?.Invoke(
                        new PlayerIntPropertyUpdate(p.Value.Property, p.Value.Value));
            }
            else if (op == SetStackSize.Opcode)
            {
                var p = SetStackSize.TryParse(body);
                if (p is not null)
                    StackSizeUpdated?.Invoke(
                        new StackSizeUpdate(p.Value.Guid, p.Value.StackSize, p.Value.Value));
            }
            else if (op == InventoryRemoveObject.Opcode)
            {
                var p = InventoryRemoveObject.TryParse(body);
                if (p is not null) InventoryObjectRemoved?.Invoke(p.Value.Guid);
            }
  • Step 3: Build to verify it compiles

Run: dotnet build src/AcDream.Core.Net/AcDream.Core.Net.csproj Expected: Build succeeded.

  • Step 4: Run the full Core.Net test suite (no regressions)

Run: dotnet test tests/AcDream.Core.Net.Tests/AcDream.Core.Net.Tests.csproj Expected: PASS (all green).

  • Step 5: Commit
git add src/AcDream.Core.Net/WorldSession.cs
git commit -m "feat(net): D.2b-B B-Wire — WorldSession dispatch for 0x02CD/SetStackSize/InventoryRemoveObject"

Task 13: ObjectTableWiring — apply all ints + player-int route + stack/remove (Core.Net)

Loosen the int gate (apply all ints, not just UiEffects), route the player int to the player object, and apply SetStackSize / InventoryRemoveObject. Glue — verified by build + the underlying table-method tests (Tasks 1/2) + run.

Files:

  • Modify: src/AcDream.Core.Net/ObjectTableWiring.cs (the Wire method)

  • Step 1: Replace the Wire method body

In src/AcDream.Core.Net/ObjectTableWiring.cs, change the Wire signature + subscriptions. Replace the whole method (lines ~19-31) with:

    public static void Wire(WorldSession session, ClientObjectTable table, Func<uint>? playerGuid = null)
    {
        ArgumentNullException.ThrowIfNull(session);
        ArgumentNullException.ThrowIfNull(table);

        session.EntitySpawned += s => table.Ingest(ToWeenieData(s));
        session.EntityDeleted += d => table.Remove(d.Guid);

        // B-Wire: apply EVERY PropertyInt update on a visible object (0x02CE), not just
        // UiEffects — the server is the authority on object properties. UpdateIntProperty
        // stores it in the bundle and still mirrors UiEffects → the typed Effects field.
        session.ObjectIntPropertyUpdated += u =>
            table.UpdateIntProperty(u.Guid, u.Property, u.Value);

        // B-Wire: PrivateUpdatePropertyInt (0x02CD) carries no guid — it targets the
        // local player. Route it to the player object so live EncumbranceVal updates the
        // burden bar. (No-op if the player guid isn't wired yet.)
        session.PlayerIntPropertyUpdated += u =>
        {
            if (playerGuid is not null)
                table.UpdateIntProperty(playerGuid(), u.Property, u.Value);
        };

        // B-Wire: SetStackSize (0x0197) — update the object's stack count + value.
        session.StackSizeUpdated += u => table.UpdateStackSize(u.Guid, u.StackSize, u.Value);

        // B-Wire: InventoryRemoveObject (0x0024) — the object left the player's inventory
        // view; drop it from the table (retail ClientUISystem removes it from maintenance).
        session.InventoryObjectRemoved += guid => table.Remove(guid);
    }

Also update the class doc-comment (line ~8) to mention the new routes:

/// = evict, PublicUpdatePropertyInt (0x02CE) = live int apply, PrivateUpdatePropertyInt
/// (0x02CD) = player int (burden), SetStackSize (0x0197) = stack count, InventoryRemoveObject
/// (0x0024) = inventory-view removal.
  • Step 2: Build to verify it compiles

Run: dotnet build src/AcDream.Core.Net/AcDream.Core.Net.csproj Expected: Build succeeded.

  • Step 3: Run the full Core.Net test suite

Run: dotnet test tests/AcDream.Core.Net.Tests/AcDream.Core.Net.Tests.csproj Expected: PASS (ObjectTableWiringTests.ToWeenieData_CopiesFieldsFromSpawn still green — the signature change is source-compatible since playerGuid is optional).

  • Step 4: Commit
git add src/AcDream.Core.Net/ObjectTableWiring.cs
git commit -m "feat(net): D.2b-B B-Wire — ObjectTableWiring applies all ints + player int + stack/remove"

Task 14: GameWindow — pass the player guid into both wirings (App)

Files:

  • Modify: src/AcDream.App/Rendering/GameWindow.cs (line ~2471 ObjectTableWiring.Wire; line ~2569 the WireAll onShortcuts arg)

  • Step 1: Pass playerGuid to ObjectTableWiring.Wire

In src/AcDream.App/Rendering/GameWindow.cs at ~line 2471, change:

        AcDream.Core.Net.ObjectTableWiring.Wire(session, Objects);

to:

        AcDream.Core.Net.ObjectTableWiring.Wire(session, Objects, () => _playerServerGuid);
  • Step 2: Pass playerGuid to GameEventWiring.WireAll

In src/AcDream.App/Rendering/GameWindow.cs, in the WireAll(...) call (~line 2525-2569), change the final argument line:

                onShortcuts: list => Shortcuts = list);

to:

                onShortcuts: list => Shortcuts = list,
                playerGuid: () => _playerServerGuid);
  • Step 3: Build to verify it compiles

Run: dotnet build src/AcDream.App/AcDream.App.csproj Expected: Build succeeded.

  • Step 4: Commit
git add src/AcDream.App/Rendering/GameWindow.cs
git commit -m "feat(app): D.2b-B B-Wire — wire player guid into ObjectTableWiring + GameEventWiring"

Task 15: InventoryController refreshes burden on player-object update (App)

Concerns() excludes the player's own object, so a live EncumbranceVal update wouldn't repaint the burden bar. The player object IS the burden source — include it.

Files:

  • Modify: src/AcDream.App/UI/Layout/InventoryController.cs (Concerns, ~line 115)

  • Step 1: Make the change

In src/AcDream.App/UI/Layout/InventoryController.cs, change Concerns (~line 115-120):

    private bool Concerns(ClientObject o)
    {
        uint p = _playerGuid();
        return o.ContainerId == p || o.WielderId == p
            || (o.ContainerId != 0 && _objects.Get(o.ContainerId)?.ContainerId == p);  // 2-deep
    }

to:

    private bool Concerns(ClientObject o)
    {
        uint p = _playerGuid();
        return o.ObjectId == p                          // B-Wire: the player object IS the burden source
            || o.ContainerId == p || o.WielderId == p
            || (o.ContainerId != 0 && _objects.Get(o.ContainerId)?.ContainerId == p);  // 2-deep
    }
  • Step 2: Build to verify it compiles

Run: dotnet build src/AcDream.App/AcDream.App.csproj Expected: Build succeeded.

  • Step 3: Commit
git add src/AcDream.App/UI/Layout/InventoryController.cs
git commit -m "fix(app): D.2b-B B-Wire — burden bar refreshes on player-object property update"

Task 16: Full verification + divergence register + ISSUES/roadmap + memory

Files:

  • Modify: docs/architecture/retail-divergence-register.md (retire AP-48, AP-49)

  • Modify: docs/ISSUES.md (B-Wire SHIPPED note)

  • Modify: docs/plans/2026-04-11-roadmap.md (B-Wire shipped row)

  • Modify: claude-memory/project_d2b_retail_ui.md (B-Wire SHIPPED entry + the 0x02CD/no-guid + apply-gate DO-NOT-RETRY)

  • Step 1: Full build + test (the phase gate)

Run: dotnet build then dotnet test Expected: Build succeeded; all tests green (App + Core + Core.Net). Note the new counts vs the B-Controller baseline (App 532 / Core 1526).

  • Step 2: Retire the divergence rows

In docs/architecture/retail-divergence-register.md, find rows AP-48 (client-side SumCarriedBurden) and AP-49 (carry-aug unwired). Delete them (the wire mechanism is now ported), OR if SumCarriedBurden remains reachable as a fallback when the wire value is genuinely absent, reword AP-48 to "fallback only when EncumbranceVal absent" rather than deleting. State which you did in the commit. Confirm no other row references them.

  • Step 3: Update ISSUES + roadmap

In docs/ISSUES.md: add a "Recently closed / shipped" note for D.2b-B B-Wire referencing the commits, and note that the contents-grid overflow + B-Drag remain open.

In docs/plans/2026-04-11-roadmap.md: move B-Wire to the shipped column for the D.2b inventory arc.

  • Step 4: Update the memory digest

In claude-memory/project_d2b_retail_ui.md, add a B-Wire SHIPPED entry (after the B-Controller entry) capturing: EncumbranceVal delivery (login PD upsert + live 0x02CD, no-guid) retires AP-48/AP-49; the apply-gate now passes all ints; SetStackSize 0x0197 / InventoryRemoveObject 0x0024 are top-level GameMessages (not GameEvents); ViewContents 0x0196 is a GameEvent applying membership. Update the MEMORY.md index line if the one-liner changed.

  • Step 5: Commit
git add docs/architecture/retail-divergence-register.md docs/ISSUES.md docs/plans/2026-04-11-roadmap.md claude-memory/
git commit -m "docs(D.2b-B): B-Wire shipped — retire AP-48/AP-49 + ISSUES/roadmap/memory"

Post-implementation: visual gate

After Task 16, B-Wire is code-complete and test-green, but the burden-bar-reads-server-value behavior is best confirmed live (the project's acceptance model). Launch the client against ACE (per CLAUDE.md "Running the client"), open the inventory (F12, ACDREAM_RETAIL_UI=1), and confirm: the burden % matches what a retail client / ACE shows for +Acdream, and it updates when you pick up / drop an item (live 0x02CD). Report the observation; do not claim the behavior verified without it.


Self-review notes (coverage vs. spec)

  • C1a login delivery → Task 10 (+ Task 1 UpsertProperties). C1b live 0x02CD → Tasks 3, 12, 13. C1c gate → Task 13. C1d consumer refresh → Task 15.
  • C2a 0x0022 4th field → Task 7. C2b 0x00A0 error → Task 8.
  • C3 builders → Task 9. C4a ViewContents → Tasks 6, 11. C4b SetStackSize → Tasks 4, 12, 13 (+ Task 2 UpdateStackSize). C4c InventoryRemoveObject → Tasks 5, 12, 13.
  • C5 registration → Tasks 11 (GameEvents) + 12 (WorldSession switch).
  • Divergence/AP-48/AP-49 + bookkeeping → Task 16.
  • CreateObject extension (spec §7): already done (ToWeenieData); if Step-1 grep-named of CreateObject.TryParse finds a still-discarded inventory field, file it as a follow-up issue — out of scope here.