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.