feat(D.5.1): faithful item-icon type-default underlay (EnumIDMap 0x10000004) — opaque icon backing
Retail IconData::RenderIcons (decomp 407524) builds the icon layer stack bottom→top: type-default underlay (OPAQUE, Blit_Normal) first, then custom underlay, base icon, custom overlay. acdream's IconComposer omitted the type-default underlay, leaving filled toolbar slots with a transparent background. Resolution via the two-level EnumIDMap chain that retail uses (DBCache::GetDIDFromEnum 0x413940): Portal.Header.MasterMapId (0x25000000) → master[0x10000004] → submap DID (0x25000008) → submap[LSB(itemType)+1] → 0x06 RenderSurface underlay DID. Golden values confirmed against the live dats: MeleeWeapon→0x060011CB, Armor→0x060011CF, Clothing→0x060011F3, Jewelry→0x060011D5, None(fallback 0x21)→0x060011D4. Changes: - IconComposer: add ResolveUnderlayDid(ItemType)/EnsureUnderlaySubMap (memoised); widen cache key from (uint,uint,uint)→(uint,uint,uint,uint); GetIcon gains ItemType param and prepends the opaque underlay as layer 0 (Compose sizes to it → fully opaque) - ToolbarController: widen _iconIds Func from 3-arg to 4-arg; Populate passes item.Type - GameWindow: update toolbar mount lambda to 4-arg form - Tests: update ToolbarController test stubs to (_,_,_,_); add Compose_opaqueUnderlayFirst_resultIsFullyOpaque (dat-free) and ResolveUnderlayDid_goldenValues_matchDat (dat-gated, skip when dats absent) No divergence-register row existed for this omission; none added (fully ported now). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
bfc452d610
commit
f21dbfad80
5 changed files with 156 additions and 23 deletions
|
|
@ -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]);
|
||||
}
|
||||
|
||||
/// <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));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<PlayerDescriptionParser.ShortcutEntry>(),
|
||||
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<PlayerDescriptionParser.ShortcutEntry>(),
|
||||
iconIds: (_,_,_) => 0u, useItem: _ => { });
|
||||
iconIds: (_,_,_,_) =>0u, useItem: _ => { });
|
||||
|
||||
ctrl.SetCombatMode(CombatMode.Melee);
|
||||
|
||||
|
|
@ -152,7 +152,7 @@ public class ToolbarControllerTests
|
|||
|
||||
ToolbarController.Bind(layout, repo,
|
||||
() => Array.Empty<PlayerDescriptionParser.ShortcutEntry>(),
|
||||
iconIds: (_,_,_) => 0u, useItem: _ => { },
|
||||
iconIds: (_,_,_,_) =>0u, useItem: _ => { },
|
||||
combatState: combat);
|
||||
|
||||
// Initially NonCombat after bind.
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue