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