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>
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(addEffectsfield, ~line 138) -
Modify:
src/AcDream.Core/Items/ItemRepository.cs(EnrichItem+param; addUpdateIntProperty) -
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
Effectsfield
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
effectsparam toEnrichItem
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
UiEffectsrecord 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
Parsedconstructor
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
PrivateUpdateVitalrouting (parser tested, loop not).
- Step 1: Add
UiEffectsto theEntitySpawnrecord
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
EntitySpawnconstruction 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;
}
DecodedTextureis inAcDream.Core.Textures— already imported byIconComposer.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(_byTuplekey +GetIconrewrite + class doc) - Modify:
src/AcDream.App/UI/Layout/ToolbarController.cs(_iconIdsFunc type +Populate) - Modify:
src/AcDream.App/Rendering/GameWindow.cs(iconIdsclosure +OnLiveEntitySpawnedeffects) - 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
GetIconto 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
ToolbarControllerfor 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> iconIdsparam):
Func<ItemType, uint, uint, uint, uint, uint> iconIds,
- Change the
Bindparameter type to match (sameFunc<ItemType, uint, uint, uint, uint, uint> iconIds). - In
Populate, passitem.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
iconIdsclosure (~line 2005):
iconIds: (type, icon, under, over, effects) => iconComposer.GetIcon(type, icon, under, over, effects),
- Pass
spawn.UiEffectsinOnLiveEntitySpawned'sEnrichItemcall (~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(subscribeObjectIntPropertyUpdated, next toVitalUpdated)
No unit test: this is a one-line session-event binding (the same shape as the existing
VitalUpdatedbinding).UpdateIntPropertyis 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(retireIA-16; addDR-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-16row (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 aReplaceColortint SOURCE, not a blit layer; this IS faithful retail behavior — do not "fix" it back to a blit. Anchor:IconData::RenderIcons0x0058d180,ReplaceColor0x00441530; codesrc/AcDream.App/UI/IconComposer.cs(GetIcon).DR-2— effect tint color = the effect tile's mean-opaque color; the exact retail color byte (effectTile + 0xacreinterpreted as RGBAColor) is decompiler-ambiguous. Approximation; visual/cdb confirmation pending. CodeIconComposer.TryGetEffectColor.DR-3— theeffects==0black-fallback recolor that retail nominally runs is skipped (white→black on every item — likely no-op, real regression risk). CodeIconComposer.GetIcon(effects != 0gate).DR-4—PublicUpdatePropertyInt(0x02CE)sequence not honored (latest-wins). Codesrc/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:
- A magical item pinned to the toolbar shows the effect tint (white highlights take the effect hue).
- An item whose mana drains updates its icon live (the server's
0x02CEUiEffects 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 inIconComposer.GetIcon,ToolbarControllerfield/ctor/Bind, and theGameWindowclosure;UiEffectsPropertyId(18) defined in T1 and referenced in T8;ObjectIntPropertyUpdaterecord defined in T4 and consumed in T8;ReplaceColorWhite/ResolveEffectDid/Composesignatures match between definition (T5/T6/T7) and tests.