acdream/docs/superpowers/plans/2026-06-18-d54-object-item-model.md
Erik e4dd37a3b8 docs(D.5.4): plan — StackSizeMax int? for downstream type consistency
Code-review follow-up from Task 2: align StackSizeMax with the other quantity
fields (int?, ACE PropertyInt convention) in Tasks 3/4/5; drop the (int) cast.

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

58 KiB

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

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 — ItemInstanceClientObject, ItemRepositoryClientObjectTable

Pure refactor, no behavior change. Do this first so every later task uses the new names.

Files:

  • Rename: src/AcDream.Core/Items/ItemInstance.csClientObject.cs

  • Rename: src/AcDream.Core/Items/ItemRepository.csClientObjectTable.cs

  • Rename: tests/AcDream.Core.Tests/Items/ItemRepositoryTests.csClientObjectTableTests.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):

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
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 ItemInstancepublic 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 ItemRepositorypublic sealed class ClientObjectTable

  • every ItemInstanceClientObject (field types, event generic args, params)

  • event Action<ItemInstance>? ItemAddedevent Action<ClientObject>? ObjectAdded

  • event Action<ItemInstance, uint, uint>? ItemMovedevent Action<ClientObject, uint, uint>? ObjectMoved

  • event Action<ItemInstance>? ItemRemovedevent Action<ClientObject>? ObjectRemoved

  • event Action<ItemInstance>? ItemPropertiesUpdatedevent Action<ClientObject>? ObjectUpdated

  • public int ItemCountpublic int ObjectCount

  • public IEnumerable<ItemInstance> Itemspublic 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 itemsClientObjectTable items; new ItemInstancenew ClientObject; items.GetItemitems.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.UiEffectsPropertyIdClientObjectTable.UiEffectsPropertyId.
  • the WireAll(_liveSession.GameEvents, Items, ...) arg → Objects.

In src/AcDream.App/UI/Layout/ToolbarController.cs: ItemRepositoryClientObjectTable (field _repo, ctor param); repo.ItemAddedrepo.ObjectAdded; repo.ItemPropertiesUpdatedrepo.ObjectUpdated; _repo.GetItem_repo.Get.

In tests/AcDream.Core.Tests/Items/ClientObjectTableTests.cs: ItemRepositoryClientObjectTable; ItemInstanceClientObject; repo.GetItemrepo.Get; event names; ItemCountObjectCount. (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)
dotnet build
dotnet test

Expected: build succeeds; full suite PASS (same count as before, just renamed).

  • Step 7: Commit
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):

// 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:

    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:

[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(250, p.Value);
    Assert.Equal(7, p.StackSize);
    Assert.Equal(100, 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
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,):

        // 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,
        int? 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):

            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:

                    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):

                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):

                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
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
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 ,):

        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,
        int? 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):

                        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
dotnet build
dotnet test

Expected: build succeeds; full suite PASS.

  • Step 4: Commit
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:

    [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
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:

    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:

/// <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,
    int? 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
dotnet test tests/AcDream.Core.Tests/AcDream.Core.Tests.csproj --filter "FullyQualifiedName~ClientObjectTableTests"

Expected: PASS.

  • Step 6: Commit
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):

    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
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):

    /// <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 = 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
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
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

    [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
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:

    private readonly Dictionary<uint, List<uint>> _containerIndex = new();

Replace the Reindex no-op stub with:

    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):

        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:

        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
dotnet test tests/AcDream.Core.Tests/AcDream.Core.Tests.csproj --filter "FullyQualifiedName~ClientObjectTableTests"

Expected: PASS.

  • Step 5: Commit
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:

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
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:

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:
        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

dotnet build
dotnet test

Expected: build succeeds; full suite PASS (the new ObjectTableWiringTests + all existing). The toolbar now gets its icons via IngestObjectAdded/ObjectUpdated.

  • Step 6: Commit
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):

    [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
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:

            // 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
dotnet test tests/AcDream.Core.Net.Tests/AcDream.Core.Net.Tests.csproj --filter "FullyQualifiedName~GameEventWiringTests"
dotnet build

Expected: PASS; build green.

  • Step 5: Commit
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

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
dotnet build
dotnet test

Expected: build succeeds (no EnrichItem references); full suite PASS.

  • Step 5: Commit
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):

    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

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
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:

        repo.ItemAdded             += _ => Populate();   // (already renamed to ObjectAdded in Task 1)
        repo.ItemPropertiesUpdated += _ => Populate();

with:

        // 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:

    private bool IsShortcutGuid(uint guid)
    {
        foreach (var sc in _shortcuts())
            if (sc.ObjectGuid == guid) return true;
        return false;
    }
  • Step 2: Build + test green
dotnet build
dotnet test

Expected: build succeeds; full suite PASS.

  • Step 3: Commit
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
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"):

$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
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.