Visual verification caught it: a no-mana scroll's icon edges are BLACK in retail but rendered WHITE in acdream. Cause = the effects!=0 gate (registered AP-44) that skipped retail's effects==0 recolor. Retail's effect tile is non-null even for effects==0 (the 0x21 SOLID-BLACK fallback 0x060011C5), so RenderIcons recolors pure-white pixels to black on mundane items and to the effect hue on magical ones. Remove the gate (always recolor); retire AP-44 (now faithful). TryGetEffectColor made internal + a golden test pins effects==0 -> ~black. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
178 lines
8.4 KiB
C#
178 lines
8.4 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 TryGetEffectColor_noEffect_resolvesToBlackFallback()
|
|
{
|
|
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 resolves to the 0x21 solid-black fallback tile (0x060011C5), so the
|
|
// ALWAYS-on recolor blackens an icon's pure-white edge pixels on mundane items —
|
|
// retail-faithful (the no-mana scroll's edges are BLACK, not white). Confirmed
|
|
// visually against retail 2026-06-17.
|
|
Assert.True(composer.TryGetEffectColor(0u, out var c));
|
|
Assert.True(c.r <= 8 && c.g <= 8 && c.b <= 8, $"expected ~black, got ({c.r},{c.g},{c.b})");
|
|
Assert.Equal(255, c.a);
|
|
}
|
|
|
|
[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
|
|
}
|
|
|
|
[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
|
|
}
|
|
}
|