diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs index e62fd76f..8bfdf628 100644 --- a/src/AcDream.App/Rendering/GameWindow.cs +++ b/src/AcDream.App/Rendering/GameWindow.cs @@ -1920,7 +1920,7 @@ public sealed class GameWindow : IDisposable _toolbarController = AcDream.App.UI.Layout.ToolbarController.Bind( toolbarLayout, Items, () => Shortcuts, - iconIds: (icon, under, over) => iconComposer.GetIcon(icon, under, over), + iconIds: (type, icon, under, over) => iconComposer.GetIcon(type, icon, under, over), useItem: guid => UseItemByGuid(guid), combatState: Combat); diff --git a/src/AcDream.App/UI/IconComposer.cs b/src/AcDream.App/UI/IconComposer.cs index 09b97def..fc2c87aa 100644 --- a/src/AcDream.App/UI/IconComposer.cs +++ b/src/AcDream.App/UI/IconComposer.cs @@ -1,6 +1,8 @@ using System; using System.Collections.Generic; +using System.Numerics; using AcDream.App.Rendering; +using AcDream.Core.Items; using AcDream.Core.Textures; using DatReaderWriter; using DatReaderWriter.DBObjs; @@ -9,18 +11,37 @@ namespace AcDream.App.UI; /// /// Builds an item icon by alpha-compositing its RenderSurface layers into one 32x32 -/// texture, mirroring retail IconData::RenderIcons (decomp 407524). Each layer is a -/// 0x06 RenderSurface decoded DIRECTLY (the D.2b RenderSurface-vs-Surface rule). -/// Phase 1 composites the layers ItemInstance exposes (custom underlay + base + -/// custom overlay); the GetByEnum type-default underlay, the overlay ReplaceColor -/// tint, and the effect overlay are deferred (see plan Task 12 / divergence rows). -/// Composited textures are cached by their layer-id tuple. +/// texture, mirroring retail IconData::RenderIcons (decomp 407524) and +/// DBCache::GetDIDFromEnum (0x413940). Each layer is a 0x06 RenderSurface decoded +/// DIRECTLY (the D.2b RenderSurface-vs-Surface rule). +/// +/// Layer order (bottom → top), matching retail: +/// 1. type-default underlay (OPAQUE backing; resolved via EnumIDMap 0x10000004 from +/// the portal MasterMap) — +/// 2. item custom underlay (e.g. "magic" tint strip) +/// 3. base icon +/// 4. item custom overlay (e.g. "enchanted" sparkle) +/// +/// The type-default underlay is the key to non-transparent filled slots: because it +/// is fully opaque and is layer 0, sizes the output to it and +/// the alpha-over pass fills every pixel. The overlay ReplaceColor tint and the effect +/// overlay (RenderIcons 407546) remain out of scope (paperdoll phase). +/// +/// Composited textures are cached by their (typeUnderlay, underlay, base, overlay) tuple. /// public sealed class IconComposer { private readonly DatCollection _dats; private readonly TextureCache _cache; - private readonly Dictionary<(uint, uint, uint), uint> _byTuple = new(); + private readonly Dictionary<(uint, uint, uint, uint), uint> _byTuple = new(); + + // ── type-default underlay resolve (EnumIDMap 0x10000004) ───────────────── + // Portal MasterMap (0x25000000) maps enum 0x10000004 → submap DID (0x25000008). + // Submap maps index → 0x06 RenderSurface DID. index = LSB(itemType)+1, or 0x21. + // Refs: IconData::RenderIcons 0058d214–0058d22c; DBCache::GetDIDFromEnum 0x413940. + private EnumIDMap? _underlaySubMap; + private bool _underlayResolveTried; + private readonly Dictionary _underlayDidByIndex = new(); public IconComposer(DatCollection dats, TextureCache cache) { @@ -28,6 +49,41 @@ public sealed class IconComposer _cache = cache; } + /// + /// Resolve the type-default underlay DID for via the + /// two-level EnumIDMap chain (retail: IconData::RenderIcons 0058d214–0058d22c + + /// DBCache::GetDIDFromEnum 0x413940). + /// + /// index = LowestSetBit(itemType) + 1, or 0x21 when itemType has no bits set. + /// + /// NOTE: retail RenderIcons (407546) has a special paperdoll IsThePlayer case + /// that uses GetDIDByEnum(0x10000004, 7) + TYPE_CONTAINER for the player doll — that + /// path is out of scope here (paperdoll phase). + /// + internal uint ResolveUnderlayDid(ItemType itemType) + { + uint raw = (uint)itemType; + int lsb = raw == 0 ? -1 : BitOperations.TrailingZeroCount(raw); + uint index = lsb < 0 ? 0x21u : (uint)(lsb + 1); + if (_underlayDidByIndex.TryGetValue(index, out var cached)) return cached; + EnsureUnderlaySubMap(); + uint did = 0; + if (_underlaySubMap is { } sub && sub.ClientEnumToID.TryGetValue(index, out var d)) did = d; + _underlayDidByIndex[index] = did; + return did; + } + + private void EnsureUnderlaySubMap() + { + if (_underlayResolveTried) return; + _underlayResolveTried = 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(0x10000004u, out var subDid)) return; // → 0x25000008 + if (_dats.Portal.TryGet(subDid, out var sub)) _underlaySubMap = sub; + } + /// Pure alpha-over composite, bottom->top. Layers may differ in size; /// the result is sized to the FIRST (bottom) layer and upper layers are sampled /// top-left aligned (all icon layers are 32x32 in practice). @@ -56,15 +112,24 @@ public sealed class IconComposer return (outp, w, h); } - /// Resolve (and cache) the composited GL texture for an item's icon - /// layers. Returns 0 if no base icon is available. - public uint GetIcon(uint iconId, uint underlayId, uint overlayId) + /// + /// Resolve (and cache) the composited GL texture for an item's icon layers. + /// Returns 0 if no base icon is available. + /// + /// Layer order mirrors retail IconData::RenderIcons (decomp 407524): + /// type-default underlay (OPAQUE) → custom underlay → base icon → custom overlay. + /// The type-default underlay is resolved via the EnumIDMap 0x10000004 chain; + /// its presence ensures filled slots are never transparent. + /// + public uint GetIcon(ItemType itemType, uint iconId, uint underlayId, uint overlayId) { if (iconId == 0) return 0; - var key = (iconId, underlayId, overlayId); + uint typeUnderlayDid = ResolveUnderlayDid(itemType); + var key = (typeUnderlayDid, iconId, underlayId, overlayId); if (_byTuple.TryGetValue(key, out var tex)) return tex; var layers = new List<(byte[] rgba, int w, int h)>(); + AddLayer(layers, typeUnderlayDid); // OPAQUE bottom; sizes the 32x32 output AddLayer(layers, underlayId); AddLayer(layers, iconId); AddLayer(layers, overlayId); diff --git a/src/AcDream.App/UI/Layout/ToolbarController.cs b/src/AcDream.App/UI/Layout/ToolbarController.cs index bd861476..8bfc91d9 100644 --- a/src/AcDream.App/UI/Layout/ToolbarController.cs +++ b/src/AcDream.App/UI/Layout/ToolbarController.cs @@ -51,14 +51,14 @@ public sealed class ToolbarController private readonly UiElement?[] _combatIndicators = new UiElement?[CombatIndicatorIds.Length]; private readonly ItemRepository _repo; private readonly Func> _shortcuts; - private readonly Func _iconIds; // (iconId, underlayId, overlayId) → GL tex + private readonly Func _iconIds; // (itemType, iconId, underlayId, overlayId) → GL tex private readonly Action _useItem; // guid → fire UseObject private ToolbarController( ImportedLayout layout, ItemRepository repo, Func> shortcuts, - Func iconIds, + Func iconIds, Action useItem, CombatState? combatState) { @@ -105,7 +105,7 @@ public sealed class ToolbarController /// Imported toolbar layout (LayoutDesc 0x21000016). /// Live item repository — must stay alive for the controller's lifetime. /// Provider for the current shortcut bar list. - /// Resolves (iconId, underlayId, overlayId) → GL texture handle. + /// Resolves (itemType, iconId, underlayId, overlayId) → GL texture handle. /// Callback fired when a bound slot is clicked; receives the item guid. /// /// Optional live combat state — when provided, the toolbar subscribes to @@ -117,7 +117,7 @@ public sealed class ToolbarController ImportedLayout layout, ItemRepository repo, Func> shortcuts, - Func iconIds, + Func iconIds, Action useItem, CombatState? combatState = null) { @@ -148,7 +148,7 @@ public sealed class ToolbarController var item = _repo.GetItem(sc.ObjectGuid); if (item is null) continue; // deferred: ItemAdded will re-call Populate - uint tex = _iconIds(item.IconId, item.IconUnderlayId, item.IconOverlayId); + uint tex = _iconIds(item.Type, item.IconId, item.IconUnderlayId, item.IconOverlayId); list.Cell.SetItem(sc.ObjectGuid, tex); } } diff --git a/tests/AcDream.App.Tests/UI/IconComposerTests.cs b/tests/AcDream.App.Tests/UI/IconComposerTests.cs index 09ec721f..06a225e5 100644 --- a/tests/AcDream.App.Tests/UI/IconComposerTests.cs +++ b/tests/AcDream.App.Tests/UI/IconComposerTests.cs @@ -1,4 +1,9 @@ +using System; +using System.IO; using AcDream.App.UI; +using AcDream.Core.Items; +using DatReaderWriter; +using DatReaderWriter.Options; namespace AcDream.App.Tests.UI; @@ -33,4 +38,67 @@ public class IconComposerTests 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)); + } } diff --git a/tests/AcDream.App.Tests/UI/Layout/ToolbarControllerTests.cs b/tests/AcDream.App.Tests/UI/Layout/ToolbarControllerTests.cs index 6055805e..95b90a46 100644 --- a/tests/AcDream.App.Tests/UI/Layout/ToolbarControllerTests.cs +++ b/tests/AcDream.App.Tests/UI/Layout/ToolbarControllerTests.cs @@ -53,7 +53,7 @@ public class ToolbarControllerTests { new(Index: 0, ObjectGuid: 0x5001u, SpellId: 0, Layer: 0) }; ToolbarController.Bind(layout, repo, () => shortcuts, - iconIds: (_,_,_) => 0x77u, useItem: _ => { }); + iconIds: (_,_,_,_) => 0x77u, useItem: _ => { }); Assert.Equal(0x5001u, slots[Row1[0]].Cell.ItemId); Assert.Equal(0x77u, slots[Row1[0]].Cell.IconTexture); @@ -69,7 +69,7 @@ public class ToolbarControllerTests { new(Index: 2, ObjectGuid: 0x5002u, SpellId: 0, Layer: 0) }; ToolbarController.Bind(layout, repo, () => shortcuts, - iconIds: (_,_,_) => 0x88u, useItem: _ => { }); + iconIds: (_,_,_,_) => 0x88u, useItem: _ => { }); Assert.Equal(0u, slots[Row1[2]].Cell.ItemId); // not bound yet repo.AddOrUpdate(new ItemInstance { ObjectId = 0x5002u, WeenieClassId = 1u, IconId = 0x06005678u }); @@ -88,7 +88,7 @@ public class ToolbarControllerTests uint used = 0; ToolbarController.Bind(layout, repo, () => shortcuts, - iconIds: (_,_,_) => 0x77u, useItem: g => used = g); + iconIds: (_,_,_,_) => 0x77u, useItem: g => used = g); // UiEvent is a positional record struct: (SourceId, Target, Type, Data0..3, Payload) slots[Row1[0]].Cell.OnEvent(new UiEvent(0u, null, UiEventType.MouseDown)); @@ -110,7 +110,7 @@ public class ToolbarControllerTests ToolbarController.Bind(layout, repo, () => Array.Empty(), - iconIds: (_,_,_) => 0u, useItem: _ => { }); + iconIds: (_,_,_,_) =>0u, useItem: _ => { }); // Only peace indicator (index 0 = 0x10000192) is visible. Assert.True (indicators[0x10000192u].Visible, "peace indicator should be visible after bind"); @@ -130,7 +130,7 @@ public class ToolbarControllerTests var ctrl = ToolbarController.Bind(layout, repo, () => Array.Empty(), - iconIds: (_,_,_) => 0u, useItem: _ => { }); + iconIds: (_,_,_,_) =>0u, useItem: _ => { }); ctrl.SetCombatMode(CombatMode.Melee); @@ -152,7 +152,7 @@ public class ToolbarControllerTests ToolbarController.Bind(layout, repo, () => Array.Empty(), - iconIds: (_,_,_) => 0u, useItem: _ => { }, + iconIds: (_,_,_,_) =>0u, useItem: _ => { }, combatState: combat); // Initially NonCombat after bind.