acdream/tests/AcDream.App.Tests/UI/IconComposerTests.cs
Erik fb288ad852 fix(D.5.2): effect tint = per-pixel tile copy (surface ReplaceColor overload)
Visual verification (Coldeve, Energy Crystal) showed acdream's Magical blue as a
flat tint vs retail's gradient. Root cause: RenderIcons calls the SURFACE overload
of SurfaceWindow::ReplaceColor (0x004415b0), which copies the textured effect tile
pixel-by-pixel into the icon's pure-white pixels — not the flat color->color overload
(0x00441530) I'd approximated with the tile's mean color. Port the surface overload
exactly (dst[x,y]=src[x,y] where dst==white); confirmed via clean Ghidra decompile +
named decomp. Retires AP-43 (mean-color approximation); IA-18 updated to the surface op.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 10:21:33 +02:00

197 lines
9.5 KiB
C#

using System;
using System.IO;
using AcDream.App.UI;
using AcDream.Core.Items;
using DatReaderWriter;
using DatReaderWriter.Options;
namespace AcDream.App.Tests.UI;
public class IconComposerTests
{
private static byte[] Solid(int w, int h, byte r, byte g, byte b, byte a)
{
var px = new byte[w * h * 4];
for (int i = 0; i < w * h; i++) { px[i*4]=r; px[i*4+1]=g; px[i*4+2]=b; px[i*4+3]=a; }
return px;
}
[Fact]
public void Compose_alphaOver_topOpaqueLayerWins()
{
var bottom = (Solid(2, 2, 255, 0, 0, 255), 2, 2); // red, opaque
var top = (Solid(2, 2, 0, 0, 255, 255), 2, 2); // blue, opaque
var (rgba, w, h) = IconComposer.Compose(new[] { bottom, top });
Assert.Equal(2, w); Assert.Equal(2, h);
Assert.Equal(0, rgba[0]); // R
Assert.Equal(0, rgba[1]); // G
Assert.Equal(255, rgba[2]); // B — top layer won
Assert.Equal(255, rgba[3]); // A
}
[Fact]
public void Compose_alphaOver_transparentTopKeepsBottom()
{
var bottom = (Solid(1, 1, 255, 0, 0, 255), 1, 1);
var top = (Solid(1, 1, 0, 0, 255, 0), 1, 1); // fully transparent blue
var (rgba, _, _) = IconComposer.Compose(new[] { bottom, top });
Assert.Equal(255, rgba[0]); // bottom red preserved
Assert.Equal(0, rgba[2]);
}
/// <summary>
/// Dat-free: when an opaque type-default underlay is prepended as layer 0,
/// Compose yields a fully-opaque result even when the base icon is semi-transparent.
/// This validates the bottom-up ordering that makes filled toolbar slots non-transparent
/// (retail IconData::RenderIcons 407524: underlay is OPAQUE Blit_Normal first).
/// </summary>
[Fact]
public void Compose_opaqueUnderlayFirst_resultIsFullyOpaque()
{
var underlay = (Solid(2, 2, 128, 64, 32, 255), 2, 2); // opaque tawny
var baseIcon = (Solid(2, 2, 0, 0, 0, 128), 2, 2); // semi-transparent black
var (rgba, w, h) = IconComposer.Compose(new[] { underlay, baseIcon });
Assert.Equal(2, w); Assert.Equal(2, h);
// All pixels fully opaque: underlay A=255, baseIcon blends over it.
for (int i = 0; i < w * h; i++)
Assert.Equal(255, rgba[i * 4 + 3]);
}
// ── Dat-gated golden tests ────────────────────────────────────────────────
// These tests open the real Asheron's Call dats (ACDREAM_DAT_DIR or the default
// Documents path) and verify the EnumIDMap 0x10000004 resolve chain against the
// known golden DIDs from the dat (confirmed 2026-06-17 research).
// Golden values: IconData::RenderIcons 0058d214 + DBCache::GetDIDFromEnum 0x413940.
private static string? ResolveDatDir()
{
var fromEnv = Environment.GetEnvironmentVariable("ACDREAM_DAT_DIR");
if (!string.IsNullOrWhiteSpace(fromEnv) && Directory.Exists(fromEnv))
return fromEnv;
var def = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.UserProfile),
"Documents", "Asheron's Call");
return Directory.Exists(def) ? def : null;
}
[Fact]
public void ResolveUnderlayDid_goldenValues_matchDat()
{
var datDir = ResolveDatDir();
if (datDir is null)
return; // dats absent (CI) — skip cleanly
using var dats = new DatCollection(datDir, DatAccessType.Read);
// TextureCache is not needed for the resolve path; pass a null-safe stub
// via IconComposer — the underlay-resolve methods only touch _dats.
// We cannot construct TextureCache without GL, so use a bare IconComposer
// with a null cache guard: ResolveUnderlayDid is internal and pure-dat.
var composer = new IconComposer(dats, null!);
// Golden values confirmed against C:/Users/erikn/Documents/Asheron's Call
// (IconData::RenderIcons decomp 407524; DBCache::GetDIDFromEnum 0x413940):
// MeleeWeapon (0x1) → index 1 → 0x060011CB
// Armor (0x2) → index 2 → 0x060011CF
// Clothing (0x4) → index 3 → 0x060011F3
// Jewelry (0x8) → index 4 → 0x060011D5
// None (0x0) → index 0x21 (fallback) → 0x060011D4
Assert.Equal(0x060011CBu, composer.ResolveUnderlayDid(ItemType.MeleeWeapon));
Assert.Equal(0x060011CFu, composer.ResolveUnderlayDid(ItemType.Armor));
Assert.Equal(0x060011F3u, composer.ResolveUnderlayDid(ItemType.Clothing));
Assert.Equal(0x060011D5u, composer.ResolveUnderlayDid(ItemType.Jewelry));
Assert.Equal(0x060011D4u, composer.ResolveUnderlayDid(ItemType.None));
}
[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));
}
[Fact]
public void TryGetEffectTile_noEffectBlack_magicalTextured()
{
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!);
// effects==0 → 0x21 fallback → 0x060011C5, a 32x32 SOLID-BLACK tile. Copying it
// per-pixel blackens an icon's pure-white pixels (retail-faithful no-mana scroll edge).
Assert.True(composer.TryGetEffectTile(0u, out var black));
Assert.Equal(32, black.Width);
Assert.Equal(32, black.Height);
Assert.True(black.Rgba8[0] <= 8 && black.Rgba8[1] <= 8 && black.Rgba8[2] <= 8);
// Magical (0x1) → 0x060011CA, a TEXTURED blue tile (NOT a flat color) — this is the
// gradient retail copies per-pixel into the icon's white pixels (the Energy Crystal
// blue). Assert the tile is non-uniform so a future flat-color regression fails here.
Assert.True(composer.TryGetEffectTile(0x1u, out var magic));
bool uniform = true;
for (int i = 4; i < magic.Width * magic.Height * 4 && uniform; i += 4)
if (magic.Rgba8[i] != magic.Rgba8[0] || magic.Rgba8[i + 1] != magic.Rgba8[1] ||
magic.Rgba8[i + 2] != magic.Rgba8[2])
uniform = false;
Assert.False(uniform); // textured → gradient, not flat
}
[Fact]
public void ReplaceWhiteFromSurface_copiesSourcePixelForPureWhiteOpaque()
{
// 2x2 dest: [white-opaque, red-opaque, white-transparent, white-opaque]
var dst = new byte[]
{
255,255,255,255, // pure white opaque → takes src(0,0)
255, 0, 0,255, // red → untouched
255,255,255, 0, // white but alpha 0 → untouched (not 0xFFFFFFFF)
255,255,255,255, // pure white opaque → takes src(1,1)
};
// 2x2 src — distinct per-pixel colors (a "gradient").
var src = new byte[]
{
10, 20, 30,255, // (0,0)
40, 50, 60,255, // (1,0)
70, 80, 90,255, // (0,1)
100,110,120,255, // (1,1)
};
IconComposer.ReplaceWhiteFromSurface(dst, 2, 2, src, 2, 2);
Assert.Equal(new byte[] { 10, 20, 30, 255 }, dst[0..4]); // copied src(0,0)
Assert.Equal(new byte[] { 255, 0, 0, 255 }, dst[4..8]); // untouched (not white)
Assert.Equal(new byte[] { 255, 255, 255, 0 }, dst[8..12]); // untouched (transparent)
Assert.Equal(new byte[] { 100, 110, 120, 255 }, dst[12..16]); // copied src(1,1) — per-pixel
}
[Fact]
public void TwoStageWithEffect_copiesTilePixelBeforeUnderlay()
{
// drag = base (white pixel); copy the effect tile's pixel into the white; then over
// an opaque tawny underlay. The white pixel must become the tile's pixel in the final.
var baseIcon = (new byte[] { 255,255,255,255 }, 1, 1); // 1x1 white opaque
var drag = IconComposer.Compose(new[] { baseIcon });
var tile = new byte[] { 0, 0, 255, 255 }; // 1x1 blue tile pixel
IconComposer.ReplaceWhiteFromSurface(drag.rgba, drag.w, drag.h, tile, 1, 1);
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); // tile pixel on top
}
}