# 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) `. 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 /// /// 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. /// 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 /// PropertyInt.UiEffects (ACE enum value 18) — the icon effect bitfield. public const uint UiEffectsPropertyId = 18u; /// /// 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) → . Fires /// ItemPropertiesUpdated so bound widgets re-composite. Extensible hook for future /// typed PropertyInts (StackSize, Structure, …). False if the item is unknown. /// 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; /// /// Inbound PublicUpdatePropertyInt (0x02CE) — the server updates one /// PropertyInt on a visible object (carries the object guid). Standalone /// GameMessage, dispatched like / CreateObject. /// /// /// The companion PrivateUpdatePropertyInt (0x02CD) targets the player's OWN /// object (no guid) and is not parsed here — it has no item-icon impact. /// /// /// Wire layout (ACE GameMessagePublicUpdatePropertyInt, size hint 17): /// /// u32 opcode = 0x02CE /// u8 sequence // single byte (ByteSequence.NextBytes) — see PrivateUpdateVital /// u32 guid /// u32 property // PropertyInt enum; UiEffects = 18 /// i32 value /// /// The sequence is parsed-past but not honored (latest-wins; divergence DR-4). /// public static class PublicUpdatePropertyInt { public const uint Opcode = 0x02CEu; public readonly record struct Parsed(uint Guid, uint Property, int Value); /// Parse a raw 0x02CE body. Returns null on opcode mismatch / truncation. public static Parsed? TryParse(ReadOnlySpan 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 /// /// Payload for : 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). /// public readonly record struct ObjectIntPropertyUpdate(uint Guid, uint Property, int Value); /// /// 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. /// public event Action? 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 _effectDidByIndex = new(); ``` - [ ] **Step 4: Add `ResolveEffectDid` + `EnsureEffectSubMap`** In `IconComposer.cs`, after `EnsureUnderlaySubMap`: ```csharp /// /// Resolve the effect-overlay DID for 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.) /// 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(masterDid, out var master)) return; if (!master.ClientEnumToID.TryGetValue(0x10000005u, out var subDid)) return; // → 0x25000009 if (_dats.Portal.TryGet(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 /// /// Retail SurfaceWindow::ReplaceColor (0x00441530) with the icon-composite's /// fixed source color: replace pixels exactly equal to pure-white-opaque /// (RGBAColor(1,1,1,1) → 0xFFFFFFFF) with . Mutates in place. /// 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 _effectColorByDid = new(); ``` And the methods (after `ResolveEffectDid`): ```csharp /// /// The effect tint color for : 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. /// 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(renderSurfaceId, out var rs) && !_dats.HighRes.TryGet(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 /// /// 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. /// 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 _iconIds; // (itemType, icon, underlay, overlay, effects) → GL tex ``` - Change the constructor parameter type (the `Func iconIds` param): ```csharp Func iconIds, ``` - Change the `Bind` parameter type to match (same `Func 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` 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.