diff --git a/docs/superpowers/plans/2026-06-17-d2b-stateful-icon.md b/docs/superpowers/plans/2026-06-17-d2b-stateful-icon.md new file mode 100644 index 00000000..63b76929 --- /dev/null +++ b/docs/superpowers/plans/2026-06-17-d2b-stateful-icon.md @@ -0,0 +1,973 @@ +# Stateful item-icon system (D.5.2) — Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Make the item icon a live function of the item's state — capture the discarded `UiEffects` bitfield, build retail's faithful effect-recolor in the icon compositor, and wire the live `PublicUpdatePropertyInt(0x02CE)` update so the icon re-composites in real time. + +**Architecture:** `UiEffects` flows `CreateObject → EntitySpawn → ItemInstance.Effects` and, live, `PublicUpdatePropertyInt(0x02CE) → ItemRepository.UpdateIntProperty → ItemInstance.Effects`. Any change fires `ItemPropertiesUpdated`, which the bound `UiItemSlot` already re-resolves via `IconComposer.GetIcon(…, effects)`. The compositor mirrors retail `IconData::RenderIcons`: a 2-stage composite where the effect tile (`enum 0x10000005`) supplies a `ReplaceColor(white → effectColor)` tint, never a blit layer. + +**Tech Stack:** C# .NET 10, xUnit, `DatReaderWriter` (EnumIDMap/RenderSurface), Silk.NET (GL via `TextureCache`). + +**Spec:** [`docs/superpowers/specs/2026-06-17-d2b-stateful-icon-design.md`](../specs/2026-06-17-d2b-stateful-icon-design.md). **Research:** [`docs/research/2026-06-17-stateful-icon-RESOLVED.md`](../../research/2026-06-17-stateful-icon-RESOLVED.md). + +**Conventions:** Every commit appends the CLAUDE.md co-author trailer: +`Co-Authored-By: Claude Opus 4.8 (1M context) `. 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. diff --git a/docs/superpowers/specs/2026-06-17-d2b-stateful-icon-design.md b/docs/superpowers/specs/2026-06-17-d2b-stateful-icon-design.md index 5a2806b5..af66b641 100644 --- a/docs/superpowers/specs/2026-06-17-d2b-stateful-icon-design.md +++ b/docs/superpowers/specs/2026-06-17-d2b-stateful-icon-design.md @@ -87,6 +87,9 @@ PublicUpdatePropertyInt(0x02CE) ──────────┤ The re-composition contract (`ItemPropertiesUpdated` → widget re-resolve via the toolbar's `Populate`) already exists; D.5.2 feeds it the effect state from two sources. +The live `0x02CE` event is bound in `GameWindow`'s session-event binding (next to the +existing `VitalUpdated` subscription) — NOT `GameEventWiring`, which only handles the +`0xF7B0` GameEvent sub-opcode dispatcher. ## 5. Components @@ -166,9 +169,11 @@ Each component below states **what it does / how it's used / what it depends on. `OnLiveEntitySpawned` pass `spawn.UiEffects`. - **Depends on:** §5.1, §5.6. -### 5.8 `GameEventWiring` (`AcDream.Core.Net/GameEventWiring.cs`) -- **What:** subscribe to `WorldSession.ObjectIntPropertyUpdated`; route - `property == 18 (UiEffects)` to `items.UpdateIntProperty(guid, 18, value)`. +### 5.8 `GameWindow` session-event binding (`AcDream.App/Rendering/GameWindow.cs`) +- **What:** subscribe to `WorldSession.ObjectIntPropertyUpdated` (alongside the existing + `VitalUpdated` subscription, ~line 2630); route `Property == 18 (UiEffects)` to + `Items.UpdateIntProperty(guid, 18, value)`. (Top-level session events bind here, not in + `GameEventWiring` — that class only handles the `0xF7B0` GameEvent dispatcher.) - **Depends on:** §5.3, §5.5. ## 6. Divergence-register changes