feat(D.5.2): IconComposer.ResolveEffectDid (effect submap 0x10000005)

Add effect-overlay submap resolve: EnsureEffectSubMap walks the portal
MasterMap (0x25000000) → EnumIDMap 0x10000005 → submap 0x25000009;
ResolveEffectDid(effects) maps LowestSetBit(effects)+1 → RenderSurface
DID with fallback to index 0x21. Golden test validates all 6 cases
(Magical/Poisoned/BoostHealth/BoostStamina/Nether/zero) against the
live dat. Retail ref: IconData::RenderIcons 0x0058d180.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Erik 2026-06-17 18:37:40 +02:00
parent e7b6e83cf8
commit 75ac51ac23
2 changed files with 66 additions and 0 deletions

View file

@ -43,6 +43,15 @@ public sealed class IconComposer
private bool _underlayResolveTried; private bool _underlayResolveTried;
private readonly Dictionary<uint, uint> _underlayDidByIndex = new(); private readonly Dictionary<uint, uint> _underlayDidByIndex = new();
// ── effect overlay resolve (EnumIDMap 0x10000005) ────────────────────────
// Portal MasterMap (0x25000000) maps enum 0x10000005 → submap DID (0x25000009).
// Submap maps index → 0x06 RenderSurface DID. index = LSB(effects)+1, fallback 0x21.
// Refs: IconData::RenderIcons 0x0058d180 (effect path); the effect tile is a
// ReplaceColor tint SOURCE, not a blit layer (see RESOLVED doc, divergence DR-1).
private EnumIDMap? _effectSubMap;
private bool _effectResolveTried;
private readonly Dictionary<uint, uint> _effectDidByIndex = new();
public IconComposer(DatCollection dats, TextureCache cache) public IconComposer(DatCollection dats, TextureCache cache)
{ {
_dats = dats; _dats = dats;
@ -84,6 +93,38 @@ public sealed class IconComposer
if (_dats.Portal.TryGet<EnumIDMap>(subDid, out var sub)) _underlaySubMap = sub; if (_dats.Portal.TryGet<EnumIDMap>(subDid, out var sub)) _underlaySubMap = sub;
} }
/// <summary>
/// Resolve the effect-overlay DID for <paramref name="effects"/> via the EnumIDMap
/// 0x10000005 chain. index = LowestSetBit(effects)+1; if the entry is missing/zero,
/// retail falls back to index 0x21 (the solid-black tile). NOTE: the effect path has
/// NO lsb==-1 pre-check (unlike the type underlay), so effects==0 → index 0 → miss →
/// fallback. (Retail IconData::RenderIcons 0x0058d180.)
/// </summary>
internal uint ResolveEffectDid(uint effects)
{
int lsb = effects == 0 ? -1 : BitOperations.TrailingZeroCount(effects);
uint index = (uint)(lsb + 1);
if (_effectDidByIndex.TryGetValue(index, out var cached)) return cached;
EnsureEffectSubMap();
uint did = 0;
if (_effectSubMap is { } sub && sub.ClientEnumToID.TryGetValue(index, out var d)) did = d;
if (did == 0 && _effectSubMap is { } sub2 && sub2.ClientEnumToID.TryGetValue(0x21u, out var fb))
did = fb;
_effectDidByIndex[index] = did;
return did;
}
private void EnsureEffectSubMap()
{
if (_effectResolveTried) return;
_effectResolveTried = true;
uint masterDid = (uint)_dats.Portal.Header.MasterMapId; // = 0x25000000
if (masterDid == 0) return;
if (!_dats.Portal.TryGet<EnumIDMap>(masterDid, out var master)) return;
if (!master.ClientEnumToID.TryGetValue(0x10000005u, out var subDid)) return; // → 0x25000009
if (_dats.Portal.TryGet<EnumIDMap>(subDid, out var sub)) _effectSubMap = sub;
}
/// <summary>Pure alpha-over composite, bottom-&gt;top. Layers may differ in size; /// <summary>Pure alpha-over composite, bottom-&gt;top. Layers may differ in size;
/// the result is sized to the FIRST (bottom) layer and upper layers are sampled /// the result is sized to the FIRST (bottom) layer and upper layers are sampled
/// top-left aligned (all icon layers are 32x32 in practice).</summary> /// top-left aligned (all icon layers are 32x32 in practice).</summary>

View file

@ -101,4 +101,29 @@ public class IconComposerTests
Assert.Equal(0x060011D5u, composer.ResolveUnderlayDid(ItemType.Jewelry)); Assert.Equal(0x060011D5u, composer.ResolveUnderlayDid(ItemType.Jewelry));
Assert.Equal(0x060011D4u, composer.ResolveUnderlayDid(ItemType.None)); 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));
}
} }