docs(D.5.2): implementation plan (9 TDD tasks) + spec wiring fix

Bite-sized TDD plan for the stateful item-icon system. Corrects spec 5.8:
the live 0x02CE event binds in GameWindow (next to VitalUpdated), not
GameEventWiring (which only handles the 0xF7B0 GameEvent dispatcher).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Erik 2026-06-17 18:19:26 +02:00
parent 419c3ac40c
commit 52306d9268
2 changed files with 981 additions and 3 deletions

View file

@ -0,0 +1,973 @@
# Stateful item-icon system (D.5.2) — 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 the item icon a live function of the item's state — capture the discarded `UiEffects` bitfield, build retail's faithful effect-recolor in the icon compositor, and wire the live `PublicUpdatePropertyInt(0x02CE)` update so the icon re-composites in real time.
**Architecture:** `UiEffects` flows `CreateObject → EntitySpawn → ItemInstance.Effects` and, live, `PublicUpdatePropertyInt(0x02CE) → ItemRepository.UpdateIntProperty → ItemInstance.Effects`. Any change fires `ItemPropertiesUpdated`, which the bound `UiItemSlot` already re-resolves via `IconComposer.GetIcon(…, effects)`. The compositor mirrors retail `IconData::RenderIcons`: a 2-stage composite where the effect tile (`enum 0x10000005`) supplies a `ReplaceColor(white → effectColor)` tint, never a blit layer.
**Tech Stack:** C# .NET 10, xUnit, `DatReaderWriter` (EnumIDMap/RenderSurface), Silk.NET (GL via `TextureCache`).
**Spec:** [`docs/superpowers/specs/2026-06-17-d2b-stateful-icon-design.md`](../specs/2026-06-17-d2b-stateful-icon-design.md). **Research:** [`docs/research/2026-06-17-stateful-icon-RESOLVED.md`](../../research/2026-06-17-stateful-icon-RESOLVED.md).
**Conventions:** Every commit appends the CLAUDE.md co-author trailer:
`Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>`. Build with `dotnet build`; the tree must be green after every task.
---
### Task 1: Core data model — `ItemInstance.Effects` + `ItemRepository` hooks
**Files:**
- Modify: `src/AcDream.Core/Items/ItemInstance.cs` (add `Effects` field, ~line 138)
- Modify: `src/AcDream.Core/Items/ItemRepository.cs` (`EnrichItem` +param; add `UpdateIntProperty`)
- Test: `tests/AcDream.Core.Tests/Items/ItemRepositoryTests.cs`
- [ ] **Step 1: Write the failing tests**
Add to `ItemRepositoryTests.cs`:
```csharp
[Fact]
public void EnrichItem_carriesEffects()
{
var repo = new ItemRepository();
repo.AddOrUpdate(new ItemInstance { ObjectId = 0x500000AAu });
bool ok = repo.EnrichItem(0x500000AAu, iconId: 0x06001234u, name: "Wand",
type: ItemType.Caster, iconOverlayId: 0, iconUnderlayId: 0, effects: 0x1u);
Assert.True(ok);
Assert.Equal(0x1u, repo.GetItem(0x500000AAu)!.Effects);
}
[Fact]
public void UpdateIntProperty_uiEffects_setsEffectsAndFires()
{
var repo = new ItemRepository();
repo.AddOrUpdate(new ItemInstance { ObjectId = 0x500000ABu });
ItemInstance? fired = null;
repo.ItemPropertiesUpdated += i => fired = i;
bool ok = repo.UpdateIntProperty(0x500000ABu, 18u, value: 0x9); // 18 = UiEffects
Assert.True(ok);
Assert.Equal(0x9u, repo.GetItem(0x500000ABu)!.Effects);
Assert.Equal(0x9, repo.GetItem(0x500000ABu)!.Properties.Ints[18u]);
Assert.NotNull(fired);
}
[Fact]
public void UpdateIntProperty_unknownItem_returnsFalse()
{
var repo = new ItemRepository();
Assert.False(repo.UpdateIntProperty(0xDEADBEEFu, 18u, 1));
}
```
- [ ] **Step 2: Run tests to verify they fail**
Run: `dotnet test tests/AcDream.Core.Tests/AcDream.Core.Tests.csproj --filter "FullyQualifiedName~ItemRepositoryTests"`
Expected: FAIL — `EnrichItem` has no `effects` param; `UpdateIntProperty`/`Effects` don't exist.
- [ ] **Step 3: Add the `Effects` field**
In `ItemInstance.cs`, after the `IconOverlayId` property (~line 138):
```csharp
/// <summary>
/// UiEffects bitfield (retail PublicWeenieDesc._effects, acclient.h:37183).
/// Drives the icon's effect-overlay recolor (Magical=0x1 … Nether=0x1000).
/// CreateObject-only (weenieFlags 0x80) + live PublicUpdatePropertyInt(0x02CE);
/// appraise never carries it. 0 = no effect.
/// </summary>
public uint Effects { get; set; }
```
- [ ] **Step 4: Add the `effects` param to `EnrichItem`**
In `ItemRepository.cs`, change the `EnrichItem` signature + body:
```csharp
public bool EnrichItem(uint objectId, uint iconId, string name, ItemType type,
uint iconOverlayId = 0, uint iconUnderlayId = 0, uint effects = 0)
{
if (!_items.TryGetValue(objectId, out var item)) return false;
if (iconId != 0) item.IconId = iconId;
if (!string.IsNullOrEmpty(name)) item.Name = name;
if (type != default) item.Type = type;
if (iconOverlayId != 0) item.IconOverlayId = iconOverlayId;
if (iconUnderlayId != 0) item.IconUnderlayId = iconUnderlayId;
// D.5.2: 0 is a meaningful "no effect" state (e.g. a caster out of mana),
// so assign unconditionally — re-composition reflects the CURRENT state.
item.Effects = effects;
ItemPropertiesUpdated?.Invoke(item);
return true;
}
```
- [ ] **Step 5: Add `UpdateIntProperty`**
In `ItemRepository.cs`, add after `UpdateProperties`:
```csharp
/// <summary>PropertyInt.UiEffects (ACE enum value 18) — the icon effect bitfield.</summary>
public const uint UiEffectsPropertyId = 18u;
/// <summary>
/// Apply a single PropertyInt update (from PublicUpdatePropertyInt 0x02CE) to an
/// item: store it in the bundle and, for known typed ints, mirror to the typed
/// field. Today: UiEffects (18) → <see cref="ItemInstance.Effects"/>. Fires
/// ItemPropertiesUpdated so bound widgets re-composite. Extensible hook for future
/// typed PropertyInts (StackSize, Structure, …). False if the item is unknown.
/// </summary>
public bool UpdateIntProperty(uint itemId, uint propertyId, int value)
{
if (!_items.TryGetValue(itemId, out var item)) return false;
item.Properties.Ints[propertyId] = value;
if (propertyId == UiEffectsPropertyId) item.Effects = (uint)value;
ItemPropertiesUpdated?.Invoke(item);
return true;
}
```
- [ ] **Step 6: Run tests to verify they pass**
Run: `dotnet test tests/AcDream.Core.Tests/AcDream.Core.Tests.csproj --filter "FullyQualifiedName~ItemRepositoryTests"`
Expected: PASS.
- [ ] **Step 7: Commit**
```bash
git add src/AcDream.Core/Items/ItemInstance.cs src/AcDream.Core/Items/ItemRepository.cs tests/AcDream.Core.Tests/Items/ItemRepositoryTests.cs
git commit -m "feat(D.5.2): ItemInstance.Effects + ItemRepository.UpdateIntProperty"
```
---
### Task 2: Capture `UiEffects` from `CreateObject`
**Files:**
- Modify: `src/AcDream.Core.Net/Messages/CreateObject.cs` (record field, capture site, ctor call)
- Test: `tests/AcDream.Core.Net.Tests/Messages/CreateObjectTests.cs`
- [ ] **Step 1: Write the failing tests**
In `CreateObjectTests.cs`, add a `uiEffects` parameter to the builder and write it for the
UiEffects field. Change the builder signature (add `uint uiEffects = 0,` next to `iconId`)
and the UiEffects write line (currently `WriteU32(bytes, 0); // UiEffects u32`):
```csharp
if ((weenieFlags & 0x00000080u) != 0) WriteU32(bytes, uiEffects); // UiEffects u32
```
Then add the tests:
```csharp
[Fact]
public void TryParse_UiEffects_Captured()
{
// weenieFlags 0x80 = UiEffects; value 0x1 = Magical.
byte[] body = BuildMinimalCreateObjectWithWeenieHeader(
guid: 0x50000010u, name: "MagicWand", itemType: (uint)ItemType.Caster,
weenieFlags: 0x80u, uiEffects: 0x1u);
var parsed = CreateObject.TryParse(body);
Assert.NotNull(parsed);
Assert.Equal(0x1u, parsed!.Value.UiEffects);
}
[Fact]
public void TryParse_UiEffectsThenIconOverlay_BothCaptured()
{
// Verifies the cursor still reaches IconOverlay after reading (not skipping) UiEffects.
byte[] body = BuildMinimalCreateObjectWithWeenieHeader(
guid: 0x50000011u, name: "GlowSword", itemType: (uint)ItemType.MeleeWeapon,
weenieFlags: 0x80u | 0x40000000u, uiEffects: 0x4u, iconOverlayId: 0x1ABCu);
var parsed = CreateObject.TryParse(body);
Assert.NotNull(parsed);
Assert.Equal(0x4u, parsed!.Value.UiEffects);
Assert.Equal(0x06001ABCu, parsed.Value.IconOverlayId);
}
[Fact]
public void TryParse_NoUiEffectsBit_LeavesUiEffectsZero()
{
byte[] body = BuildMinimalCreateObjectWithWeenieHeader(
guid: 0x50000012u, name: "PlainRock", itemType: (uint)ItemType.Misc, weenieFlags: 0u);
var parsed = CreateObject.TryParse(body);
Assert.NotNull(parsed);
Assert.Equal(0u, parsed!.Value.UiEffects);
}
```
- [ ] **Step 2: Run tests to verify they fail**
Run: `dotnet test tests/AcDream.Core.Net.Tests/AcDream.Core.Net.Tests.csproj --filter "FullyQualifiedName~CreateObjectTests"`
Expected: FAIL — `Parsed` has no `UiEffects` member.
- [ ] **Step 3: Add the `UiEffects` record field**
In `CreateObject.cs`, in the `Parsed` record, after `uint IconUnderlayId = 0`:
```csharp
uint IconUnderlayId = 0,
// D.5.2 (2026-06-17): UiEffects bitfield (weenieFlags 0x80) — drives the icon's
// effect recolor (Magical=0x1 … Nether=0x1000). The ONLY wire path for the effect
// state (PropertyInt.UiEffects=18 has no [AssessmentProperty] → not in appraise).
// Previously read + discarded at the UiEffects skip. 0 = no effect.
uint UiEffects = 0);
```
- [ ] **Step 4: Capture at the UiEffects site**
In `CreateObject.cs`, declare the local next to `iconOverlayId`/`iconUnderlayId`:
```csharp
uint iconOverlayId = 0;
uint iconUnderlayId = 0;
uint uiEffects = 0;
uint weenieFlags2 = 0;
```
Change the UiEffects skip to a capture:
```csharp
if ((weenieFlags & 0x00000080u) != 0) // UiEffects u32 ← CAPTURE
{
if (body.Length - pos < 4) throw new FormatException("trunc UiEffects");
uiEffects = ReadU32(body, ref pos);
}
```
- [ ] **Step 5: Pass it to the `Parsed` constructor**
In the success-path `return new Parsed(...)`, change the tail:
```csharp
IconId: iconId,
Useability: useability, UseRadius: useRadius,
IconOverlayId: iconOverlayId, IconUnderlayId: iconUnderlayId,
UiEffects: uiEffects);
```
- [ ] **Step 6: Run tests to verify they pass**
Run: `dotnet test tests/AcDream.Core.Net.Tests/AcDream.Core.Net.Tests.csproj --filter "FullyQualifiedName~CreateObjectTests"`
Expected: PASS (all existing CreateObject tests still pass — the builder change is additive).
- [ ] **Step 7: Commit**
```bash
git add src/AcDream.Core.Net/Messages/CreateObject.cs tests/AcDream.Core.Net.Tests/Messages/CreateObjectTests.cs
git commit -m "feat(D.5.2): capture UiEffects from CreateObject weenie header"
```
---
### Task 3: `PublicUpdatePropertyInt (0x02CE)` parser
**Files:**
- Create: `src/AcDream.Core.Net/Messages/PublicUpdatePropertyInt.cs`
- Test: `tests/AcDream.Core.Net.Tests/Messages/PublicUpdatePropertyIntTests.cs`
- [ ] **Step 1: Write the failing tests**
Create `tests/AcDream.Core.Net.Tests/Messages/PublicUpdatePropertyIntTests.cs`:
```csharp
using System.Buffers.Binary;
using AcDream.Core.Net.Messages;
namespace AcDream.Core.Net.Tests.Messages;
public sealed class PublicUpdatePropertyIntTests
{
private static byte[] Build(uint guid, uint property, int value, byte seq = 1, uint opcode = 0x02CEu)
{
var b = new byte[17];
BinaryPrimitives.WriteUInt32LittleEndian(b.AsSpan(0), opcode);
b[4] = seq;
BinaryPrimitives.WriteUInt32LittleEndian(b.AsSpan(5), guid);
BinaryPrimitives.WriteUInt32LittleEndian(b.AsSpan(9), property);
BinaryPrimitives.WriteInt32LittleEndian(b.AsSpan(13), value);
return b;
}
[Fact]
public void TryParse_uiEffectsUpdate_returnsGuidPropValue()
{
var p = PublicUpdatePropertyInt.TryParse(Build(0x50000001u, property: 18u, value: 0x9));
Assert.NotNull(p);
Assert.Equal(0x50000001u, p!.Value.Guid);
Assert.Equal(18u, p.Value.Property);
Assert.Equal(0x9, p.Value.Value);
}
[Fact]
public void TryParse_wrongOpcode_returnsNull()
=> Assert.Null(PublicUpdatePropertyInt.TryParse(Build(1, 18, 1, opcode: 0x02CDu)));
[Fact]
public void TryParse_truncated_returnsNull()
=> Assert.Null(PublicUpdatePropertyInt.TryParse(new byte[16]));
}
```
- [ ] **Step 2: Run tests to verify they fail**
Run: `dotnet test tests/AcDream.Core.Net.Tests/AcDream.Core.Net.Tests.csproj --filter "FullyQualifiedName~PublicUpdatePropertyIntTests"`
Expected: FAIL — `PublicUpdatePropertyInt` does not exist.
- [ ] **Step 3: Create the parser**
Create `src/AcDream.Core.Net/Messages/PublicUpdatePropertyInt.cs`:
```csharp
using System;
using System.Buffers.Binary;
namespace AcDream.Core.Net.Messages;
/// <summary>
/// Inbound <c>PublicUpdatePropertyInt (0x02CE)</c> — the server updates one
/// <c>PropertyInt</c> on a visible object (carries the object guid). Standalone
/// GameMessage, dispatched like <see cref="PrivateUpdateVital"/> / CreateObject.
///
/// <para>
/// The companion <c>PrivateUpdatePropertyInt (0x02CD)</c> targets the player's OWN
/// object (no guid) and is not parsed here — it has no item-icon impact.
/// </para>
///
/// <para>Wire layout (ACE <c>GameMessagePublicUpdatePropertyInt</c>, size hint 17):</para>
/// <code>
/// u32 opcode = 0x02CE
/// u8 sequence // single byte (ByteSequence.NextBytes) — see PrivateUpdateVital
/// u32 guid
/// u32 property // PropertyInt enum; UiEffects = 18
/// i32 value
/// </code>
/// The sequence is parsed-past but not honored (latest-wins; divergence DR-4).
/// </summary>
public static class PublicUpdatePropertyInt
{
public const uint Opcode = 0x02CEu;
public readonly record struct Parsed(uint Guid, uint Property, int Value);
/// <summary>Parse a raw 0x02CE body. Returns null on opcode mismatch / truncation.</summary>
public static Parsed? TryParse(ReadOnlySpan<byte> body)
{
if (body.Length < 17) return null; // 4 + 1 + 4 + 4 + 4
if (BinaryPrimitives.ReadUInt32LittleEndian(body) != Opcode) return null;
int pos = 4;
pos += 1; // sequence byte (not honored)
uint guid = BinaryPrimitives.ReadUInt32LittleEndian(body[pos..]); pos += 4;
uint prop = BinaryPrimitives.ReadUInt32LittleEndian(body[pos..]); pos += 4;
int value = BinaryPrimitives.ReadInt32LittleEndian(body[pos..]);
return new Parsed(guid, prop, value);
}
}
```
- [ ] **Step 4: Run tests to verify they pass**
Run: `dotnet test tests/AcDream.Core.Net.Tests/AcDream.Core.Net.Tests.csproj --filter "FullyQualifiedName~PublicUpdatePropertyIntTests"`
Expected: PASS.
- [ ] **Step 5: Commit**
```bash
git add src/AcDream.Core.Net/Messages/PublicUpdatePropertyInt.cs tests/AcDream.Core.Net.Tests/Messages/PublicUpdatePropertyIntTests.cs
git commit -m "feat(D.5.2): PublicUpdatePropertyInt (0x02CE) parser"
```
---
### Task 4: Thread `UiEffects` through `WorldSession` + route `0x02CE`
**Files:**
- Modify: `src/AcDream.Core.Net/WorldSession.cs` (EntitySpawn field + ctor thread; new event; message-loop branch)
> No unit test: the private message loop needs a live session. The parser is covered by
> Task 3; the event consumption by Tasks 1+8; the end-to-end path by visual verification.
> This matches the existing `PrivateUpdateVital` routing (parser tested, loop not).
- [ ] **Step 1: Add `UiEffects` to the `EntitySpawn` record**
In `WorldSession.cs`, in the `EntitySpawn` record, after `uint IconUnderlayId = 0`:
```csharp
uint IconOverlayId = 0,
uint IconUnderlayId = 0,
// D.5.2 (2026-06-17): UiEffects bitfield (weenieFlags 0x80) — drives the icon's
// effect recolor. CreateObject-only; 0 = no effect.
uint UiEffects = 0);
```
- [ ] **Step 2: Thread it at the `EntitySpawn` construction site**
Find the `new EntitySpawn(... parsed.Value.IconUnderlayId)` construction (the spawn fired from
the CreateObject branch). Change its tail:
```csharp
parsed.Value.IconId,
parsed.Value.IconOverlayId,
parsed.Value.IconUnderlayId,
parsed.Value.UiEffects));
```
- [ ] **Step 3: Declare the live-update event + payload**
In `WorldSession.cs`, near the other event declarations (e.g. after the `StateUpdated`
event ~line 162), add:
```csharp
/// <summary>
/// Payload for <see cref="ObjectIntPropertyUpdated"/>: a single PropertyInt change on
/// a visible object (from PublicUpdatePropertyInt 0x02CE). Subscribers map the
/// property to typed state (e.g. UiEffects → the item's icon effect).
/// </summary>
public readonly record struct ObjectIntPropertyUpdate(uint Guid, uint Property, int Value);
/// <summary>
/// Fires when the session parses a PublicUpdatePropertyInt (0x02CE) — one
/// PropertyInt updated on a visible object. D.5.2 routes UiEffects (18) to the
/// item repository so the icon re-composites live.
/// </summary>
public event Action<ObjectIntPropertyUpdate>? ObjectIntPropertyUpdated;
```
- [ ] **Step 4: Add the message-loop branch**
In the top-level message dispatch (where `op` is the opcode and `body` the message bytes),
add after the `PrivateUpdateVital.CurrentOpcode` branch (~line 905):
```csharp
else if (op == PublicUpdatePropertyInt.Opcode)
{
var p = PublicUpdatePropertyInt.TryParse(body);
if (p is not null)
ObjectIntPropertyUpdated?.Invoke(
new ObjectIntPropertyUpdate(p.Value.Guid, p.Value.Property, p.Value.Value));
}
```
- [ ] **Step 5: Build to verify it compiles**
Run: `dotnet build src/AcDream.Core.Net/AcDream.Core.Net.csproj`
Expected: Build succeeded.
- [ ] **Step 6: Run the Net test suite (regression)**
Run: `dotnet test tests/AcDream.Core.Net.Tests/AcDream.Core.Net.Tests.csproj`
Expected: PASS.
- [ ] **Step 7: Commit**
```bash
git add src/AcDream.Core.Net/WorldSession.cs
git commit -m "feat(D.5.2): thread UiEffects through EntitySpawn + route 0x02CE PublicUpdatePropertyInt"
```
---
### Task 5: `IconComposer.ResolveEffectDid` (effect submap resolve)
**Files:**
- Modify: `src/AcDream.App/UI/IconComposer.cs` (effect-submap fields + `ResolveEffectDid` + `EnsureEffectSubMap`)
- Test: `tests/AcDream.App.Tests/UI/IconComposerTests.cs`
- [ ] **Step 1: Write the failing golden test**
In `IconComposerTests.cs`, add (dat-gated, mirroring `ResolveUnderlayDid_goldenValues_matchDat`):
```csharp
[Fact]
public void ResolveEffectDid_goldenValues_matchDat()
{
var datDir = ResolveDatDir();
if (datDir is null) return; // dats absent (CI) — skip cleanly
using var dats = new DatCollection(datDir, DatAccessType.Read);
var composer = new IconComposer(dats, null!);
// Golden values (live dat, MasterMap 0x25000000 → effect submap 0x25000009;
// index = LowestSetBit(UiEffects)+1, fallback 0x21):
// Magical (0x0001) → idx 1 → 0x060011CA
// Poisoned (0x0002) → idx 2 → 0x060011C6
// BoostHealth (0x0004) → idx 3 → 0x06001B05
// BoostStamina (0x0010) → idx 5 → 0x06001B06
// Nether (0x1000) → idx 13 (absent) → fallback 0x21 → 0x060011C5
// none (0x0000) → idx 0 (zero) → fallback 0x21 → 0x060011C5
Assert.Equal(0x060011CAu, composer.ResolveEffectDid(0x0001u));
Assert.Equal(0x060011C6u, composer.ResolveEffectDid(0x0002u));
Assert.Equal(0x06001B05u, composer.ResolveEffectDid(0x0004u));
Assert.Equal(0x06001B06u, composer.ResolveEffectDid(0x0010u));
Assert.Equal(0x060011C5u, composer.ResolveEffectDid(0x1000u));
Assert.Equal(0x060011C5u, composer.ResolveEffectDid(0x0000u));
}
```
- [ ] **Step 2: Run test to verify it fails**
Run: `dotnet test tests/AcDream.App.Tests/AcDream.App.Tests.csproj --filter "FullyQualifiedName~ResolveEffectDid"`
Expected: FAIL — `ResolveEffectDid` does not exist.
- [ ] **Step 3: Add the effect-submap fields**
In `IconComposer.cs`, after the underlay fields (`_underlayDidByIndex`):
```csharp
// ── effect overlay resolve (EnumIDMap 0x10000005) ────────────────────────
// Portal MasterMap (0x25000000) maps enum 0x10000005 → submap DID (0x25000009).
// Submap maps index → 0x06 RenderSurface DID. index = LSB(effects)+1, fallback 0x21.
// Refs: IconData::RenderIcons 0x0058d180 (effect path); the effect tile is a
// ReplaceColor tint SOURCE, not a blit layer (see RESOLVED doc, divergence DR-1).
private EnumIDMap? _effectSubMap;
private bool _effectResolveTried;
private readonly Dictionary<uint, uint> _effectDidByIndex = new();
```
- [ ] **Step 4: Add `ResolveEffectDid` + `EnsureEffectSubMap`**
In `IconComposer.cs`, after `EnsureUnderlaySubMap`:
```csharp
/// <summary>
/// Resolve the effect-overlay DID for <paramref name="effects"/> via the EnumIDMap
/// 0x10000005 chain. index = LowestSetBit(effects)+1; if the entry is missing/zero,
/// retail falls back to index 0x21 (the solid-black tile). NOTE: the effect path has
/// NO lsb==-1 pre-check (unlike the type underlay), so effects==0 → index 0 → miss →
/// fallback. (Retail IconData::RenderIcons 0x0058d180.)
/// </summary>
internal uint ResolveEffectDid(uint effects)
{
int lsb = effects == 0 ? -1 : BitOperations.TrailingZeroCount(effects);
uint index = (uint)(lsb + 1);
if (_effectDidByIndex.TryGetValue(index, out var cached)) return cached;
EnsureEffectSubMap();
uint did = 0;
if (_effectSubMap is { } sub && sub.ClientEnumToID.TryGetValue(index, out var d)) did = d;
if (did == 0 && _effectSubMap is { } sub2 && sub2.ClientEnumToID.TryGetValue(0x21u, out var fb))
did = fb;
_effectDidByIndex[index] = did;
return did;
}
private void EnsureEffectSubMap()
{
if (_effectResolveTried) return;
_effectResolveTried = true;
uint masterDid = (uint)_dats.Portal.Header.MasterMapId; // = 0x25000000
if (masterDid == 0) return;
if (!_dats.Portal.TryGet<EnumIDMap>(masterDid, out var master)) return;
if (!master.ClientEnumToID.TryGetValue(0x10000005u, out var subDid)) return; // → 0x25000009
if (_dats.Portal.TryGet<EnumIDMap>(subDid, out var sub)) _effectSubMap = sub;
}
```
- [ ] **Step 5: Run test to verify it passes**
Run: `dotnet test tests/AcDream.App.Tests/AcDream.App.Tests.csproj --filter "FullyQualifiedName~ResolveEffectDid"`
Expected: PASS. (If it skips, the dats aren't at `%USERPROFILE%\Documents\Asheron's Call` — set `ACDREAM_DAT_DIR` and re-run.)
- [ ] **Step 6: Commit**
```bash
git add src/AcDream.App/UI/IconComposer.cs tests/AcDream.App.Tests/UI/IconComposerTests.cs
git commit -m "feat(D.5.2): IconComposer.ResolveEffectDid (effect submap 0x10000005)"
```
---
### Task 6: `IconComposer` recolor helpers (`ReplaceColorWhite` + effect color)
**Files:**
- Modify: `src/AcDream.App/UI/IconComposer.cs` (`ReplaceColorWhite`, `TryGetEffectColor`, `TryDecode`)
- Test: `tests/AcDream.App.Tests/UI/IconComposerTests.cs`
- [ ] **Step 1: Write the failing dat-free recolor test**
In `IconComposerTests.cs`, add:
```csharp
[Fact]
public void ReplaceColorWhite_replacesOnlyPureWhiteOpaque()
{
// 2x2: [white-opaque, red-opaque, white-transparent, white-opaque]
var px = new byte[]
{
255,255,255,255, // pure white opaque → replaced
255, 0, 0,255, // red → untouched
255,255,255, 0, // white but alpha 0 → untouched (not 0xFFFFFFFF)
255,255,255,255, // pure white opaque → replaced
};
IconComposer.ReplaceColorWhite(px, 2, 2, (10, 20, 30, 255));
Assert.Equal(new byte[] { 10, 20, 30, 255 }, px[0..4]); // replaced
Assert.Equal(new byte[] { 255, 0, 0, 255 }, px[4..8]); // untouched
Assert.Equal(new byte[] { 255, 255, 255, 0 }, px[8..12]); // untouched
Assert.Equal(new byte[] { 10, 20, 30, 255 }, px[12..16]); // replaced
}
```
- [ ] **Step 2: Run test to verify it fails**
Run: `dotnet test tests/AcDream.App.Tests/AcDream.App.Tests.csproj --filter "FullyQualifiedName~ReplaceColorWhite"`
Expected: FAIL — `ReplaceColorWhite` does not exist.
- [ ] **Step 3: Add `ReplaceColorWhite`**
In `IconComposer.cs`, add (near `Compose`):
```csharp
/// <summary>
/// Retail <c>SurfaceWindow::ReplaceColor</c> (0x00441530) with the icon-composite's
/// fixed source color: replace pixels exactly equal to pure-white-opaque
/// (RGBAColor(1,1,1,1) → 0xFFFFFFFF) with <paramref name="dest"/>. Mutates in place.
/// </summary>
internal static void ReplaceColorWhite(byte[] rgba, int w, int h, (byte r, byte g, byte b, byte a) dest)
{
for (int i = 0; i < w * h; i++)
{
if (rgba[i * 4] == 255 && rgba[i * 4 + 1] == 255 &&
rgba[i * 4 + 2] == 255 && rgba[i * 4 + 3] == 255)
{
rgba[i * 4] = dest.r; rgba[i * 4 + 1] = dest.g;
rgba[i * 4 + 2] = dest.b; rgba[i * 4 + 3] = dest.a;
}
}
}
```
- [ ] **Step 4: Add `TryGetEffectColor` + `TryDecode`**
In `IconComposer.cs`, add the color cache field next to `_effectDidByIndex`:
```csharp
private readonly Dictionary<uint, (byte r, byte g, byte b, byte a)> _effectColorByDid = new();
```
And the methods (after `ResolveEffectDid`):
```csharp
/// <summary>
/// The effect tint color for <paramref name="effects"/>: the effect tile's mean-opaque
/// color (blue=Magical, green=Poisoned, …). The exact retail color byte is a
/// decompiler-ambiguous SurfaceWindow-header read; the tile IS the per-effect color, so
/// its representative color is the faithful equivalent (divergence DR-2). Cached per DID.
/// </summary>
private bool TryGetEffectColor(uint effects, out (byte r, byte g, byte b, byte a) color)
{
color = default;
uint did = ResolveEffectDid(effects);
if (did == 0) return false;
if (_effectColorByDid.TryGetValue(did, out var cached)) { color = cached; return true; }
if (!TryDecode(did, out var d)) return false;
long sr = 0, sg = 0, sb = 0; int n = 0;
for (int i = 0; i < d.Width * d.Height; i++)
{
if (d.Rgba8[i * 4 + 3] == 0) continue;
sr += d.Rgba8[i * 4]; sg += d.Rgba8[i * 4 + 1]; sb += d.Rgba8[i * 4 + 2]; n++;
}
if (n == 0) return false;
var rep = ((byte)(sr / n), (byte)(sg / n), (byte)(sb / n), (byte)255);
_effectColorByDid[did] = rep;
color = rep;
return true;
}
private bool TryDecode(uint renderSurfaceId, out DecodedTexture decoded)
{
decoded = null!;
if (renderSurfaceId == 0) return false;
if (!_dats.Portal.TryGet<RenderSurface>(renderSurfaceId, out var rs) &&
!_dats.HighRes.TryGet<RenderSurface>(renderSurfaceId, out rs))
return false;
decoded = SurfaceDecoder.DecodeRenderSurface(rs, palette: null);
return true;
}
```
> `DecodedTexture` is in `AcDream.Core.Textures` — already imported by `IconComposer.cs`.
- [ ] **Step 5: Run test to verify it passes**
Run: `dotnet test tests/AcDream.App.Tests/AcDream.App.Tests.csproj --filter "FullyQualifiedName~ReplaceColorWhite"`
Expected: PASS.
- [ ] **Step 6: Commit**
```bash
git add src/AcDream.App/UI/IconComposer.cs tests/AcDream.App.Tests/UI/IconComposerTests.cs
git commit -m "feat(D.5.2): IconComposer effect-color + ReplaceColorWhite helpers"
```
---
### Task 7: `IconComposer.GetIcon` 5-arg 2-stage composite + update callers
**Files:**
- Modify: `src/AcDream.App/UI/IconComposer.cs` (`_byTuple` key + `GetIcon` rewrite + class doc)
- Modify: `src/AcDream.App/UI/Layout/ToolbarController.cs` (`_iconIds` Func type + `Populate`)
- Modify: `src/AcDream.App/Rendering/GameWindow.cs` (`iconIds` closure + `OnLiveEntitySpawned` effects)
- Test: `tests/AcDream.App.Tests/UI/IconComposerTests.cs`
> This task changes `GetIcon`'s signature, which breaks both callers; all three files are
> edited together so the tree compiles.
- [ ] **Step 1: Write the failing dat-free composite test**
In `IconComposerTests.cs`, add (exercises the 2-stage compose + recolor without GL/dat via
the static `Compose`/`ReplaceColorWhite` — the GL upload in `GetIcon` needs a real cache):
```csharp
[Fact]
public void TwoStageWithEffect_recolorsWhiteBeforeUnderlay()
{
// drag = base (white pixel) over overlay (none); recolor white→blue; then over
// an opaque tawny underlay. The white pixel must become blue in the final.
var baseIcon = (new byte[] { 255,255,255,255 }, 1, 1); // 1x1 white opaque
var drag = IconComposer.Compose(new[] { baseIcon });
IconComposer.ReplaceColorWhite(drag.rgba, drag.w, drag.h, (0, 0, 255, 255)); // blue
var underlay = (new byte[] { 105, 70, 50, 255 }, 1, 1); // tawny opaque
var final = IconComposer.Compose(new[] { underlay, (drag.rgba, drag.w, drag.h) });
Assert.Equal(new byte[] { 0, 0, 255, 255 }, final.rgba); // blue on top
}
```
- [ ] **Step 2: Run test to verify it fails**
Run: `dotnet test tests/AcDream.App.Tests/AcDream.App.Tests.csproj --filter "FullyQualifiedName~TwoStageWithEffect"`
Expected: FAIL — won't compile yet only if `Compose`/`ReplaceColorWhite` aren't both public/internal; they are (`Compose` public, `ReplaceColorWhite` internal from Task 6), so this test should actually PASS once Task 6 is in. If it passes immediately, that's fine — it locks the recolor-before-underlay ordering. Proceed to Step 3 regardless (the GetIcon rewrite is the real change).
- [ ] **Step 3: Widen the cache key**
In `IconComposer.cs`, change the dictionary field:
```csharp
private readonly Dictionary<(uint, uint, uint, uint, uint), uint> _byTuple = new();
```
- [ ] **Step 4: Rewrite `GetIcon` to 5-arg 2-stage**
Replace the whole `GetIcon` method with:
```csharp
/// <summary>
/// Resolve (and cache) the composited GL texture for an item's icon state.
/// Returns 0 if no base icon. Mirrors retail IconData::RenderIcons (0x0058d180):
/// a DRAG composite (base + custom overlay + effect recolor) blitted over the
/// type-default underlay + custom underlay. The effect tile (enum 0x10000005) is a
/// ReplaceColor tint SOURCE, not a blit layer (DR-1). The 2-stage form is
/// associative-equivalent to a single Compose when effects==0, so D.5.1 visuals are
/// unchanged for non-effect items.
/// </summary>
public uint GetIcon(ItemType itemType, uint iconId, uint underlayId, uint overlayId, uint effects)
{
if (iconId == 0) return 0;
uint typeUnderlayDid = ResolveUnderlayDid(itemType);
var key = (typeUnderlayDid, iconId, underlayId, overlayId, effects);
if (_byTuple.TryGetValue(key, out var tex)) return tex;
// Stage 1 — retail m_pDragIcon: base + custom overlay, then the effect recolor.
var dragLayers = new List<(byte[] rgba, int w, int h)>();
AddLayer(dragLayers, iconId);
AddLayer(dragLayers, overlayId);
(byte[] rgba, int w, int h)? drag = null;
if (dragLayers.Count > 0)
{
var composed = Compose(dragLayers);
// Effect recolor only when an effect bit is set. Retail nominally also runs the
// effects==0 black-fallback recolor; we skip it (DR-3: white→black on every item
// is a likely no-op but a regression risk, pending visual/cdb confirmation).
if (effects != 0 && TryGetEffectColor(effects, out var ec))
ReplaceColorWhite(composed.rgba, composed.w, composed.h, ec);
drag = composed;
}
// Stage 2 — retail m_pIcon: type-default underlay (opaque) + custom underlay + drag.
var layers = new List<(byte[] rgba, int w, int h)>();
AddLayer(layers, typeUnderlayDid);
AddLayer(layers, underlayId);
if (drag is { } d) layers.Add(d);
if (layers.Count == 0) return 0;
var (rgba, w, h) = Compose(layers);
uint handle = _cache.UploadRgba8(rgba, w, h, nearest: true);
_byTuple[key] = handle;
return handle;
}
```
- [ ] **Step 5: Update `ToolbarController` for the new delegate arity**
In `ToolbarController.cs`:
- Change the field type (~line 54):
```csharp
private readonly Func<ItemType, uint, uint, uint, uint, uint> _iconIds; // (itemType, icon, underlay, overlay, effects) → GL tex
```
- Change the constructor parameter type (the `Func<ItemType, uint, uint, uint, uint> iconIds` param):
```csharp
Func<ItemType, uint, uint, uint, uint, uint> iconIds,
```
- Change the `Bind` parameter type to match (same `Func<ItemType, uint, uint, uint, uint, uint> iconIds`).
- In `Populate`, pass `item.Effects`:
```csharp
uint tex = _iconIds(item.Type, item.IconId, item.IconUnderlayId, item.IconOverlayId, item.Effects);
```
- [ ] **Step 6: Update `GameWindow` — closure + spawn enrich**
In `GameWindow.cs`:
- Widen the `iconIds` closure (~line 2005):
```csharp
iconIds: (type, icon, under, over, effects) => iconComposer.GetIcon(type, icon, under, over, effects),
```
- Pass `spawn.UiEffects` in `OnLiveEntitySpawned`'s `EnrichItem` call (~line 2647):
```csharp
Items.EnrichItem(spawn.Guid, spawn.IconId, spawn.Name ?? string.Empty,
(AcDream.Core.Items.ItemType)(spawn.ItemType ?? 0),
spawn.IconOverlayId, spawn.IconUnderlayId, spawn.UiEffects);
```
- [ ] **Step 7: Build + run the App test suite**
Run: `dotnet build src/AcDream.App/AcDream.App.csproj`
Expected: Build succeeded.
Run: `dotnet test tests/AcDream.App.Tests/AcDream.App.Tests.csproj --filter "FullyQualifiedName~IconComposer"`
Expected: PASS.
- [ ] **Step 8: Commit**
```bash
git add src/AcDream.App/UI/IconComposer.cs src/AcDream.App/UI/Layout/ToolbarController.cs src/AcDream.App/Rendering/GameWindow.cs tests/AcDream.App.Tests/UI/IconComposerTests.cs
git commit -m "feat(D.5.2): IconComposer 2-stage effect composite + 5-arg GetIcon"
```
---
### Task 8: Wire the live `0x02CE` update into the item repository
**Files:**
- Modify: `src/AcDream.App/Rendering/GameWindow.cs` (subscribe `ObjectIntPropertyUpdated`, next to `VitalUpdated`)
> No unit test: this is a one-line session-event binding (the same shape as the existing
> `VitalUpdated` binding). `UpdateIntProperty` is unit-tested in Task 1; the end-to-end path
> is the visual-verification acceptance test.
- [ ] **Step 1: Subscribe the event**
In `GameWindow.cs`, next to the `VitalUpdated`/`VitalCurrentUpdated` subscriptions (~line 2630),
add:
```csharp
// D.5.2: live PublicUpdatePropertyInt(0x02CE). Route UiEffects (18) to the item
// repository so a draining/charging item re-composites its icon in real time.
_liveSession.ObjectIntPropertyUpdated += u =>
{
if (u.Property == AcDream.Core.Items.ItemRepository.UiEffectsPropertyId)
Items.UpdateIntProperty(u.Guid, u.Property, u.Value);
};
```
- [ ] **Step 2: Build to verify it compiles**
Run: `dotnet build src/AcDream.App/AcDream.App.csproj`
Expected: Build succeeded.
- [ ] **Step 3: Commit**
```bash
git add src/AcDream.App/Rendering/GameWindow.cs
git commit -m "feat(D.5.2): route live UiEffects updates (0x02CE) to the item icon"
```
---
### Task 9: Bookkeeping — divergence register, roadmap, memory
**Files:**
- Modify: `docs/architecture/retail-divergence-register.md` (retire `IA-16`; add `DR-1..DR-4`)
- Modify: `docs/plans/2026-04-11-roadmap.md` (mark D.5.2 shipped)
- Modify: `claude-memory/project_d2b_retail_ui.md` (D.5.2 entry)
- [ ] **Step 1: Update the divergence register**
In `docs/architecture/retail-divergence-register.md`:
- **Delete the `IA-16` row** (item-icon composite PARTIAL — now complete).
- **Add four rows** (use the table's existing column shape; anchor file:line):
- `DR-1` — effect overlay (enum 0x10000005) is a `ReplaceColor` tint SOURCE, not a blit
layer; this IS faithful retail behavior — do not "fix" it back to a blit. Anchor:
`IconData::RenderIcons` 0x0058d180, `ReplaceColor` 0x00441530; code
`src/AcDream.App/UI/IconComposer.cs` (`GetIcon`).
- `DR-2` — effect tint color = the effect tile's mean-opaque color; the exact retail color
byte (`effectTile + 0xac` reinterpreted as RGBAColor) is decompiler-ambiguous.
Approximation; visual/cdb confirmation pending. Code `IconComposer.TryGetEffectColor`.
- `DR-3` — the `effects==0` black-fallback recolor that retail nominally runs is skipped
(white→black on every item — likely no-op, real regression risk). Code
`IconComposer.GetIcon` (`effects != 0` gate).
- `DR-4``PublicUpdatePropertyInt(0x02CE)` sequence not honored (latest-wins). Code
`src/AcDream.Core.Net/Messages/PublicUpdatePropertyInt.cs`.
- [ ] **Step 2: Update the roadmap shipped table**
In `docs/plans/2026-04-11-roadmap.md`, move D.5.2 (stateful item-icon system) into the shipped
section with the commit range and a one-line summary (appraise dropped as no-op; effect recolor
+ live 0x02CE wire-up).
- [ ] **Step 3: Update the D.2b memory digest**
In `claude-memory/project_d2b_retail_ui.md`, append a D.5.2 entry: UiEffects captured from
CreateObject (was discarded) → ItemInstance.Effects → IconComposer 2-stage recolor (effect
tile = ReplaceColor SOURCE, golden submap 0x10000005); live via PublicUpdatePropertyInt 0x02CE;
appraise carries NO icon data (dropped). Link `[[stateful-icon-system-handoff]]` superseded by
the RESOLVED doc.
- [ ] **Step 4: Full build + test sweep**
Run: `dotnet build`
Expected: Build succeeded (no warnings introduced).
Run: `dotnet test`
Expected: All green.
- [ ] **Step 5: Commit**
```bash
git add docs/architecture/retail-divergence-register.md docs/plans/2026-04-11-roadmap.md claude-memory/project_d2b_retail_ui.md
git commit -m "docs(D.5.2): retire IA-16, add DR-1..4, roadmap + memory"
```
---
## Visual verification (acceptance — after all tasks)
Launch against live ACE (per CLAUDE.md "Running the client" recipe), then confirm with the user:
1. A **magical item** pinned to the toolbar shows the effect tint (white highlights take the
effect hue).
2. An item whose **mana drains** updates its icon live (the server's `0x02CE` UiEffects change
re-composites without a relog).
If the tint is wrong/too subtle vs retail, the open lever is `DR-2` (effect color source) — a
cdb trace of `RenderIcons`/`ReplaceColor` on a live retail client resolves the exact byte.
---
## Self-review
- **Spec coverage:** §5.1→T1, §5.2→T2, §5.4→T3, §5.3→T4, §5.5→T1, §5.6→T5+T6+T7,
§5.7→T7, §5.8→T8, §6→T9, §7 tests→T1/T2/T3/T5/T6/T7 + visual. All covered.
- **Placeholders:** none — every code step shows full code; every command shows expected output.
- **Type consistency:** `Func<ItemType,uint,uint,uint,uint,uint>` used identically in
`IconComposer.GetIcon`, `ToolbarController` field/ctor/Bind, and the `GameWindow` closure;
`UiEffectsPropertyId` (18) defined in T1 and referenced in T8; `ObjectIntPropertyUpdate`
record defined in T4 and consumed in T8; `ReplaceColorWhite`/`ResolveEffectDid`/`Compose`
signatures match between definition (T5/T6/T7) and tests.