acdream/docs/superpowers/plans/2026-06-17-d2b-stateful-icon.md
Erik 52306d9268 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>
2026-06-17 18:19:26 +02:00

39 KiB

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. Research: docs/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:

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

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

    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:

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

        if ((weenieFlags & 0x00000080u) != 0) WriteU32(bytes, uiEffects);  // UiEffects u32

Then add the tests:

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

        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:

            uint iconOverlayId = 0;
            uint iconUnderlayId = 0;
            uint uiEffects = 0;
            uint weenieFlags2 = 0;

Change the UiEffects skip to a capture:

                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:

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

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:

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

        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:

                        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:

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

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

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

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

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

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

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

    private readonly Dictionary<uint, (byte r, byte g, byte b, byte a)> _effectColorByDid = new();

And the methods (after ResolveEffectDid):

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

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

    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:

    /// <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):
    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):
        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:
            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):
                    iconIds: (type, icon, under, over, effects) => iconComposer.GetIcon(type, icon, under, over, effects),
  • Pass spawn.UiEffects in OnLiveEntitySpawned's EnrichItem call (~line 2647):
        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
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:

            // 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
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-4PublicUpdatePropertyInt(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
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.