acdream/docs/superpowers/plans/2026-06-18-d54-object-item-model.md
Erik 2fc253d9ff docs(D.5.4): implementation plan (12 tasks, TDD, green-per-task)
Rename -> field capture -> EntitySpawn plumb -> ClientObject/WeenieData ->
Ingest merge-upsert -> container index -> ObjectTableWiring (off GameWindow) ->
PD manifest -> delete EnrichItem -> retire _liveEntityInfoByGuid -> toolbar
guid-filter -> bookkeeping+live run. Sequenced so every task builds green.

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

1344 lines
58 KiB
Markdown

# D.5.4 Client Object/Item Data Model — 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:** Make `CreateObject (0xF745)` the canonical create-or-update for every server object, holding the data side of all objects in one guid-keyed table (retail's `weenie_object_table` shape), so the UI resolves items by guid and the Coldeve blank-icon bug is fixed at the root.
**Architecture:** Two guid-keyed tables (render/physics `WorldEntity` unchanged; data/UI `ClientObjectTable` broadened to all objects). `CreateObject` field-level **merge upsert** into `ClientObjectTable`; `DeleteObject` evicts; `PlayerDescription`/shortcuts are references; a live container-membership index; ingestion wired in `AcDream.Core.Net` (off `GameWindow`); `_liveEntityInfoByGuid` retired.
**Tech Stack:** C# / .NET 10, xUnit (hand-built byte fixtures, no Moq), `AcDream.slnx` solution. Build `dotnet build`; test `dotnet test`.
**Spec:** [`docs/superpowers/specs/2026-06-18-d54-object-item-model-design.md`](../specs/2026-06-18-d54-object-item-model-design.md)
**Canonical name map (used throughout this plan):**
| Old | New |
|---|---|
| `ItemInstance` (type) | `ClientObject` |
| `ItemRepository` (type) | `ClientObjectTable` |
| `ItemRepository.GetItem` | `ClientObjectTable.Get` |
| `ItemRepository.ItemCount` | `ClientObjectTable.ObjectCount` |
| `ItemRepository.Items` (IEnumerable) | `ClientObjectTable.Objects` |
| event `ItemAdded` | `ObjectAdded` |
| event `ItemMoved` | `ObjectMoved` |
| event `ItemRemoved` | `ObjectRemoved` |
| event `ItemPropertiesUpdated` | `ObjectUpdated` |
| `GameWindow.Items` (field) | `GameWindow.Objects` |
Unchanged member names (object-agnostic / container-specific): `AddOrUpdate`, `MoveItem`, `Remove`, `UpdateProperties`, `UpdateIntProperty`, `Clear`, `AddContainer`, `GetContainer`, `Containers`, `ContainerCount`, `UiEffectsPropertyId`. (`EnrichItem` is kept temporarily and deleted in Task 9.)
---
## Task 1: Mechanical rename — `ItemInstance`→`ClientObject`, `ItemRepository`→`ClientObjectTable`
Pure refactor, no behavior change. Do this first so every later task uses the new names.
**Files:**
- Rename: `src/AcDream.Core/Items/ItemInstance.cs``ClientObject.cs`
- Rename: `src/AcDream.Core/Items/ItemRepository.cs``ClientObjectTable.cs`
- Rename: `tests/AcDream.Core.Tests/Items/ItemRepositoryTests.cs``ClientObjectTableTests.cs`
- Modify (consumers): `src/AcDream.Core.Net/GameEventWiring.cs`, `src/AcDream.App/Rendering/GameWindow.cs`, `src/AcDream.App/UI/Layout/ToolbarController.cs`, plus anything the grep in Step 1 surfaces.
- [ ] **Step 1: Enumerate every reference (bound the rename)**
Run (Grep tool or shell):
```bash
grep -rn -E "ItemInstance|ItemRepository|\.GetItem\(|\.ItemAdded|\.ItemMoved|\.ItemRemoved|\.ItemPropertiesUpdated|\.ItemCount\b" src tests
```
Expected: hits in the files listed above (ItemInstance.cs, ItemRepository.cs, GameEventWiring.cs, GameWindow.cs, ToolbarController.cs, ItemRepositoryTests.cs). Record any *additional* files (e.g. plugin abstractions) and include them in the edits below. `CreateObjectTests.cs` references only `ItemType` (not renamed) — leave it.
- [ ] **Step 2: git mv the three files**
```bash
git mv src/AcDream.Core/Items/ItemInstance.cs src/AcDream.Core/Items/ClientObject.cs
git mv src/AcDream.Core/Items/ItemRepository.cs src/AcDream.Core/Items/ClientObjectTable.cs
git mv tests/AcDream.Core.Tests/Items/ItemRepositoryTests.cs tests/AcDream.Core.Tests/Items/ClientObjectTableTests.cs
```
- [ ] **Step 3: Rename the types + members in `ClientObject.cs`**
In `src/AcDream.Core/Items/ClientObject.cs`: `public sealed class ItemInstance``public sealed class ClientObject`. (The `ItemType`, `EquipMask`, `PropertyBundle`, `Container`, `BurdenMath` types in this file keep their names.) Update the XML-doc summary on the class from "Per-item live state" to "Per-object live state (the data side of every server object — items and creatures alike). Retail `ACCWeenieObject`."
- [ ] **Step 4: Rename the type + members in `ClientObjectTable.cs`**
In `src/AcDream.Core/Items/ClientObjectTable.cs`, apply (replace_all per token):
- `public sealed class ItemRepository``public sealed class ClientObjectTable`
- every `ItemInstance``ClientObject` (field types, event generic args, params)
- `event Action<ItemInstance>? ItemAdded``event Action<ClientObject>? ObjectAdded`
- `event Action<ItemInstance, uint, uint>? ItemMoved``event Action<ClientObject, uint, uint>? ObjectMoved`
- `event Action<ItemInstance>? ItemRemoved``event Action<ClientObject>? ObjectRemoved`
- `event Action<ItemInstance>? ItemPropertiesUpdated``event Action<ClientObject>? ObjectUpdated`
- `public int ItemCount``public int ObjectCount`
- `public IEnumerable<ItemInstance> Items``public IEnumerable<ClientObject> Objects`
- `public ItemInstance? GetItem(uint objectId)``public ClientObject? Get(uint objectId)`
- update every internal `ItemAdded?.Invoke`/`ItemPropertiesUpdated?.Invoke`/`ItemMoved?.Invoke`/`ItemRemoved?.Invoke` to the new event names.
- Update the class XML-doc summary to "the client's table of every server object (retail `weenie_object_table` / `CObjectMaint`)."
- [ ] **Step 5: Fix consumers**
In `src/AcDream.Core.Net/GameEventWiring.cs`: `ItemRepository items``ClientObjectTable items`; `new ItemInstance``new ClientObject`; `items.GetItem``items.Get`. (Leave the PD seeding body as-is for now — Task 8 rewrites it.)
In `src/AcDream.App/Rendering/GameWindow.cs`:
- `public readonly AcDream.Core.Items.ItemRepository Items = new();``public readonly AcDream.Core.Items.ClientObjectTable Objects = new();`
- every other `Items.` in this file → `Objects.` (e.g. `Items.EnrichItem`, `Items.UpdateIntProperty`); every `ItemRepository.UiEffectsPropertyId``ClientObjectTable.UiEffectsPropertyId`.
- the `WireAll(_liveSession.GameEvents, Items, ...)` arg → `Objects`.
In `src/AcDream.App/UI/Layout/ToolbarController.cs`: `ItemRepository``ClientObjectTable` (field `_repo`, ctor param); `repo.ItemAdded``repo.ObjectAdded`; `repo.ItemPropertiesUpdated``repo.ObjectUpdated`; `_repo.GetItem``_repo.Get`.
In `tests/AcDream.Core.Tests/Items/ClientObjectTableTests.cs`: `ItemRepository``ClientObjectTable`; `ItemInstance``ClientObject`; `repo.GetItem``repo.Get`; event names; `ItemCount``ObjectCount`. (The `MakeItem` helper keeps its name; it returns a `ClientObject`.)
Apply the same renames in any extra files Step 1 surfaced.
- [ ] **Step 6: Build + test green (no behavior change)**
```bash
dotnet build
dotnet test
```
Expected: build succeeds; full suite PASS (same count as before, just renamed).
- [ ] **Step 7: Commit**
```bash
git add -A
git commit -m "refactor(D.5.4): rename ItemRepository->ClientObjectTable, ItemInstance->ClientObject
Broaden naming to the data side of every server object (retail weenie_object_table
shape). Pure rename; no behavior change.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>"
```
---
## Task 2: Capture the full item field set in the `CreateObject` parser
The wire-cursor walk already exists (`CreateObject.cs:558-806`); turn the `pos += N` skips into reads, capture `WeenieClassId` from the fixed prefix, and surface all fields on `Parsed`.
**Files:**
- Modify: `src/AcDream.Core.Net/Messages/CreateObject.cs`
- Test: `tests/AcDream.Core.Net.Tests/Messages/CreateObjectTests.cs`
- [ ] **Step 1: Write the failing test (full-field capture + cursor integrity)**
Add to `CreateObjectTests.cs`. First extend the builder so the new fields are parameterizable — add these parameters to `BuildMinimalCreateObjectWithWeenieHeader` and write them in their correct slots (insert next to the existing matching `if ((weenieFlags & ...))` lines):
```csharp
// add to the BuildMinimalCreateObjectWithWeenieHeader parameter list:
uint weenieClassId = 0x1234,
uint? maxStackSize = null,
byte? itemsCapacity = null,
byte? containersCapacity = null,
uint? container = null,
uint? wielder = null,
uint? validLocations = null,
uint? currentWieldedLocation = null,
uint? priority = null,
float? workmanship = null,
```
Replace the corresponding writer lines in the builder body with value-carrying versions:
```csharp
WritePackedDword(bytes, weenieClassId); // WeenieClassId (was hardcoded 0x1234)
// ...
if ((weenieFlags & 0x00000002u) != 0) bytes.Add(itemsCapacity ?? 0); // ItemsCapacity u8
if ((weenieFlags & 0x00000004u) != 0) bytes.Add(containersCapacity ?? 0); // ContainersCapacity u8
if ((weenieFlags & 0x00002000u) != 0) WriteU16(bytes, (ushort)(maxStackSize ?? 0)); // MaxStackSize u16
if ((weenieFlags & 0x00004000u) != 0) WriteU32(bytes, container ?? 0); // Container u32
if ((weenieFlags & 0x00008000u) != 0) WriteU32(bytes, wielder ?? 0); // Wielder u32
if ((weenieFlags & 0x00010000u) != 0) WriteU32(bytes, validLocations ?? 0); // ValidLocations
if ((weenieFlags & 0x00020000u) != 0) WriteU32(bytes, currentWieldedLocation ?? 0); // CurrentlyWieldedLocation
if ((weenieFlags & 0x00040000u) != 0) WriteU32(bytes, priority ?? 0); // Priority
if ((weenieFlags & 0x01000000u) != 0) // Workmanship f32
{
Span<byte> tmp = stackalloc byte[4];
BinaryPrimitives.WriteSingleLittleEndian(tmp, workmanship ?? 0f);
bytes.AddRange(tmp.ToArray());
}
```
(Leave `WritePackedDword(bytes, 0x1234)` → now `weenieClassId`; keep the `value`/`structure`/`maxStructure`/`stackSize`/`burden` lines already parameterized.)
Then add the tests:
```csharp
[Fact]
public void TryParse_WeenieClassId_Surfaced()
{
byte[] body = BuildMinimalCreateObjectWithWeenieHeader(
guid: 0x50000020u, name: "Sword", itemType: (uint)ItemType.MeleeWeapon,
weenieClassId: 0xABCDu);
var parsed = CreateObject.TryParse(body);
Assert.NotNull(parsed);
Assert.Equal(0xABCDu, parsed!.Value.WeenieClassId);
}
[Fact]
public void TryParse_FullItemFields_Captured()
{
// Set every capture flag and assert every value round-trips.
uint flags =
0x00000008u | // Value
0x00001000u | // StackSize
0x00002000u | // MaxStackSize
0x00200000u | // Burden
0x00000002u | // ItemsCapacity
0x00000004u | // ContainersCapacity
0x00004000u | // Container
0x00008000u | // Wielder
0x00010000u | // ValidLocations
0x00020000u | // CurrentlyWieldedLocation
0x00040000u | // Priority
0x00000400u | // Structure
0x00000800u | // MaxStructure
0x01000000u; // Workmanship
byte[] body = BuildMinimalCreateObjectWithWeenieHeader(
guid: 0x50000021u, name: "Pack", itemType: (uint)ItemType.Container,
weenieFlags: flags,
value: 250u, stackSize: 7, maxStackSize: 100u, burden: 42,
itemsCapacity: 24, containersCapacity: 7,
container: 0x50000099u, wielder: 0x5000009Au,
validLocations: 0x02000000u, currentWieldedLocation: 0x02000000u,
priority: 8u, structure: 5, maxStructure: 10, workmanship: 7.5f);
var parsed = CreateObject.TryParse(body);
Assert.NotNull(parsed);
var p = parsed!.Value;
Assert.Equal(250u, p.Value);
Assert.Equal(7, p.StackSize);
Assert.Equal(100u, p.StackSizeMax);
Assert.Equal(42, p.Burden);
Assert.Equal(24, p.ItemsCapacity);
Assert.Equal(7, p.ContainersCapacity);
Assert.Equal(0x50000099u, p.ContainerId);
Assert.Equal(0x5000009Au, p.WielderId);
Assert.Equal(0x02000000u, p.ValidLocations);
Assert.Equal(0x02000000u, p.CurrentWieldedLocation);
Assert.Equal(8u, p.Priority);
Assert.Equal(5, p.Structure);
Assert.Equal(10, p.MaxStructure);
Assert.Equal(7.5f, p.Workmanship);
}
[Fact]
public void TryParse_MidTailFieldsSet_StillReachesIconOverlay()
{
// Cursor-integrity guard: setting fields BEFORE IconOverlay must not
// desync the IconOverlay read.
uint flags =
0x00001000u | // StackSize (mid-tail)
0x00004000u | // Container
0x40000000u; // IconOverlay
byte[] body = BuildMinimalCreateObjectWithWeenieHeader(
guid: 0x50000022u, name: "Ring", itemType: (uint)ItemType.Jewelry,
weenieFlags: flags, stackSize: 1, container: 0x500000F0u,
iconOverlayId: 0x4321u);
var parsed = CreateObject.TryParse(body);
Assert.NotNull(parsed);
Assert.Equal(0x06004321u, parsed!.Value.IconOverlayId);
Assert.Equal(0x500000F0u, parsed.Value.ContainerId);
}
```
- [ ] **Step 2: Run to verify it fails**
```bash
dotnet test tests/AcDream.Core.Net.Tests/AcDream.Core.Net.Tests.csproj --filter "FullyQualifiedName~CreateObjectTests"
```
Expected: FAIL — `Parsed` has no `WeenieClassId`/`Value`/`StackSize`/… members (compile error).
- [ ] **Step 3: Extend the `Parsed` record**
In `CreateObject.cs`, append these parameters to the `Parsed` record (after `UiEffects = 0`, before the closing `)`; bump the `UiEffects = 0` to `UiEffects = 0,`):
```csharp
// D.5.4 (2026-06-18): full item field set from the WeenieHeader tail —
// previously walked-past. Nullable = the gated flag was absent (don't
// clobber on merge); WeenieClassId is the fixed-prefix class id (was
// discarded at cs:538). Wire bits per r06 §4 / PublicWeenieDesc.
uint WeenieClassId = 0,
int? Value = null,
int? StackSize = null,
uint? StackSizeMax = null,
int? Burden = null,
int? ItemsCapacity = null,
int? ContainersCapacity = null,
uint? ContainerId = null,
uint? WielderId = null,
uint? ValidLocations = null,
uint? CurrentWieldedLocation = null,
uint? Priority = null,
int? Structure = null,
int? MaxStructure = null,
float? Workmanship = null);
```
- [ ] **Step 4: Capture the values in `TryParse`**
In `CreateObject.cs`, declare the new locals beside `iconId` (before the fixed-prefix `try`):
```csharp
uint weenieClassId = 0;
int? wValue = null; int? wStackSize = null; uint? wMaxStackSize = null;
int? wBurden = null; int? wItemsCapacity = null; int? wContainersCapacity = null;
uint? wContainerId = null; uint? wWielderId = null;
uint? wValidLocations = null; uint? wCurrentWieldedLocation = null;
uint? wPriority = null; int? wStructure = null; int? wMaxStructure = null;
float? wWorkmanship = null;
```
Change the fixed-prefix WeenieClassId read:
```csharp
weenieClassId = ReadPackedDword(body, ref pos); // WeenieClassId (D.5.4: was discarded)
```
In the optional-tail `try`, change these skips to reads (keep the bounds-check throw on each):
```csharp
if ((weenieFlags & 0x00000002u) != 0) // ItemsCapacity u8
{
if (body.Length - pos < 1) throw new FormatException("trunc ItemCap");
wItemsCapacity = body[pos]; pos += 1;
}
if ((weenieFlags & 0x00000004u) != 0) // ContainersCapacity u8
{
if (body.Length - pos < 1) throw new FormatException("trunc ContCap");
wContainersCapacity = body[pos]; pos += 1;
}
if ((weenieFlags & 0x00000008u) != 0) // Value u32
{
if (body.Length - pos < 4) throw new FormatException("trunc Value");
wValue = (int)ReadU32(body, ref pos);
}
// ... (Usable/UseRadius/TargetType/UiEffects/CombatUse unchanged) ...
if ((weenieFlags & 0x00000400u) != 0) // Structure u16
{
if (body.Length - pos < 2) throw new FormatException("trunc Structure");
wStructure = BinaryPrimitives.ReadUInt16LittleEndian(body.Slice(pos)); pos += 2;
}
if ((weenieFlags & 0x00000800u) != 0) // MaxStructure u16
{
if (body.Length - pos < 2) throw new FormatException("trunc MaxStructure");
wMaxStructure = BinaryPrimitives.ReadUInt16LittleEndian(body.Slice(pos)); pos += 2;
}
if ((weenieFlags & 0x00001000u) != 0) // StackSize u16
{
if (body.Length - pos < 2) throw new FormatException("trunc StackSize");
wStackSize = BinaryPrimitives.ReadUInt16LittleEndian(body.Slice(pos)); pos += 2;
}
if ((weenieFlags & 0x00002000u) != 0) // MaxStackSize u16
{
if (body.Length - pos < 2) throw new FormatException("trunc MaxStackSize");
wMaxStackSize = BinaryPrimitives.ReadUInt16LittleEndian(body.Slice(pos)); pos += 2;
}
if ((weenieFlags & 0x00004000u) != 0) // Container u32
{
if (body.Length - pos < 4) throw new FormatException("trunc Container");
wContainerId = ReadU32(body, ref pos);
}
if ((weenieFlags & 0x00008000u) != 0) // Wielder u32
{
if (body.Length - pos < 4) throw new FormatException("trunc Wielder");
wWielderId = ReadU32(body, ref pos);
}
if ((weenieFlags & 0x00010000u) != 0) // ValidLocations u32
{
if (body.Length - pos < 4) throw new FormatException("trunc ValidLocations");
wValidLocations = ReadU32(body, ref pos);
}
if ((weenieFlags & 0x00020000u) != 0) // CurrentlyWieldedLocation u32
{
if (body.Length - pos < 4) throw new FormatException("trunc CurrentlyWieldedLocation");
wCurrentWieldedLocation = ReadU32(body, ref pos);
}
if ((weenieFlags & 0x00040000u) != 0) // Priority u32
{
if (body.Length - pos < 4) throw new FormatException("trunc Priority");
wPriority = ReadU32(body, ref pos);
}
// ... (RadarBlipColor/RadarBehavior/PScript unchanged) ...
if ((weenieFlags & 0x01000000u) != 0) // Workmanship f32
{
if (body.Length - pos < 4) throw new FormatException("trunc Workmanship");
wWorkmanship = BinaryPrimitives.ReadSingleLittleEndian(body.Slice(pos)); pos += 4;
}
if ((weenieFlags & 0x00200000u) != 0) // Burden u16
{
if (body.Length - pos < 2) throw new FormatException("trunc Burden");
wBurden = BinaryPrimitives.ReadUInt16LittleEndian(body.Slice(pos)); pos += 2;
}
```
(Leave every other field — Usable, UseRadius, UiEffects, CombatUse, RadarBlipColor, RadarBehavior, PScript, Spell, HouseOwner, HouseRestrictions, HookItemTypes, Monarch, HookType, IconOverlay, IconUnderlay — exactly as-is.)
- [ ] **Step 5: Pass the new fields to both `Parsed` construction sites**
Append to the final `return new Parsed(...)` (after `UiEffects: uiEffects`):
```csharp
UiEffects: uiEffects,
WeenieClassId: weenieClassId,
Value: wValue, StackSize: wStackSize, StackSizeMax: wMaxStackSize,
Burden: wBurden, ItemsCapacity: wItemsCapacity, ContainersCapacity: wContainersCapacity,
ContainerId: wContainerId, WielderId: wWielderId,
ValidLocations: wValidLocations, CurrentWieldedLocation: wCurrentWieldedLocation,
Priority: wPriority, Structure: wStructure, MaxStructure: wMaxStructure,
Workmanship: wWorkmanship);
```
`PartialResult()` does not reach the weenie tail, so it needs no change (its new fields default to null/0).
- [ ] **Step 6: Run to verify pass**
```bash
dotnet test tests/AcDream.Core.Net.Tests/AcDream.Core.Net.Tests.csproj --filter "FullyQualifiedName~CreateObjectTests"
```
Expected: PASS (all existing + 3 new tests).
- [ ] **Step 7: Commit**
```bash
git add -A
git commit -m "feat(D.5.4): capture full item field set in CreateObject parser
WeenieClassId + Value/StackSize/MaxStackSize/Burden/capacities/Container/Wielder/
ValidLocations/CurrentWieldedLocation/Priority/Structure/Workmanship. Nullable =
flag absent (don't clobber on merge). Cursor walk unchanged; +cursor-integrity test.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>"
```
---
## Task 3: Plumb the new fields through `WorldSession.EntitySpawn`
Pure plumbing (record + the single `EntitySpawned.Invoke`). Verified by build + existing tests (no easy unit test for an event record).
**Files:**
- Modify: `src/AcDream.Core.Net/WorldSession.cs`
- [ ] **Step 1: Extend the `EntitySpawn` record**
Append to the `EntitySpawn` record (after `uint UiEffects = 0`, change it to `,`):
```csharp
uint UiEffects = 0,
// D.5.4 (2026-06-18): full item field set, forwarded to the object table.
uint WeenieClassId = 0,
int? Value = null,
int? StackSize = null,
uint? StackSizeMax = null,
int? Burden = null,
int? ItemsCapacity = null,
int? ContainersCapacity = null,
uint? ContainerId = null,
uint? WielderId = null,
uint? ValidLocations = null,
uint? CurrentWieldedLocation = null,
uint? Priority = null,
int? Structure = null,
int? MaxStructure = null,
float? Workmanship = null);
```
- [ ] **Step 2: Forward the fields at the `EntitySpawned.Invoke` site**
In the `0xF745` dispatch (after `parsed.Value.UiEffects` in the `new EntitySpawn(...)` call):
```csharp
parsed.Value.UiEffects,
parsed.Value.WeenieClassId,
parsed.Value.Value,
parsed.Value.StackSize,
parsed.Value.StackSizeMax,
parsed.Value.Burden,
parsed.Value.ItemsCapacity,
parsed.Value.ContainersCapacity,
parsed.Value.ContainerId,
parsed.Value.WielderId,
parsed.Value.ValidLocations,
parsed.Value.CurrentWieldedLocation,
parsed.Value.Priority,
parsed.Value.Structure,
parsed.Value.MaxStructure,
parsed.Value.Workmanship));
```
(Replace the existing closing `parsed.Value.UiEffects));` with the block above.)
- [ ] **Step 3: Build + test green**
```bash
dotnet build
dotnet test
```
Expected: build succeeds; full suite PASS.
- [ ] **Step 4: Commit**
```bash
git add -A
git commit -m "feat(D.5.4): forward full item field set through WorldSession.EntitySpawn
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>"
```
---
## Task 4: Add the new fields to `ClientObject` + define the `WeenieData` ingest DTO
**Files:**
- Modify: `src/AcDream.Core/Items/ClientObject.cs`
- Test: `tests/AcDream.Core.Tests/Items/ClientObjectTableTests.cs`
- [ ] **Step 1: Write the failing test**
Add to `ClientObjectTableTests.cs`:
```csharp
[Fact]
public void ClientObject_NewFields_DefaultAndSettable()
{
var o = new ClientObject
{
ObjectId = 1, WielderId = 0x42u, ItemsCapacity = 24, ContainersCapacity = 7,
Priority = 8u, Structure = 5, MaxStructure = 10, Workmanship = 7.5f,
};
o.WeenieClassId = 0xABCDu; // now settable
Assert.Equal(0x42u, o.WielderId);
Assert.Equal(24, o.ItemsCapacity);
Assert.Equal(7, o.ContainersCapacity);
Assert.Equal(8u, o.Priority);
Assert.Equal(5, o.Structure);
Assert.Equal(10, o.MaxStructure);
Assert.Equal(7.5f, o.Workmanship);
Assert.Equal(0xABCDu, o.WeenieClassId);
}
[Fact]
public void WeenieData_Construct()
{
var d = new WeenieData(Guid: 1, Name: "x", Type: ItemType.Misc, WeenieClassId: 2,
IconId: 0x06001234u, IconOverlayId: 0, IconUnderlayId: 0, Effects: 0,
Value: 5, StackSize: 1, StackSizeMax: 1, Burden: 10,
ContainerId: 0x99u, WielderId: null, ValidLocations: null,
CurrentWieldedLocation: null, Priority: null,
ItemsCapacity: null, ContainersCapacity: null,
Structure: null, MaxStructure: null, Workmanship: null);
Assert.Equal(0x99u, d.ContainerId);
}
```
- [ ] **Step 2: Run to verify it fails**
```bash
dotnet test tests/AcDream.Core.Tests/AcDream.Core.Tests.csproj --filter "FullyQualifiedName~ClientObjectTableTests"
```
Expected: FAIL — `WielderId`/`Priority`/… and `WeenieData` don't exist; `WeenieClassId` is init-only.
- [ ] **Step 3: Add fields to `ClientObject`**
In `ClientObject.cs`, change `public uint WeenieClassId { get; init; }``public uint WeenieClassId { get; set; }`. After the `Bonded` property (before `Properties`), add:
```csharp
public uint WielderId { get; set; } // PropertyInstanceId.Wielder; 0 = not wielded
public int ItemsCapacity { get; set; } // main-pack slots (containers)
public int ContainersCapacity{ get; set; } // side-pack slots (containers)
public uint Priority { get; set; } // ClothingPriority / CoverageMask layer order
public int Structure { get; set; } // charges/uses remaining
public int MaxStructure{ get; set; }
public float Workmanship{ get; set; } // 0..10 (fractional on the wire)
```
- [ ] **Step 4: Add the `WeenieData` DTO**
Append to `ClientObject.cs` (same namespace), after the `ClientObject` class:
```csharp
/// <summary>
/// The wire-delivered patch from a <c>CreateObject</c> (0xF745). Nullable fields
/// were gated by a WeenieHeader flag that was ABSENT — the merge upsert
/// (<see cref="ClientObjectTable.Ingest"/>) leaves the existing value untouched
/// for those, matching retail's <c>SetWeenieDesc</c> (patches only present fields).
/// Non-nullable id/effect fields use 0 = "not sent". Effects is assigned
/// unconditionally (0 clears) — the D.5.2 icon contract.
/// </summary>
public readonly record struct WeenieData(
uint Guid,
string? Name,
ItemType? Type,
uint WeenieClassId,
uint IconId,
uint IconOverlayId,
uint IconUnderlayId,
uint Effects,
int? Value,
int? StackSize,
uint? StackSizeMax,
int? Burden,
uint? ContainerId,
uint? WielderId,
uint? ValidLocations,
uint? CurrentWieldedLocation,
uint? Priority,
int? ItemsCapacity,
int? ContainersCapacity,
int? Structure,
int? MaxStructure,
float? Workmanship);
```
- [ ] **Step 5: Run to verify pass**
```bash
dotnet test tests/AcDream.Core.Tests/AcDream.Core.Tests.csproj --filter "FullyQualifiedName~ClientObjectTableTests"
```
Expected: PASS.
- [ ] **Step 6: Commit**
```bash
git add -A
git commit -m "feat(D.5.4): add item fields to ClientObject + WeenieData ingest DTO
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>"
```
---
## Task 5: `Ingest` merge-upsert + `RecordMembership` (with the D.5.2 effects contract)
**Files:**
- Modify: `src/AcDream.Core/Items/ClientObjectTable.cs`
- Test: `tests/AcDream.Core.Tests/Items/ClientObjectTableTests.cs`
- [ ] **Step 1: Write the failing tests**
Add to `ClientObjectTableTests.cs` (these port the D.5.2 effects contract onto `Ingest` + lock the merge + the Coldeve fix):
```csharp
private static WeenieData FullWeenie(uint guid, uint icon = 0x06001234u,
string name = "Sword", ItemType type = ItemType.MeleeWeapon, uint effects = 0,
int? value = 100, int? stack = 1, uint? container = null, uint wcid = 0xABCDu) =>
new WeenieData(guid, name, type, wcid, icon, 0, 0, effects,
value, stack, StackSizeMax: 1, Burden: 10, ContainerId: container,
WielderId: null, ValidLocations: null, CurrentWieldedLocation: null,
Priority: null, ItemsCapacity: null, ContainersCapacity: null,
Structure: null, MaxStructure: null, Workmanship: null);
[Fact]
public void Ingest_NewItemWithNoPriorStub_Creates_AndFiresAdded() // the Coldeve bug
{
var table = new ClientObjectTable();
ClientObject? added = null;
table.ObjectAdded += o => added = o;
var obj = table.Ingest(FullWeenie(0x500000B0u));
Assert.NotNull(added);
Assert.Equal(0x06001234u, table.Get(0x500000B0u)!.IconId);
Assert.Equal(0xABCDu, obj.WeenieClassId);
}
[Fact]
public void Ingest_Existing_PatchesInPlace_PreservesPropertyBundle()
{
var table = new ClientObjectTable();
table.Ingest(FullWeenie(0x500000B1u));
// Simulate an appraise having populated Properties.
table.Get(0x500000B1u)!.Properties.Ints[999u] = 7;
ClientObject? updated = null;
table.ObjectUpdated += o => updated = o;
table.Ingest(FullWeenie(0x500000B1u, name: "Renamed"));
Assert.NotNull(updated);
Assert.Equal("Renamed", table.Get(0x500000B1u)!.Name);
Assert.Equal(7, table.Get(0x500000B1u)!.Properties.Ints[999u]); // NOT clobbered
}
[Fact]
public void Ingest_AbsentNullableField_DoesNotClobber()
{
var table = new ClientObjectTable();
table.Ingest(FullWeenie(0x500000B2u, value: 100));
// Re-send with Value absent (null) — prior 100 must stay.
var noValue = FullWeenie(0x500000B2u) with { Value = null };
table.Ingest(noValue);
Assert.Equal(100, table.Get(0x500000B2u)!.Value);
}
[Fact]
public void Ingest_Effects_AssignedUnconditionally_ClearsToZero() // D.5.2 contract
{
var table = new ClientObjectTable();
table.Ingest(FullWeenie(0x500000B3u, effects: 0x1u));
Assert.Equal(0x1u, table.Get(0x500000B3u)!.Effects);
table.Ingest(FullWeenie(0x500000B3u, effects: 0u)); // now inert
Assert.Equal(0u, table.Get(0x500000B3u)!.Effects);
}
[Fact]
public void RecordMembership_CreatesEntry_AndSetsEquip()
{
var table = new ClientObjectTable();
table.RecordMembership(0x500000B4u, equip: EquipMask.MeleeWeapon);
var o = table.Get(0x500000B4u);
Assert.NotNull(o);
Assert.Equal(EquipMask.MeleeWeapon, o!.CurrentlyEquippedLocation);
Assert.Equal(0u, o.IconId); // data not set — CreateObject fills it
}
[Fact]
public void Ingest_AfterMembership_FillsData_NoDuplicate() // out-of-order: PD then CreateObject
{
var table = new ClientObjectTable();
table.RecordMembership(0x500000B5u);
table.Ingest(FullWeenie(0x500000B5u));
Assert.Equal(1, table.ObjectCount);
Assert.Equal(0x06001234u, table.Get(0x500000B5u)!.IconId);
}
```
- [ ] **Step 2: Run to verify it fails**
```bash
dotnet test tests/AcDream.Core.Tests/AcDream.Core.Tests.csproj --filter "FullyQualifiedName~ClientObjectTableTests"
```
Expected: FAIL — `Ingest`/`RecordMembership` don't exist.
- [ ] **Step 3: Implement `Ingest` + `RecordMembership`**
In `ClientObjectTable.cs`, rename the backing field `_items``_objects` (and update existing references in the file), then add (the `Reindex` call is a no-op stub here; Task 6 fills it):
```csharp
/// <summary>
/// Canonical CreateObject ingestion: create-if-absent, else patch the
/// wire-carried fields in place (retail SetWeenieDesc). Preserves the
/// PropertyBundle (appraise) and any field the wire didn't carry.
/// Effects is assigned unconditionally (0 clears) — the D.5.2 icon contract.
/// </summary>
public ClientObject Ingest(WeenieData d)
{
bool existed = _objects.TryGetValue(d.Guid, out var obj);
if (!existed || obj is null)
{
obj = new ClientObject { ObjectId = d.Guid };
_objects[d.Guid] = obj;
}
uint oldContainer = obj.ContainerId;
if (!string.IsNullOrEmpty(d.Name)) obj.Name = d.Name!;
if (d.Type is { } t) obj.Type = t;
if (d.WeenieClassId != 0) obj.WeenieClassId = d.WeenieClassId;
if (d.IconId != 0) obj.IconId = d.IconId;
if (d.IconOverlayId != 0) obj.IconOverlayId = d.IconOverlayId;
if (d.IconUnderlayId != 0) obj.IconUnderlayId = d.IconUnderlayId;
obj.Effects = d.Effects; // D.5.2 contract
if (d.Value is { } v) obj.Value = v;
if (d.StackSize is { } s) obj.StackSize = s;
if (d.StackSizeMax is { } sm) obj.StackSizeMax = (int)sm;
if (d.Burden is { } b) obj.Burden = b;
if (d.ContainerId is { } c) obj.ContainerId = c;
if (d.WielderId is { } w) obj.WielderId = w;
if (d.ValidLocations is { } vl) obj.ValidLocations = (EquipMask)vl;
if (d.CurrentWieldedLocation is { } cwl) obj.CurrentlyEquippedLocation = (EquipMask)cwl;
if (d.Priority is { } pr) obj.Priority = pr;
if (d.ItemsCapacity is { } ic) obj.ItemsCapacity = ic;
if (d.ContainersCapacity is { } cc) obj.ContainersCapacity = cc;
if (d.Structure is { } st) obj.Structure = st;
if (d.MaxStructure is { } ms) obj.MaxStructure = ms;
if (d.Workmanship is { } wm) obj.Workmanship = wm;
Reindex(obj, oldContainer);
if (!existed) ObjectAdded?.Invoke(obj); else ObjectUpdated?.Invoke(obj);
return obj;
}
/// <summary>
/// PlayerDescription manifest: record that this guid is the player's
/// (in inventory or equipped at <paramref name="equip"/>), creating an
/// empty entry if CreateObject hasn't arrived yet. Never touches
/// icon/name/type/effects — that data comes from CreateObject.
/// </summary>
public ClientObject RecordMembership(uint guid, uint containerId = 0,
EquipMask equip = EquipMask.None)
{
bool existed = _objects.TryGetValue(guid, out var obj);
if (!existed || obj is null)
{
obj = new ClientObject { ObjectId = guid };
_objects[guid] = obj;
}
uint oldContainer = obj.ContainerId;
if (containerId != 0) obj.ContainerId = containerId;
if (equip != EquipMask.None) obj.CurrentlyEquippedLocation = equip;
Reindex(obj, oldContainer);
if (!existed) ObjectAdded?.Invoke(obj); else ObjectUpdated?.Invoke(obj);
return obj;
}
// Filled in Task 6 (container index). No-op until then.
private void Reindex(ClientObject obj, uint oldContainerId) { }
```
- [ ] **Step 4: Run to verify pass**
```bash
dotnet test tests/AcDream.Core.Tests/AcDream.Core.Tests.csproj --filter "FullyQualifiedName~ClientObjectTableTests"
```
Expected: PASS (existing + 6 new). `dotnet build` still green (EnrichItem unchanged, still called by GameWindow).
- [ ] **Step 5: Commit**
```bash
git add -A
git commit -m "feat(D.5.4): ClientObjectTable.Ingest merge-upsert + RecordMembership
Field-level merge (retail SetWeenieDesc): create-if-absent else patch present
fields, preserve PropertyBundle. Effects unconditional (D.5.2 contract).
RecordMembership = PD manifest. Locks the Coldeve no-prior-stub fix + out-of-order.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>"
```
---
## Task 6: Container membership index
**Files:**
- Modify: `src/AcDream.Core/Items/ClientObjectTable.cs`
- Test: `tests/AcDream.Core.Tests/Items/ClientObjectTableTests.cs`
- [ ] **Step 1: Write the failing tests**
```csharp
[Fact]
public void ContainerIndex_IngestThenContents_OrderedBySlot()
{
var table = new ClientObjectTable();
// two items into container 0xC0, slots set via MoveItem after ingest
table.Ingest(FullWeenie(0x510u, container: 0xC0u));
table.Ingest(FullWeenie(0x511u, container: 0xC0u));
table.MoveItem(0x510u, 0xC0u, newSlot: 1);
table.MoveItem(0x511u, 0xC0u, newSlot: 0);
Assert.Equal(new[] { 0x511u, 0x510u }, table.GetContents(0xC0u));
}
[Fact]
public void ContainerIndex_Move_ReparentsBetweenContainers()
{
var table = new ClientObjectTable();
table.Ingest(FullWeenie(0x520u, container: 0xC1u));
table.MoveItem(0x520u, 0xC2u, newSlot: 0);
Assert.Empty(table.GetContents(0xC1u));
Assert.Equal(new[] { 0x520u }, table.GetContents(0xC2u));
}
[Fact]
public void ContainerIndex_Remove_DropsFromContents()
{
var table = new ClientObjectTable();
table.Ingest(FullWeenie(0x530u, container: 0xC3u));
table.Remove(0x530u);
Assert.Empty(table.GetContents(0xC3u));
}
[Fact]
public void GetContents_UnknownContainer_Empty()
{
var table = new ClientObjectTable();
Assert.Empty(table.GetContents(0xDEADu));
}
```
- [ ] **Step 2: Run to verify it fails**
```bash
dotnet test tests/AcDream.Core.Tests/AcDream.Core.Tests.csproj --filter "FullyQualifiedName~ClientObjectTableTests"
```
Expected: FAIL — `GetContents` doesn't exist; `Reindex` is a no-op.
- [ ] **Step 3: Implement the index**
In `ClientObjectTable.cs`, add the field beside `_objects`:
```csharp
private readonly Dictionary<uint, List<uint>> _containerIndex = new();
```
Replace the `Reindex` no-op stub with:
```csharp
private void Reindex(ClientObject obj, uint oldContainerId)
{
if (oldContainerId != obj.ContainerId && oldContainerId != 0
&& _containerIndex.TryGetValue(oldContainerId, out var oldList))
oldList.Remove(obj.ObjectId);
if (obj.ContainerId != 0)
{
if (!_containerIndex.TryGetValue(obj.ContainerId, out var list))
_containerIndex[obj.ContainerId] = list = new List<uint>();
if (!list.Contains(obj.ObjectId)) list.Add(obj.ObjectId);
list.Sort((a, b) => SlotOf(a).CompareTo(SlotOf(b)));
}
}
private int SlotOf(uint guid) =>
_objects.TryGetValue(guid, out var o) ? o.ContainerSlot : int.MaxValue;
/// <summary>Ordered item guids in a container (retail object_inventory_table).</summary>
public IReadOnlyList<uint> GetContents(uint containerId) =>
_containerIndex.TryGetValue(containerId, out var l)
? l : (IReadOnlyList<uint>)System.Array.Empty<uint>();
```
In `MoveItem`, add a `Reindex` call before firing the event (and rename the event to `ObjectMoved`):
```csharp
uint oldContainer = item.ContainerId;
item.ContainerId = newContainerId;
item.ContainerSlot = newSlot;
item.CurrentlyEquippedLocation = newEquipLocation;
Reindex(item, oldContainer);
ObjectMoved?.Invoke(item, oldContainer, newContainerId);
```
In `Remove`, drop from the index before firing `ObjectRemoved`:
```csharp
if (!_objects.TryRemove(itemId, out var item)) return false;
if (item.ContainerId != 0 && _containerIndex.TryGetValue(item.ContainerId, out var l))
l.Remove(itemId);
ObjectRemoved?.Invoke(item);
return true;
```
In `Clear`, also clear the index: add `_containerIndex.Clear();`.
- [ ] **Step 4: Run to verify pass**
```bash
dotnet test tests/AcDream.Core.Tests/AcDream.Core.Tests.csproj --filter "FullyQualifiedName~ClientObjectTableTests"
```
Expected: PASS.
- [ ] **Step 5: Commit**
```bash
git add -A
git commit -m "feat(D.5.4): live container membership index (object_inventory_table)
Reindex on Ingest/MoveItem/Remove; GetContents(containerId) ordered by slot.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>"
```
---
## Task 7: `ObjectTableWiring` + rewire `GameWindow` ingestion off `EnrichItem`
Move CreateObject/DeleteObject/0x02CE ingestion into `AcDream.Core.Net`; `GameWindow` stops calling `EnrichItem`.
**Files:**
- Create: `src/AcDream.Core.Net/ObjectTableWiring.cs`
- Modify: `src/AcDream.App/Rendering/GameWindow.cs`
- Test: `tests/AcDream.Core.Net.Tests/ObjectTableWiringTests.cs`
- [ ] **Step 1: Write the failing test**
Create `tests/AcDream.Core.Net.Tests/ObjectTableWiringTests.cs`. Since `EntitySpawned`/`EntityDeleted` are `WorldSession` events, test the mapping function directly by exposing it as a static. Test the translation `EntitySpawn → WeenieData`:
```csharp
using AcDream.Core.Items;
using AcDream.Core.Net;
using Xunit;
namespace AcDream.Core.Net.Tests;
public sealed class ObjectTableWiringTests
{
[Fact]
public void ToWeenieData_CopiesFieldsFromSpawn()
{
var spawn = new WorldSession.EntitySpawn(
Guid: 0x600u, Position: null, SetupTableId: null,
AnimPartChanges: System.Array.Empty<CreateObject.AnimPartChange>(),
TextureChanges: System.Array.Empty<CreateObject.TextureChange>(),
SubPalettes: System.Array.Empty<CreateObject.SubPaletteSwap>(),
BasePaletteId: null, ObjScale: null, Name: "Gem", ItemType: (uint)Items.ItemType.Gem,
MotionState: null, MotionTableId: null)
{
// positional record — use 'with' for the optional tail
} with { IconId = 0x06001111u, UiEffects = 0x2u, WeenieClassId = 0x10u,
Value = 50, StackSize = 3, ContainerId = 0xC9u };
var d = ObjectTableWiring.ToWeenieData(spawn);
Assert.Equal(0x600u, d.Guid);
Assert.Equal(0x06001111u, d.IconId);
Assert.Equal(0x2u, d.Effects);
Assert.Equal(0x10u, d.WeenieClassId);
Assert.Equal(50, d.Value);
Assert.Equal(3, d.StackSize);
Assert.Equal(0xC9u, d.ContainerId);
Assert.Equal(Items.ItemType.Gem, d.Type);
}
[Fact]
public void Wire_CreateObject_Ingests()
{
var table = new ClientObjectTable();
var session = WorldSessionTestFactory.Create(); // see note below
ObjectTableWiring.Wire(session, table);
session.RaiseEntitySpawnedForTest(new WorldSession.EntitySpawn(
0x601u, null, null,
System.Array.Empty<CreateObject.AnimPartChange>(),
System.Array.Empty<CreateObject.TextureChange>(),
System.Array.Empty<CreateObject.SubPaletteSwap>(),
null, null, "Coin", (uint)Items.ItemType.Money, null, null)
{ } with { IconId = 0x06002222u });
Assert.Equal(0x06002222u, table.Get(0x601u)!.IconId);
}
}
```
> NOTE: if `WorldSession` cannot be constructed/raised directly in a test, drop `Wire_CreateObject_Ingests` and keep only `ToWeenieData_CopiesFieldsFromSpawn` (the pure mapping is the load-bearing logic; the `Wire` subscription is verified by build + the live run). Do NOT invent a `WorldSessionTestFactory`/`RaiseEntitySpawnedForTest` if no equivalent test seam exists — check `tests/AcDream.Core.Net.Tests` for how `WorldSession` is exercised first.
- [ ] **Step 2: Run to verify it fails**
```bash
dotnet test tests/AcDream.Core.Net.Tests/AcDream.Core.Net.Tests.csproj --filter "FullyQualifiedName~ObjectTableWiringTests"
```
Expected: FAIL — `ObjectTableWiring` doesn't exist.
- [ ] **Step 3: Create `ObjectTableWiring`**
`src/AcDream.Core.Net/ObjectTableWiring.cs`:
```csharp
using AcDream.Core.Items;
namespace AcDream.Core.Net;
/// <summary>
/// Wires WorldSession GameMessage-level object events into the client object
/// table: CreateObject (0xF745) = canonical merge-upsert, DeleteObject (0xF747)
/// = evict, PublicUpdatePropertyInt (0x02CE) UiEffects = live icon re-composite.
/// Keeps object ingestion in Core.Net (pure data, no GL) and off GameWindow.
/// Retail: ACCObjectMaint::CreateObject / DeleteObject (the weenie_object_table side).
/// </summary>
public static class ObjectTableWiring
{
public static void Wire(WorldSession session, ClientObjectTable table)
{
System.ArgumentNullException.ThrowIfNull(session);
System.ArgumentNullException.ThrowIfNull(table);
session.EntitySpawned += s => table.Ingest(ToWeenieData(s));
session.EntityDeleted += d => table.Remove(d.Guid);
session.ObjectIntPropertyUpdated += u =>
{
if (u.Property == ClientObjectTable.UiEffectsPropertyId)
table.UpdateIntProperty(u.Guid, u.Property, u.Value);
};
}
/// <summary>Translate the wire spawn into the table's merge patch.</summary>
public static WeenieData ToWeenieData(WorldSession.EntitySpawn s) => new(
Guid: s.Guid,
Name: s.Name,
Type: s.ItemType is { } it ? (ItemType)it : (ItemType?)null,
WeenieClassId: s.WeenieClassId,
IconId: s.IconId,
IconOverlayId: s.IconOverlayId,
IconUnderlayId: s.IconUnderlayId,
Effects: s.UiEffects,
Value: s.Value,
StackSize: s.StackSize,
StackSizeMax: s.StackSizeMax,
Burden: s.Burden,
ContainerId: s.ContainerId,
WielderId: s.WielderId,
ValidLocations: s.ValidLocations,
CurrentWieldedLocation: s.CurrentWieldedLocation,
Priority: s.Priority,
ItemsCapacity: s.ItemsCapacity,
ContainersCapacity: s.ContainersCapacity,
Structure: s.Structure,
MaxStructure: s.MaxStructure,
Workmanship: s.Workmanship);
}
```
- [ ] **Step 4: Rewire `GameWindow`**
In `GameWindow.cs`:
- In `WireLiveSessionEvents` (the `_liveSession.EntitySpawned += OnLiveEntitySpawned;` block), add right after assigning `_liveSession`:
```csharp
AcDream.Core.Net.ObjectTableWiring.Wire(session, Objects);
```
- In `OnLiveEntitySpawned`, **delete** the `Objects.EnrichItem(...)` call (the whole 4-line `D.5.1: enrich...` block) — ingestion now happens in `ObjectTableWiring`. Leave the `lock (_datLock) { OnLiveEntitySpawnedLocked(spawn); }` render path.
- Delete the inline `_liveSession.ObjectIntPropertyUpdated += u => { ... Objects.UpdateIntProperty ... };` block (now in `ObjectTableWiring`).
- [ ] **Step 5: Run to verify pass + build**
```bash
dotnet build
dotnet test
```
Expected: build succeeds; full suite PASS (the new `ObjectTableWiringTests` + all existing). The toolbar now gets its icons via `Ingest``ObjectAdded`/`ObjectUpdated`.
- [ ] **Step 6: Commit**
```bash
git add -A
git commit -m "feat(D.5.4): ObjectTableWiring (CreateObject=upsert, Delete=evict, 0x02CE) off GameWindow
CreateObject ingestion moves to Core.Net; GameWindow drops the EnrichItem call +
inline 0x02CE handler. Fixes the Coldeve blank-icon root cause: items with no PD
stub are now created, not dropped.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>"
```
---
## Task 8: `PlayerDescription` → membership manifest (fix the `WeenieClassId` misuse)
**Files:**
- Modify: `src/AcDream.Core.Net/GameEventWiring.cs`
- Test: `tests/AcDream.Core.Net.Tests/GameEventWiringTests.cs`
- [ ] **Step 1: Write the failing test**
Add to `GameEventWiringTests.cs` (match the file's existing dispatch-test style; adapt names to its helpers):
```csharp
[Fact]
public void PlayerDescription_SeedsMembership_NotWeenieClassIdMisuse()
{
var table = new ClientObjectTable();
// ... build + dispatch a PlayerDescription with one inventory guid 0x700
// (ContainerType=1) and one equipped guid 0x701 (EquipLocation=MeleeWeapon),
// using the same harness the existing PlayerDescription test uses ...
Assert.NotNull(table.Get(0x700u));
Assert.Equal(0u, table.Get(0x700u)!.WeenieClassId); // NOT the ContainerType (1)
Assert.Equal(EquipMask.MeleeWeapon, table.Get(0x701u)!.CurrentlyEquippedLocation);
}
```
> If the existing PlayerDescription test already builds a parser fixture, reuse its builder; otherwise model this test on the existing `PlayerDescription` registration test in the file.
- [ ] **Step 2: Run to verify it fails**
```bash
dotnet test tests/AcDream.Core.Net.Tests/AcDream.Core.Net.Tests.csproj --filter "FullyQualifiedName~GameEventWiringTests"
```
Expected: FAIL — current code sets `WeenieClassId = inv.ContainerType` (1, not 0).
- [ ] **Step 3: Replace the PD seeding block**
In `GameEventWiring.cs`, replace the inventory/equipped seeding loops (the `foreach (var inv ...)` and `foreach (var eq ...)` blocks) with:
```csharp
// D.5.4: PlayerDescription is a membership MANIFEST, not the data
// source. Record existence (+ equip slot); CreateObject fills the
// actual weenie data via ObjectTableWiring. (Previously this seeded
// stubs with WeenieClassId = ContainerType, a misuse.)
foreach (var inv in p.Value.Inventory)
items.RecordMembership(inv.Guid);
foreach (var eq in p.Value.Equipped)
items.RecordMembership(eq.Guid, equip: (EquipMask)eq.EquipLocation);
```
(`items` is now a `ClientObjectTable` after Task 1; `RecordMembership` from Task 5.)
- [ ] **Step 4: Run to verify pass + build**
```bash
dotnet test tests/AcDream.Core.Net.Tests/AcDream.Core.Net.Tests.csproj --filter "FullyQualifiedName~GameEventWiringTests"
dotnet build
```
Expected: PASS; build green.
- [ ] **Step 5: Commit**
```bash
git add -A
git commit -m "feat(D.5.4): PlayerDescription = membership manifest; drop WeenieClassId=ContainerType misuse
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>"
```
---
## Task 9: Delete `EnrichItem` + migrate its tests to `Ingest`
`EnrichItem` is now unused (Task 7 removed its only caller). Remove it and port its remaining contract tests.
**Files:**
- Modify: `src/AcDream.Core/Items/ClientObjectTable.cs`
- Modify: `tests/AcDream.Core.Tests/Items/ClientObjectTableTests.cs`
- [ ] **Step 1: Confirm no callers**
```bash
grep -rn "EnrichItem" src tests
```
Expected: hits only in `ClientObjectTable.cs` (definition) and `ClientObjectTableTests.cs` (the old tests). If any *production* caller remains, stop and rewire it to `Ingest` first.
- [ ] **Step 2: Delete `EnrichItem`**
Remove the entire `EnrichItem` method from `ClientObjectTable.cs` (the `public bool EnrichItem(...)` block).
- [ ] **Step 3: Delete/port the EnrichItem tests**
In `ClientObjectTableTests.cs`, delete `EnrichItem_updatesIconOnExistingStub_andRaisesUpdated`, `EnrichItem_returnsFalse_whenItemUnknown`, `EnrichItem_carriesEffects`, and `EnrichItem_effectsZero_clearsPriorEffects`. Their contracts are already covered by Task 5's `Ingest_*` tests (`Ingest_NewItemWithNoPriorStub_*`, `Ingest_Effects_AssignedUnconditionally_ClearsToZero`, `Ingest_Existing_PatchesInPlace_*`). Keep `UpdateIntProperty_*` tests (the 0x02CE path is unchanged).
- [ ] **Step 4: Build + test green**
```bash
dotnet build
dotnet test
```
Expected: build succeeds (no `EnrichItem` references); full suite PASS.
- [ ] **Step 5: Commit**
```bash
git add -A
git commit -m "refactor(D.5.4): delete EnrichItem (superseded by Ingest merge-upsert)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>"
```
---
## Task 10: Retire `_liveEntityInfoByGuid` → resolve from `ClientObjectTable`
All objects are now in the table, so the redundant Name+ItemType dictionary can go.
**Files:**
- Modify: `src/AcDream.App/Rendering/GameWindow.cs`
- [ ] **Step 1: Add resolve helpers**
In `GameWindow.cs`, add two private helpers (near `DescribeLiveEntity`):
```csharp
private AcDream.Core.Items.ItemType LiveItemType(uint guid) =>
Objects.Get(guid)?.Type ?? AcDream.Core.Items.ItemType.None;
private string? LiveName(uint guid) => Objects.Get(guid)?.Name;
```
- [ ] **Step 2: Migrate every `_liveEntityInfoByGuid` read**
Replace each read site (verified locations) with the table lookup:
- target-indicator `entityResolver` (~line 1308-1316): `if (_liveEntityInfoByGuid.TryGetValue(guid, out var info)) rawItemType = (uint)info.ItemType;``rawItemType = (uint)LiveItemType(guid);`
- door-cycle diagnostic (~3821): `_liveEntityInfoByGuid.TryGetValue(update.Guid, out var doorInfo) && IsDoorName(doorInfo.Name)``IsDoorName(LiveName(update.Guid))`
- picker diagnostic (~11604): same pattern → `rawItemType = (uint)LiveItemType(guid);`
- `isCreature` for SendUse (~11663): `_liveEntityInfoByGuid.TryGetValue(sel, out var info) && (info.ItemType & ...Creature) != 0``(LiveItemType(sel) & AcDream.Core.Items.ItemType.Creature) != 0`
- use-radius heuristics #1/#2 (~11905, ~11933): same Creature-bit check → `(LiveItemType(targetGuid) & ...Creature) != 0`
- `IsLiveCreatureTarget` (~12009): keep the `_entitiesByServerGuid.ContainsKey` guard; replace the info lookup with `return (LiveItemType(guid) & AcDream.Core.Items.ItemType.Creature) != 0;`
- useability creature fallback (~12185): `(LiveItemType(guid) & ...Creature) != 0`
- `DescribeLiveEntity` (~12294): `var name = LiveName(guid); if (!string.IsNullOrWhiteSpace(name)) return name!;`
Ensure `IsDoorName` tolerates a null arg (it takes `string?`; if it doesn't, guard: `LiveName(...) is { } dn && IsDoorName(dn)`).
- [ ] **Step 3: Delete the dictionary, its record, and its write/remove**
- Delete `private readonly Dictionary<uint, LiveEntityInfo> _liveEntityInfoByGuid = new();` (~840).
- Delete the `LiveEntityInfo` record (~857-859).
- Delete the write in `OnLiveEntitySpawnedLocked` (~2720-2724, the `_liveEntityInfoByGuid[spawn.Guid] = new LiveEntityInfo(...)` block).
- Delete `_liveEntityInfoByGuid.Remove(serverGuid);` in `RemoveLiveEntityByServerGuid` (~3731).
- [ ] **Step 4: Build + test green**
```bash
dotnet build
dotnet test
```
Expected: build succeeds (no `_liveEntityInfoByGuid`/`LiveEntityInfo` references remain — grep to confirm: `grep -rn "_liveEntityInfoByGuid\|LiveEntityInfo" src` returns nothing); full suite PASS.
- [ ] **Step 5: Commit**
```bash
git add -A
git commit -m "refactor(D.5.4): retire _liveEntityInfoByGuid; selection resolves from ClientObjectTable
The one weenie table now holds every object's name+type, so the redundant
Name+ItemType dictionary is gone (retail: one weenie_object_table).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>"
```
---
## Task 11: `ToolbarController` — guid-filtered re-bind + `ObjectRemoved`
With all objects ingested, every creature spawn fires `ObjectAdded`. Filter so only shortcut-guid changes re-`Populate`, and clear a slot when its item is removed.
**Files:**
- Modify: `src/AcDream.App/UI/Layout/ToolbarController.cs`
- Test: `tests/AcDream.App.Tests/...` if an App test project exists; otherwise verify by build + the live run (note in commit).
- [ ] **Step 1: Add a shortcut-guid filter + replace the subscriptions**
In `ToolbarController.cs`, replace:
```csharp
repo.ItemAdded += _ => Populate(); // (already renamed to ObjectAdded in Task 1)
repo.ItemPropertiesUpdated += _ => Populate();
```
with:
```csharp
// D.5.4: the table now holds ALL objects, so filter to our shortcut guids
// (else every creature spawn re-populates the bar).
repo.ObjectAdded += o => { if (IsShortcutGuid(o.ObjectId)) Populate(); };
repo.ObjectUpdated += o => { if (IsShortcutGuid(o.ObjectId)) Populate(); };
repo.ObjectRemoved += o => { if (IsShortcutGuid(o.ObjectId)) Populate(); };
```
Add the helper:
```csharp
private bool IsShortcutGuid(uint guid)
{
foreach (var sc in _shortcuts())
if (sc.ObjectGuid == guid) return true;
return false;
}
```
- [ ] **Step 2: Build + test green**
```bash
dotnet build
dotnet test
```
Expected: build succeeds; full suite PASS.
- [ ] **Step 3: Commit**
```bash
git add -A
git commit -m "perf(D.5.4): toolbar re-binds only on shortcut-guid object changes; clear on remove
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>"
```
---
## Task 12: Bookkeeping + final verification + live run
**Files:**
- Modify: `docs/plans/2026-04-11-roadmap.md`, `docs/architecture/retail-divergence-register.md`, `claude-memory/` (+ `MEMORY.md` index).
- [ ] **Step 1: Roadmap — mark D.5.4 shipped**
In `docs/plans/2026-04-11-roadmap.md`, change the `☐ D.5.4` ledger line to `✓ SHIPPED — D.5.4` with a one-paragraph summary (CreateObject canonical merge-upsert, all-objects table, container index, `_liveEntityInfoByGuid` retired, Coldeve blank-icon root fix) and the commit range.
- [ ] **Step 2: Divergence register**
In `docs/architecture/retail-divergence-register.md`: delete the enrich-only stopgap row(s) (the behavior is gone). Add a row for the global-event-with-guid-filter consumer model vs. retail's per-object `NoticeRegistrar`, and a row noting the deferred `null_object_table` parent/child pre-queue.
- [ ] **Step 3: Memory digest**
If there's a durable lesson (e.g. "retail is two tables, not one — keep render/data split"), add/update a `claude-memory/` note + a one-line `MEMORY.md` index entry. Keep the index line under ~200 chars.
- [ ] **Step 4: Full build + test**
```bash
dotnet build
dotnet test
```
Expected: build succeeds; entire suite PASS.
- [ ] **Step 5: Live run (visual gate — user confirms)**
Launch against the local ACE server (per CLAUDE.md "Running the client"):
```powershell
$env:ACDREAM_DAT_DIR = "$env:USERPROFILE\Documents\Asheron's Call"
$env:ACDREAM_LIVE = "1"; $env:ACDREAM_TEST_HOST = "127.0.0.1"; $env:ACDREAM_TEST_PORT = "9000"
$env:ACDREAM_TEST_USER = "testaccount"; $env:ACDREAM_TEST_PASS = "testpassword"; $env:ACDREAM_RETAIL_UI = "1"
dotnet run --project src\AcDream.App\AcDream.App.csproj --no-build -c Debug 2>&1 | Tee-Object -FilePath "d54.log"
```
Acceptance: the toolbar/hotbar now renders icons for items that were NOT in the login inventory snapshot (the Coldeve repro — previously 4/6 blank). The user confirms visually.
- [ ] **Step 6: Commit bookkeeping**
```bash
git add -A
git commit -m "docs(D.5.4): roadmap shipped + divergence register + memory
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>"
```
---
## Self-review notes (author)
- **Spec coverage:** §4 in-scope items each map to a task — rename (T1), field capture (T2/T3/T4), merge-upsert + RecordMembership (T5), container index (T6), wiring off GameWindow + DeleteObject evict (T7), PD manifest + WeenieClassId fix (T8), EnrichItem delete (T9), `_liveEntityInfoByGuid` retire (T10), toolbar guid-filter (T11), bookkeeping (T12). Out-of-scope items (panels, ViewContents, drag-drop wire, ShortCutManager, null_object_table) are untouched.
- **Type consistency:** `ClientObject`/`ClientObjectTable`/`WeenieData`/`Ingest`/`RecordMembership`/`GetContents`/`ObjectAdded`/`ObjectUpdated`/`ObjectMoved`/`ObjectRemoved`/`Objects`/`Get` are used identically across tasks.
- **Known soft spots flagged inline:** the `ObjectTableWiring` `Wire` test depends on a `WorldSession` test seam that may not exist (Task 7 Step 1 note — fall back to the pure `ToWeenieData` test); `GameEventWiringTests` PD fixture should reuse the file's existing harness (Task 8 Step 1 note). The executor verifies these against the real test files before writing.