diff --git a/docs/superpowers/plans/2026-06-21-d2b-inventory-wire.md b/docs/superpowers/plans/2026-06-21-d2b-inventory-wire.md new file mode 100644 index 00000000..c8d4aeb1 --- /dev/null +++ b/docs/superpowers/plans/2026-06-21-d2b-inventory-wire.md @@ -0,0 +1,1432 @@ +# 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`: + +```csharp +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: + +```csharp + /// + /// Apply a 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 + /// , which no-ops on an unknown object. Fires + /// ObjectAdded on create, else ObjectUpdated. + /// + 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** + +```bash +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): + +```csharp + [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: + +```csharp + /// + /// 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...). + /// + 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** + +```bash +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`: + +```csharp +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`: + +```csharp +using System; +using System.Buffers.Binary; + +namespace AcDream.Core.Net.Messages; + +/// +/// Inbound PrivateUpdatePropertyInt (0x02CD) — the server updates one +/// PropertyInt on the player's OWN object. Unlike the sibling +/// (0x02CE), this carries NO guid (it +/// implicitly targets the local player). Burden (EncumbranceVal, PropertyInt 5) +/// changes ride this opcode on every pick-up / drop. +/// +/// Wire layout (ACE GameMessagePrivateUpdatePropertyInt): +/// +/// u32 opcode = 0x02CD +/// u8 sequence // single byte (ByteSequence) — not honored, latest-wins (DR-4) +/// u32 property // PropertyInt enum +/// i32 value +/// +/// +public static class PrivateUpdatePropertyInt +{ + public const uint Opcode = 0x02CDu; + + public readonly record struct Parsed(uint Property, int Value); + + /// Parse a raw 0x02CD body. Returns null on opcode mismatch / truncation. + public static Parsed? TryParse(ReadOnlySpan 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** + +```bash +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`: + +```csharp +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`: + +```csharp +using System; +using System.Buffers.Binary; + +namespace AcDream.Core.Net.Messages; + +/// +/// Inbound SetStackSize (0x0197) — 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. +/// +/// Wire layout (ACE GameMessageSetStackSize.cs, size hint 17): +/// +/// u32 opcode = 0x0197 +/// u8 sequence // ByteSequence (UpdatePropertyInt) — not honored +/// u32 guid +/// i32 stackSize +/// i32 value +/// +/// +public static class SetStackSize +{ + public const uint Opcode = 0x0197u; + + public readonly record struct Parsed(uint Guid, int StackSize, int Value); + + /// Parse a raw 0x0197 body. Returns null on opcode mismatch / truncation. + public static Parsed? TryParse(ReadOnlySpan 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** + +```bash +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`: + +```csharp +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`: + +```csharp +using System; +using System.Buffers.Binary; + +namespace AcDream.Core.Net.Messages; + +/// +/// Inbound InventoryRemoveObject (0x0024) — 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. +/// +/// Wire layout (ACE GameMessageInventoryRemoveObject.cs, size hint 8): +/// +/// u32 opcode = 0x0024 +/// u32 guid +/// +/// +public static class InventoryRemoveObject +{ + public const uint Opcode = 0x0024u; + + public readonly record struct Parsed(uint Guid); + + /// Parse a raw 0x0024 body. Returns null on opcode mismatch / truncation. + public static Parsed? TryParse(ReadOnlySpan 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** + +```bash +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`: + +```csharp +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: + +```csharp + /// 0x0196 ViewContents: full contents list of a container you opened. + /// Layout (ACE GameEventViewContents.cs): containerGuid, count, [guid, containerType]×count. + /// Client consumer: ClientUISystem::OnViewContents (PackableList<ContentProfile>). + public readonly record struct ViewContentsEntry(uint Guid, uint ContainerType); + public readonly record struct ViewContents(uint ContainerGuid, System.Collections.Generic.IReadOnlyList Items); + + public static ViewContents? ParseViewContents(ReadOnlySpan 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** + +```bash +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): + +```csharp + [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: + +```csharp + /// 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). + public readonly record struct InventoryPutObjInContainer( + uint ItemGuid, + uint ContainerGuid, + uint Placement, + uint ContainerType); + + public static InventoryPutObjInContainer? ParsePutObjInContainer(ReadOnlySpan 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** + +```bash +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`: + +```csharp + [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: + +```csharp + /// 0x00A0 InventoryServerSaveFailed: revert a speculative local inventory op. + /// (itemGuid, weenieError) — ACE GameEventInventoryServerSaveFailed.cs; holtburger + /// events.rs:147 reads both fields. + public readonly record struct InventoryServerSaveFailed(uint ItemGuid, uint WeenieError); + + public static InventoryServerSaveFailed? ParseInventoryServerSaveFailed(ReadOnlySpan 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** + +```bash +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): + +```csharp + [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): + +```csharp + 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): + +```csharp + /// Drop an item on the ground. ACE GameActionDropItem.Handle reads 1 u32. + 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; + } + + /// Equip an item from inventory onto the doll. holtburger actions.rs + /// GetAndWieldItemActionData = (itemGuid, equipMask). + 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; + } + + /// Close a side-pack / ground-container view. holtburger actions.rs = (containerGuid). + 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: + +```csharp + /// Send DropItem (0x001B) — drop an item on the ground. + public void SendDropItem(uint itemGuid) + { + uint seq = NextGameActionSequence(); + SendGameAction(InventoryActions.BuildDropItem(seq, itemGuid)); + } + + /// Send GetAndWieldItem (0x001A) — equip an item to an equip slot. + public void SendGetAndWieldItem(uint itemGuid, uint equipMask) + { + uint seq = NextGameActionSequence(); + SendGameAction(InventoryActions.BuildGetAndWieldItem(seq, itemGuid, equipMask)); + } + + /// Send NoLongerViewingContents (0x0195) — close a container view. + 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** + +```bash +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? 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): + +```csharp + [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): + +```csharp + Action>? 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? playerGuid = null) +``` + +In the PD handler, immediately after the `if (p is null) return;` guard (~line 290), add: + +```csharp + // 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** + +```bash +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`: + +```csharp + [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: + +```csharp + // 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** + +```bash +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: + +```csharp + /// Payload for : a PropertyInt change on + /// the player's OWN object (from PrivateUpdatePropertyInt 0x02CD — no guid on the wire). + public readonly record struct PlayerIntPropertyUpdate(uint Property, int Value); + + /// Fires when the session parses a PrivateUpdatePropertyInt (0x02CD) — one + /// PropertyInt updated on the player. B-Wire routes EncumbranceVal (5) to the burden bar. + public event Action? PlayerIntPropertyUpdated; + + /// Payload for : SetStackSize (0x0197) — a stack's + /// count + value after a merge / split. + public readonly record struct StackSizeUpdate(uint Guid, int StackSize, int Value); + + /// Fires when the session parses a SetStackSize (0x0197) top-level GameMessage. + public event Action? StackSizeUpdated; + + /// Fires when the session parses an InventoryRemoveObject (0x0024) — the guid left + /// the player's inventory view. + public event Action? 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: + +```csharp + 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** + +```bash +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: + +```csharp + public static void Wire(WorldSession session, ClientObjectTable table, Func? 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: + +```csharp +/// = 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** + +```bash +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: + +```csharp + AcDream.Core.Net.ObjectTableWiring.Wire(session, Objects); +``` + +to: + +```csharp + 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: + +```csharp + onShortcuts: list => Shortcuts = list); +``` + +to: + +```csharp + 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** + +```bash +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): + +```csharp + 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: + +```csharp + 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** + +```bash +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** + +```bash +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.