fix(plugin-ui): plugin did icons use retail's keyed-white recolor, not a raw blit
Owner report 2026-09-06: MossTank's shelf icon (0x06002C41) drew with a white ring. DAT icon art reserves pure-white-opaque pixels as the recolor key that retail IconData::RenderIcons (0x0058d180) replaces per pixel through SurfaceWindow::ReplaceColor (0x004415b0) from the effect tile — the solid-black 0x21 tile when there are no effects. The inventory already does this through IconComposer; the plugin did sink (markup <icon did>, <button icon>, <list icons> and the shelf button) blitted the art raw. RetailMarkupIconResolver.ResolveDid now hands out IconComposer.GetKeyedIcon — the drag-icon composite (base art + effects==0 recolor, no overlay, no underlay), sharing that cache — so did icons look like a mundane inventory item does. The resolver no longer needs a TextureCache. KeyedIconInstalledDatTests pins both halves against the real DAT: the raw art carries the key, the composite carries none, and ResolveDid returns exactly the keyed composite. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
parent
f2d7562c86
commit
e61edd946b
8 changed files with 137 additions and 19 deletions
|
|
@ -201,6 +201,13 @@ and `item` are almost always bindings (`spell="{SpellId}"`,
|
|||
resolving to 0, or the resolver returning no texture, draws nothing — never a
|
||||
placeholder, never a throw.
|
||||
|
||||
A `did` icon is **not blitted raw**. It is drawn the way retail draws every
|
||||
icon it composites (`IconData::RenderIcons` with no overlay and no effects):
|
||||
the art's pure-white pixels are the DAT's "recolor me" key and are replaced
|
||||
with the solid-black fallback tile, exactly as a mundane item in the inventory.
|
||||
Raw art shows a white ring around the icon (Decal's `HudPictureBox` draws it
|
||||
that way); acdream does not. Art without any pure-white pixel is unaffected.
|
||||
|
||||
### `<icon>`
|
||||
|
||||
```xml
|
||||
|
|
|
|||
|
|
@ -66,7 +66,6 @@ public interface IMarkupIconResolver
|
|||
public sealed class RetailMarkupIconResolver : IMarkupIconResolver
|
||||
{
|
||||
private readonly IDatReaderWriter _dats;
|
||||
private readonly TextureCache _textureCache;
|
||||
private readonly IconComposer _icons;
|
||||
private readonly ClientObjectTable _objects;
|
||||
|
||||
|
|
@ -114,12 +113,10 @@ public sealed class RetailMarkupIconResolver : IMarkupIconResolver
|
|||
|
||||
public RetailMarkupIconResolver(
|
||||
IDatReaderWriter dats,
|
||||
TextureCache textureCache,
|
||||
IconComposer icons,
|
||||
ClientObjectTable objects)
|
||||
{
|
||||
_dats = dats ?? throw new ArgumentNullException(nameof(dats));
|
||||
_textureCache = textureCache ?? throw new ArgumentNullException(nameof(textureCache));
|
||||
_icons = icons ?? throw new ArgumentNullException(nameof(icons));
|
||||
_objects = objects ?? throw new ArgumentNullException(nameof(objects));
|
||||
}
|
||||
|
|
@ -137,12 +134,14 @@ public sealed class RetailMarkupIconResolver : IMarkupIconResolver
|
|||
/// (<c>DatDatabaseWrapper.TryGet</c>'s <c>_databaseLock</c>) — the same
|
||||
/// synchronization <see cref="IconComposer.TryDecode"/> relies on for
|
||||
/// every one of its own DAT reads — so no additional lock is taken here.
|
||||
/// Requests the NEAREST sampler (Slice B finding 6): plugin icon art is
|
||||
/// pixel-exact 32x32 DAT art, the same convention every other icon in the
|
||||
/// client draws with (dat-font glyphs, composited spell/item icons), and
|
||||
/// <see cref="TextureCache"/> keys its render-surface cache by
|
||||
/// <c>(id, nearest)</c> so this never collides with a chrome sprite's
|
||||
/// linear sampling of the same id.
|
||||
/// A resolvable id is NOT blitted raw: it goes through
|
||||
/// <see cref="IconComposer.GetKeyedIcon"/> — retail's <c>RenderIcons</c>
|
||||
/// composite with no overlay and no effects, which replaces the art's
|
||||
/// pure-white keyed pixels with the effects==0 solid-black tile. Item,
|
||||
/// spell and component art reserve pure white as that key, so a raw blit
|
||||
/// shows a white ring (owner report 2026-09-06, the MossTank shelf icon);
|
||||
/// the composed texture is uploaded nearest-sampled by the composer, the
|
||||
/// same convention every other icon in the client draws with.
|
||||
/// </summary>
|
||||
public (uint tex, int w, int h) ResolveDid(uint did)
|
||||
{
|
||||
|
|
@ -161,9 +160,8 @@ public sealed class RetailMarkupIconResolver : IMarkupIconResolver
|
|||
}
|
||||
else
|
||||
{
|
||||
uint tex = _textureCache.GetOrUploadRenderSurface(did, out int w, out int h, nearest: true);
|
||||
result = (tex, w, h);
|
||||
isMiss = false;
|
||||
result = _icons.GetKeyedIcon(did);
|
||||
isMiss = result.tex == 0u;
|
||||
}
|
||||
|
||||
_resolvedDidCache[did] = result;
|
||||
|
|
|
|||
|
|
@ -264,6 +264,46 @@ public sealed class IconComposer
|
|||
return iconId == 0 ? 0u : GetOrCreateDragIcon(iconId, overlayId, effects)?.Texture ?? 0u;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A bare RenderSurface icon drawn the way retail draws EVERY icon it composites
|
||||
/// (<c>IconData::RenderIcons</c> 0x0058d180 with no custom overlay and no effects):
|
||||
/// the art, then <c>SurfaceWindow::ReplaceColor</c> of its pure-white keyed pixels
|
||||
/// from the effects==0 fallback tile (the solid-black 0x21 tile 0x060011C5). Item,
|
||||
/// spell and component art in the DAT reserve pure white as the "recolor me" key —
|
||||
/// blitting such art raw shows a white ring around the icon (owner report
|
||||
/// 2026-09-06 on the plugin shelf's MossTank icon; the same defect the inventory
|
||||
/// had before the effect recolor landed). This is exactly the drag-icon composite
|
||||
/// (<see cref="GetDragIcon"/>) with neither overlay nor effects, so it shares that
|
||||
/// cache. Used by the plugin markup <c>did</c> sink and the plugin shelf.
|
||||
/// Returns <c>(0, 0, 0)</c> when the surface does not exist.
|
||||
/// </summary>
|
||||
public (uint tex, int w, int h) GetKeyedIcon(uint iconId)
|
||||
{
|
||||
if (iconId == 0u) return (0u, 0, 0);
|
||||
ComposedIcon? icon = GetOrCreateDragIcon(iconId, overlayId: 0u, effects: 0u);
|
||||
return icon is null ? (0u, 0, 0) : (icon.Texture, icon.Width, icon.Height);
|
||||
}
|
||||
|
||||
/// <summary>Test seam for <see cref="GetKeyedIcon"/>: the composed RGBA8 pixels.</summary>
|
||||
internal bool TryGetKeyedIconRgba(uint iconId, out byte[] rgba, out int w, out int h)
|
||||
{
|
||||
rgba = Array.Empty<byte>(); w = 0; h = 0;
|
||||
if (iconId == 0u) return false;
|
||||
ComposedIcon? icon = GetOrCreateDragIcon(iconId, overlayId: 0u, effects: 0u);
|
||||
if (icon is null) return false;
|
||||
rgba = icon.Rgba; w = icon.Width; h = icon.Height;
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>Test seam: the raw decoded RGBA8 of one RenderSurface, no compositing.</summary>
|
||||
internal bool TryDecodeRaw(uint renderSurfaceId, out byte[] rgba, out int w, out int h)
|
||||
{
|
||||
rgba = Array.Empty<byte>(); w = 0; h = 0;
|
||||
if (!TryDecode(renderSurfaceId, out DecodedTexture decoded)) return false;
|
||||
rgba = decoded.Rgba8; w = decoded.Width; h = decoded.Height;
|
||||
return true;
|
||||
}
|
||||
|
||||
private ComposedIcon? GetOrCreateDragIcon(uint iconId, uint overlayId, uint effects)
|
||||
{
|
||||
var key = (iconId, overlayId, effects);
|
||||
|
|
|
|||
|
|
@ -4726,7 +4726,6 @@ public sealed class RetailUiRuntime : IDisposable
|
|||
// existing bindings field supplies the object table.
|
||||
IMarkupIconResolver iconResolver = new RetailMarkupIconResolver(
|
||||
_bindings.Assets.Dats,
|
||||
_bindings.Assets.TextureCache,
|
||||
_bindings.Assets.Icons,
|
||||
_bindings.Toolbar.Objects);
|
||||
|
||||
|
|
|
|||
74
tests/AcDream.App.Tests/UI/KeyedIconInstalledDatTests.cs
Normal file
74
tests/AcDream.App.Tests/UI/KeyedIconInstalledDatTests.cs
Normal file
|
|
@ -0,0 +1,74 @@
|
|||
using AcDream.App.Rendering;
|
||||
using AcDream.App.Tests.Rendering;
|
||||
using AcDream.App.Tests.Rendering.Gpu;
|
||||
using AcDream.App.UI;
|
||||
using AcDream.Content;
|
||||
using AcDream.Core.Items;
|
||||
using DatReaderWriter;
|
||||
using DatReaderWriter.Options;
|
||||
using Xunit;
|
||||
|
||||
namespace AcDream.App.Tests.UI;
|
||||
|
||||
/// <summary>
|
||||
/// Owner report 2026-09-06: the MossTank shelf icon (<c>0x06002C41</c>) drew
|
||||
/// with a white ring. DAT icon art reserves pure-white-opaque pixels as the
|
||||
/// "recolor me" key that retail <c>IconData::RenderIcons</c> replaces per pixel
|
||||
/// through <c>SurfaceWindow::ReplaceColor</c> (effects==0 → the solid-black
|
||||
/// 0x21 tile); blitting the art raw leaves the key visible. The plugin
|
||||
/// <c>did</c> sink (<see cref="RetailMarkupIconResolver.ResolveDid"/>, also the
|
||||
/// shelf button's path) therefore goes through
|
||||
/// <see cref="IconComposer.GetKeyedIcon"/>, the drag-icon composite with no
|
||||
/// overlay and no effects. This pins both halves: the raw art really does
|
||||
/// carry the key (so the test is not vacuous), and the composed result has
|
||||
/// none of it left.
|
||||
/// </summary>
|
||||
[Trait("Lane", "InstalledDat")]
|
||||
public sealed class KeyedIconInstalledDatTests
|
||||
{
|
||||
private const uint MossTankShelfIconId = 0x06002C41u;
|
||||
|
||||
[Fact]
|
||||
public void KeyedIcon_ReplacesTheArtsPureWhiteKey_AndResolveDidUsesIt()
|
||||
{
|
||||
string? datDir = InstalledDatTestPath.Resolve();
|
||||
if (datDir is null)
|
||||
{
|
||||
Assert.Fail(
|
||||
"Lane=InstalledDat requires an installed retail DAT directory; see docs/release-gate.md.");
|
||||
return;
|
||||
}
|
||||
|
||||
using var dats = new DatCollection(datDir, DatAccessType.Read);
|
||||
using var adapter = new DatCollectionAdapter(dats);
|
||||
var device = new RecordingGpuDevice();
|
||||
using var cache = new TextureCache(device, adapter);
|
||||
var icons = new IconComposer(adapter, cache);
|
||||
|
||||
Assert.True(icons.TryDecodeRaw(MossTankShelfIconId, out byte[] raw, out int rw, out int rh));
|
||||
Assert.True(
|
||||
CountPureWhite(raw) > 0,
|
||||
"the raw art must carry pure-white keyed pixels, or this pin proves nothing");
|
||||
|
||||
Assert.True(icons.TryGetKeyedIconRgba(MossTankShelfIconId, out byte[] keyed, out int kw, out int kh));
|
||||
Assert.Equal((rw, rh), (kw, kh));
|
||||
Assert.Equal(0, CountPureWhite(keyed));
|
||||
|
||||
// The markup/shelf did sink must hand out exactly the keyed composite.
|
||||
var resolver = new RetailMarkupIconResolver(adapter, icons, new ClientObjectTable());
|
||||
(uint tex, int w, int h) = resolver.ResolveDid(MossTankShelfIconId);
|
||||
Assert.Equal(icons.GetKeyedIcon(MossTankShelfIconId), (tex, w, h));
|
||||
Assert.NotEqual(0u, tex);
|
||||
}
|
||||
|
||||
private static int CountPureWhite(byte[] rgba)
|
||||
{
|
||||
int count = 0;
|
||||
for (int i = 0; i + 3 < rgba.Length; i += 4)
|
||||
{
|
||||
if (rgba[i] == 255 && rgba[i + 1] == 255 && rgba[i + 2] == 255 && rgba[i + 3] == 255)
|
||||
count++;
|
||||
}
|
||||
return count;
|
||||
}
|
||||
}
|
||||
|
|
@ -56,7 +56,7 @@ public sealed class MossTankIconInstalledDatTests
|
|||
using var cache = new TextureCache(device, adapter);
|
||||
var icons = new IconComposer(adapter, cache);
|
||||
var objects = new ClientObjectTable();
|
||||
var resolver = new RetailMarkupIconResolver(adapter, cache, icons, objects);
|
||||
var resolver = new RetailMarkupIconResolver(adapter, icons, objects);
|
||||
|
||||
(uint tex, int w, int h) = resolver.ResolveDid(MossTankShelfIconId);
|
||||
Assert.NotEqual(0u, tex);
|
||||
|
|
|
|||
|
|
@ -57,7 +57,7 @@ public sealed class RetailMarkupIconResolverInstalledDatTests
|
|||
using var cache = new TextureCache(device, adapter);
|
||||
var icons = new IconComposer(adapter, cache);
|
||||
var objects = new ClientObjectTable();
|
||||
var resolver = new RetailMarkupIconResolver(adapter, cache, icons, objects);
|
||||
var resolver = new RetailMarkupIconResolver(adapter, icons, objects);
|
||||
|
||||
(uint missTex, int missW, int missH) = resolver.ResolveDid(DecalHabitDoubleNormalizedId);
|
||||
Assert.Equal((0u, 0, 0), (missTex, missW, missH));
|
||||
|
|
@ -99,7 +99,7 @@ public sealed class RetailMarkupIconResolverInstalledDatTests
|
|||
using var cache = new TextureCache(device, adapter);
|
||||
var icons = new IconComposer(adapter, cache);
|
||||
var objects = new ClientObjectTable();
|
||||
var resolver = new RetailMarkupIconResolver(adapter, cache, icons, objects);
|
||||
var resolver = new RetailMarkupIconResolver(adapter, icons, objects);
|
||||
|
||||
var root = new UiRoot { Width = 800f, Height = 600f };
|
||||
using var shelf = new PluginSidePanel(
|
||||
|
|
|
|||
|
|
@ -54,7 +54,7 @@ public sealed class RetailMarkupIconResolverMemoizationTests
|
|||
using var cache = new TextureCache(device, dats);
|
||||
var icons = new IconComposer(dats, cache);
|
||||
var objects = new ClientObjectTable();
|
||||
var resolver = new RetailMarkupIconResolver(dats, cache, icons, objects);
|
||||
var resolver = new RetailMarkupIconResolver(dats, icons, objects);
|
||||
|
||||
(uint tex1, int w1, int h1) = resolver.ResolveDid(DecalHabitDoubleNormalizedId);
|
||||
(uint tex2, int w2, int h2) = resolver.ResolveDid(DecalHabitDoubleNormalizedId);
|
||||
|
|
@ -79,7 +79,7 @@ public sealed class RetailMarkupIconResolverMemoizationTests
|
|||
using var cache = new TextureCache(device, dats);
|
||||
var icons = new IconComposer(dats, cache);
|
||||
var objects = new ClientObjectTable();
|
||||
var resolver = new RetailMarkupIconResolver(dats, cache, icons, objects);
|
||||
var resolver = new RetailMarkupIconResolver(dats, icons, objects);
|
||||
|
||||
resolver.ResolveDid(0x06000001u);
|
||||
resolver.ResolveDid(0x06000002u);
|
||||
|
|
@ -108,7 +108,7 @@ public sealed class RetailMarkupIconResolverMemoizationTests
|
|||
using var cache = new TextureCache(device, dats);
|
||||
var icons = new IconComposer(dats, cache);
|
||||
var objects = new ClientObjectTable();
|
||||
var resolver = new RetailMarkupIconResolver(dats, cache, icons, objects);
|
||||
var resolver = new RetailMarkupIconResolver(dats, icons, objects);
|
||||
|
||||
// Fill the 256-entry miss cache with distinct ids (starting at 1;
|
||||
// did == 0 short-circuits before ever touching the DAT).
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue