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]); } /// /// 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). /// [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)); } }