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