From 2fc253d9ff48baf0330fc7eee19dc261c1824623 Mon Sep 17 00:00:00 2001 From: Erik Date: Thu, 18 Jun 2026 15:19:15 +0200 Subject: [PATCH] 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) --- .../plans/2026-06-18-d54-object-item-model.md | 1344 +++++++++++++++++ 1 file changed, 1344 insertions(+) create mode 100644 docs/superpowers/plans/2026-06-18-d54-object-item-model.md diff --git a/docs/superpowers/plans/2026-06-18-d54-object-item-model.md b/docs/superpowers/plans/2026-06-18-d54-object-item-model.md new file mode 100644 index 00000000..9bfcee13 --- /dev/null +++ b/docs/superpowers/plans/2026-06-18-d54-object-item-model.md @@ -0,0 +1,1344 @@ +# 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? ItemAdded` → `event Action? ObjectAdded` +- `event Action? ItemMoved` → `event Action? ObjectMoved` +- `event Action? ItemRemoved` → `event Action? ObjectRemoved` +- `event Action? ItemPropertiesUpdated` → `event Action? ObjectUpdated` +- `public int ItemCount` → `public int ObjectCount` +- `public IEnumerable Items` → `public IEnumerable 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) " +``` + +--- + +## 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 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) " +``` + +--- + +## 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) " +``` + +--- + +## 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 +/// +/// The wire-delivered patch from a CreateObject (0xF745). Nullable fields +/// were gated by a WeenieHeader flag that was ABSENT — the merge upsert +/// () leaves the existing value untouched +/// for those, matching retail's SetWeenieDesc (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. +/// +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) " +``` + +--- + +## 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 + /// + /// 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. + /// + 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; + } + + /// + /// PlayerDescription manifest: record that this guid is the player's + /// (in inventory or equipped at ), creating an + /// empty entry if CreateObject hasn't arrived yet. Never touches + /// icon/name/type/effects — that data comes from CreateObject. + /// + 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) " +``` + +--- + +## 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> _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(); + 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; + + /// Ordered item guids in a container (retail object_inventory_table). + public IReadOnlyList GetContents(uint containerId) => + _containerIndex.TryGetValue(containerId, out var l) + ? l : (IReadOnlyList)System.Array.Empty(); +``` +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) " +``` + +--- + +## 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(), + TextureChanges: System.Array.Empty(), + SubPalettes: System.Array.Empty(), + 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(), + System.Array.Empty(), + System.Array.Empty(), + 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; + +/// +/// 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). +/// +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); + }; + } + + /// Translate the wire spawn into the table's merge patch. + 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) " +``` + +--- + +## 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) " +``` + +--- + +## 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) " +``` + +--- + +## 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 _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) " +``` + +--- + +## 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) " +``` + +--- + +## 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) " +``` + +--- + +## 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.