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

1432 lines
59 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# D.2b-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
/// <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**
```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
/// <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**
```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;
/// <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**
```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;
/// <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**
```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;
/// <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**
```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
/// <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**
```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
/// <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**
```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
/// <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**
```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
/// <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:
```csharp
/// <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**
```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<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):
```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<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:
```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
/// <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:
```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<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:
```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.