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>
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 — 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):
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 ItemInstance → public sealed class ClientObject. (The ItemType, EquipMask, PropertyBundle, Container, BurdenMath types in this file keep their names.) Update the XML-doc summary on the class from "Per-item live state" to "Per-object live state (the data side of every server object — items and creatures alike). Retail ACCWeenieObject."
- Step 4: Rename the type + members in
ClientObjectTable.cs
In src/AcDream.Core/Items/ClientObjectTable.cs, apply (replace_all per token):
-
public sealed class ItemRepository→public sealed class ClientObjectTable -
every
ItemInstance→ClientObject(field types, event generic args, params) -
event Action<ItemInstance>? ItemAdded→event Action<ClientObject>? ObjectAdded -
event Action<ItemInstance, uint, uint>? ItemMoved→event Action<ClientObject, uint, uint>? ObjectMoved -
event Action<ItemInstance>? ItemRemoved→event Action<ClientObject>? ObjectRemoved -
event Action<ItemInstance>? ItemPropertiesUpdated→event Action<ClientObject>? ObjectUpdated -
public int ItemCount→public int ObjectCount -
public IEnumerable<ItemInstance> Items→public IEnumerable<ClientObject> Objects -
public ItemInstance? GetItem(uint objectId)→public ClientObject? Get(uint objectId) -
update every internal
ItemAdded?.Invoke/ItemPropertiesUpdated?.Invoke/ItemMoved?.Invoke/ItemRemoved?.Invoketo 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); everyItemRepository.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)
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
Parsedrecord
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
Parsedconstruction 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
EntitySpawnrecord
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.Invokesite
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
WeenieDataDTO
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
WorldSessioncannot be constructed/raised directly in a test, dropWire_CreateObject_Ingestsand keep onlyToWeenieData_CopiesFieldsFromSpawn(the pure mapping is the load-bearing logic; theWiresubscription is verified by build + the live run). Do NOT invent aWorldSessionTestFactory/RaiseEntitySpawnedForTestif no equivalent test seam exists — checktests/AcDream.Core.Net.Testsfor howWorldSessionis 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 theObjects.EnrichItem(...)call (the whole 4-lineD.5.1: enrich...block) — ingestion now happens inObjectTableWiring. Leave thelock (_datLock) { OnLiveEntitySpawnedLocked(spawn); }render path. -
Delete the inline
_liveSession.ObjectIntPropertyUpdated += u => { ... Objects.UpdateIntProperty ... };block (now inObjectTableWiring). -
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 Ingest → ObjectAdded/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
PlayerDescriptionregistration 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
_liveEntityInfoByGuidread
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); isCreaturefor 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.ContainsKeyguard; replace the info lookup withreturn (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
LiveEntityInforecord (~857-859). -
Delete the write in
OnLiveEntitySpawnedLocked(~2720-2724, the_liveEntityInfoByGuid[spawn.Guid] = new LiveEntityInfo(...)block). -
Delete
_liveEntityInfoByGuid.Remove(serverGuid);inRemoveLiveEntityByServerGuid(~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.mdindex). -
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),
_liveEntityInfoByGuidretire (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/Getare used identically across tasks. - Known soft spots flagged inline: the
ObjectTableWiringWiretest depends on aWorldSessiontest seam that may not exist (Task 7 Step 1 note — fall back to the pureToWeenieDatatest);GameEventWiringTestsPD fixture should reuse the file's existing harness (Task 8 Step 1 note). The executor verifies these against the real test files before writing.