feat(plugin-ui): Slice B — DAT icons in plugin markup (icon element, button/list icons, plugin icon ids)

Owner request: plugin panels (Decal/VirindiViewService-class, per the
MosswartMassacre reference usage) need to embed real DAT icons the way
FlagTrackerView.SafeSetListImage does — spell/skill art, raw portal
indices, and a window icon. This is Slice B of
docs/plans/2026-09-06-plugin-shelf-and-dat-icons.md (Slice A, the
movable/collapsible shelf, landed in 01b98ca30/4fada238e/718005b21).

What shipped:

- AcDream.Plugin.Abstractions.PluginIcons.Normalize: the one Decal-style
  bare-index -> 0x06xxxxxx RenderSurface DID grammar, applied at every
  icon SINK (descriptor IconSurfaceId in PluginShelfButton, and markup
  <icon did>/<button icon>/<list icons> did-kind ids) rather than on the
  plugin-facing records, which already carry real DIDs read straight
  from the client's tables.
- PluginSpellInfo.IconId / PluginSkillInfo.IconId /
  PluginInventoryItem.IconId / PluginWorldObject.IconId: additive init
  properties (default 0), filled in AppAutomationSurface from
  SpellMetadata.IconId (already projected from SpellBase.Icon by
  RetailSpellMetadataProjector — no gap there), a new BindSkillIcons
  parallel to BindSkillNames (GameWindow reads
  DatReaderWriter.Types.SkillBase.IconId — confirmed via reflection over
  the installed Chorizite.DatReaderWriter package, since its XML docs
  don't cover Pack/Unpack-generated public fields: Description, Name,
  IconId (uint), TrainedCost, SpecializedCost, Category, ChargenUse,
  MinLevel, Formula, UpperBound, LowerBound, LearnMod), and
  ClientObject.IconId in CaptureOwnedItems/ProjectWorldObject.
- IMarkupIconResolver (AcDream.App.UI): ResolveDid/ResolveSpell/
  ResolveItem. MarkupDocument.Build gains an optional parameter (null by
  default -> every icon sink resolves to nothing rather than throwing,
  so pre-Slice-B callers/tests are unaffected). RetailUiRuntime.
  MountPlugins builds ONE RetailMarkupIconResolver per pass from
  RetailUiAssets.ResolveSprite + RetailUiAssets.Icons (the shared
  IconComposer) + Toolbar.Objects (the SAME ClientObjectTable
  Magic/Toolbar bindings already borrow for their own icon resolution —
  no second object lookup introduced).
- New UiMarkupIcon widget (<icon x y w h did|spell|item tooltip>):
  exactly one source required (FormatException at Build otherwise,
  matching every other malformed-attribute rule), aspect-preserved,
  centered, click-through unless a tooltip makes it a real hit-test
  target.
- UiSimpleButton.IconSource and UiMarkupList.IconIdsSource/IconResolve:
  additive, default null/no-op, so every existing button/list caller
  (including the plugin shelf's own toggle/minimize buttons) is
  unaffected. Button icon draws flush left and shifts the caption's
  centering region right; list icons reserve a leading RowHeight-2
  column (Decal's IconColumn) and skip rows whose id is 0 or
  unresolvable.
- MarkupDocument centralizes the did/spell/item dispatch (including
  PluginIcons.Normalize for did) in two small helpers (BuildIconSource
  for <icon>/<button>, BuildRowIconResolve for <list>) so all three
  markup surfaces share one resolver call path.
- AcDream.Plugins.Smoke ships a RegisterPanelContent (in-memory KSML,
  no plugin-side .xml file) proof panel exercising every new surface:
  a bare-index <icon>, a literal-hex <icon>, a composited <icon
  spell=...>, a <button icon=...>, and a <list icons=... iconkind=
  spell> of the first five known self-buffs with their IconId printed
  alongside. Descriptor IconSurfaceId reuses the same bare index to
  prove the shelf button and the panel's own icon normalize identically.
- docs/plugin-ui-markup.md is the new SSOT for the full markup
  vocabulary + icon grammar + the Slice A shelf; linked from
  docs/README.md and docs/plans/2026-04-24-ui-framework.md.

Design decisions where the plan left room:
- Normalize runs inside the resolver dispatch (BuildIconSource/
  BuildRowIconResolve), not scattered at each markup call site, so
  every did-kind sink shares one choke point.
- did/spell/item all accept either a literal (decimal or 0x-hex) or a
  {Binding}, via one BindUintLiteralOrBinding helper, for symmetry —
  the plan only showed spell/item as bindings but didn't forbid a
  literal.
- <icon> requires exactly one source INCLUDING zero (not just two);
  an icon with no source is not a coherent element.
- The button/list icon draw math (icon column extent, padding) lives
  in the widgets themselves (UiSimpleButton/UiMarkupList), not in
  MarkupDocument, keeping the parser only responsible for wiring
  Func<(tex,w,h)> sources.

Tests: PluginIconsTests (Normalize table), MarkupIconTests (icon/button/
list resolver dispatch via a fake IMarkupIconResolver, plus draw-level
pins via the RecordingGpuDevice/TextRenderer apparatus already used by
UiAncestorClipTests/UiRenderContextDrawStringDatOutlineTests — "draws
nothing when unresolvable" and "button/list icon shifts the text"),
and AppAutomationSurfaceIconInstalledDatTests (Lane=InstalledDat: a
known spell's IconId matches the real installed SpellTable's own Icon
field exactly). Verified every new test fails to COMPILE without this
change (git-stashed the src/ changes, rebuilt the test project: CS0246
on IMarkupIconResolver) before restoring. Full App suite: 7331 passed /
97 skipped / 36 failed (identical pre-existing failure set/count to the
7306/97/36 baseline; the +25 passes are exactly the new tests).
AcDream.Plugins.MossTank.Tests (the main consumer of the touched
Plugin.Abstractions records) passes 337/337 unchanged, confirming
API-v1 binary/source compatibility. Full solution builds green.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Erik 2026-09-06 14:52:23 +02:00
parent 718005b210
commit 8217a349e0
21 changed files with 1440 additions and 16 deletions

View file

@ -64,6 +64,8 @@ internal sealed class AppAutomationSurface
private MagicCatalog _magicCatalog = MagicCatalog.Empty;
private IReadOnlyDictionary<uint, string> _skillNames =
new Dictionary<uint, string>();
private IReadOnlyDictionary<uint, uint> _skillIcons =
new Dictionary<uint, uint>();
private Func<int, string> _speciesName = static _ => string.Empty;
private IChargenPaletteColorSource? _paletteColors;
private Func<uint, uint, bool>? _equip;
@ -381,6 +383,18 @@ internal sealed class AppAutomationSurface
_skillNames = skillNames;
}
/// <summary>
/// Supply retail skill icon RenderSurface DIDs (SkillTable
/// <c>SkillBase.IconId</c>), read once alongside <see cref="BindSkillNames"/>
/// from the same portal.dat SkillTable pass.
/// </summary>
public void BindSkillIcons(IReadOnlyDictionary<uint, uint> skillIcons)
{
ArgumentNullException.ThrowIfNull(skillIcons);
lock (_gate)
_skillIcons = skillIcons;
}
/// <summary>Supply the immutable retail spell/component DAT catalog.</summary>
public void BindMagicCatalog(MagicCatalog catalog)
{
@ -766,6 +780,7 @@ internal sealed class AppAutomationSurface
BaseRangeConstant = meta.BaseRangeConstant,
BaseRangeModifier = meta.BaseRangeModifier,
FormulaComponentIds = meta.FormulaComponents,
IconId = meta.IconId,
};
/// <summary>
@ -929,10 +944,12 @@ internal sealed class AppAutomationSurface
{
RuntimeCharacterState? character;
IReadOnlyDictionary<uint, string> names;
IReadOnlyDictionary<uint, uint> icons;
lock (_gate)
{
character = _character;
names = _skillNames;
icons = _skillIcons;
}
if (character is null || names.Count == 0)
return Array.Empty<PluginSkillInfo>();
@ -940,7 +957,8 @@ internal sealed class AppAutomationSurface
var built = new List<PluginSkillInfo>(names.Count);
foreach (KeyValuePair<uint, string> pair in names)
{
if (TryProjectSkill(character, pair.Key, pair.Value, out PluginSkillInfo skill))
uint iconId = icons.TryGetValue(pair.Key, out uint icon) ? icon : 0u;
if (TryProjectSkill(character, pair.Key, pair.Value, iconId, out PluginSkillInfo skill))
built.Add(skill);
}
built.Sort(static (a, b) => string.CompareOrdinal(a.Name, b.Name));
@ -952,22 +970,25 @@ internal sealed class AppAutomationSurface
{
RuntimeCharacterState? character;
IReadOnlyDictionary<uint, string> names;
IReadOnlyDictionary<uint, uint> icons;
lock (_gate)
{
character = _character;
names = _skillNames;
icons = _skillIcons;
}
if (character is not null)
{
string name = names.TryGetValue(skillId, out string? n) ? n : string.Empty;
return TryProjectSkill(character, skillId, name, out skill);
uint iconId = icons.TryGetValue(skillId, out uint icon) ? icon : 0u;
return TryProjectSkill(character, skillId, name, iconId, out skill);
}
skill = default;
return false;
}
private static bool TryProjectSkill(
RuntimeCharacterState character, uint skillId, string name,
RuntimeCharacterState character, uint skillId, string name, uint iconId,
out PluginSkillInfo skill)
{
if (!character.View.TryGetSkill(skillId, out var snapshot))
@ -984,6 +1005,7 @@ internal sealed class AppAutomationSurface
skillId, name, Training(snapshot.Status), currentLevel)
{
Base = baseLevel,
IconId = iconId,
};
return true;
}
@ -1867,6 +1889,7 @@ internal sealed class AppAutomationSurface
? item.AppraisedSpellIds.ToArray()
: Array.Empty<uint>(),
ActiveSpellIds = activeSpells,
IconId = item?.IconId ?? 0u,
};
}
@ -2376,6 +2399,7 @@ internal sealed class AppAutomationSurface
MaterialType = item.MaterialType ?? 0u,
ObjectClass = ClassifyObject(item),
Palettes = ProjectPalettes(runtime, item.ObjectId),
IconId = item.IconId,
});
}
built.Sort(static (left, right) =>

View file

@ -1019,9 +1019,14 @@ public sealed class GameWindow :
}
var names = new Dictionary<uint, string>(skillTable.Skills.Count);
var icons = new Dictionary<uint, uint>(skillTable.Skills.Count);
foreach (var entry in skillTable.Skills)
{
names[(uint)entry.Key] = entry.Value.Name;
icons[(uint)entry.Key] = entry.Value.IconId;
}
_automation.BindSkillNames(names);
_automation.BindSkillIcons(icons);
}
void IGameWindowContentEffectsAudioPublication.PublishPreparedAssetSource(

View file

@ -0,0 +1,87 @@
using AcDream.Core.Items;
namespace AcDream.App.UI;
/// <summary>
/// Host seam that lets plugin markup draw real DAT icons (Slice B,
/// <c>docs/plans/2026-09-06-plugin-shelf-and-dat-icons.md</c>) without
/// importing App/UI or Core.Items types across the plugin boundary.
/// <see cref="MarkupDocument.Build"/> accepts an optional instance; when
/// supplied, the <c>&lt;icon&gt;</c> element and the button/list icon
/// extensions route every id through it. See <c>docs/plugin-ui-markup.md</c>
/// for the full grammar.
/// </summary>
public interface IMarkupIconResolver
{
/// <summary>
/// Resolve a raw RenderSurface DID to (GL texture, width, height). The
/// caller (<see cref="MarkupDocument"/>) has already run the id through
/// <see cref="AcDream.Plugin.Abstractions.PluginIcons.Normalize"/>, so
/// implementations need not re-normalize. 0/unresolvable → <c>(0, 0, 0)</c>,
/// which draws nothing.
/// </summary>
(uint tex, int w, int h) ResolveDid(uint did);
/// <summary>
/// Resolve retail's composited spell icon (power-level backing + art +
/// reversed/normal tint + self/fellow overlay — <c>IconComposer.GetSpellIcon</c>,
/// retail <c>ClientMagicSystem::CompositeSpellIcon</c>).
/// </summary>
(uint tex, int w, int h) ResolveSpell(uint spellId);
/// <summary>
/// Resolve retail's composited item icon (type-default underlay + custom
/// underlay + base + custom overlay + effect recolor —
/// <c>IconComposer.GetIcon</c>) for a live object id, reading its icon
/// fields from the SAME <see cref="AcDream.Core.Items.ClientObjectTable"/>
/// the inventory UI already uses.
/// </summary>
(uint tex, int w, int h) ResolveItem(uint objectId);
}
/// <summary>
/// Production <see cref="IMarkupIconResolver"/>. Built once in
/// <see cref="RetailUiRuntime.MountPlugins"/> from
/// <see cref="RetailUiAssets.ResolveSprite"/>, <see cref="RetailUiAssets.Icons"/>
/// (the one <see cref="IconComposer"/> every authored panel shares), and the
/// live <see cref="ClientObjectTable"/> the inventory UI's own icon resolution
/// already borrows (<c>ToolbarRuntimeBindings.Objects</c>/
/// <c>MagicRuntimeBindings.Objects</c> — both <c>d.Inventory.Objects</c>, the
/// same instance) — no second texture cache or object lookup is introduced.
/// </summary>
public sealed class RetailMarkupIconResolver : IMarkupIconResolver
{
private readonly Func<uint, (uint tex, int w, int h)> _resolveSprite;
private readonly IconComposer _icons;
private readonly ClientObjectTable _objects;
public RetailMarkupIconResolver(
Func<uint, (uint tex, int w, int h)> resolveSprite,
IconComposer icons,
ClientObjectTable objects)
{
_resolveSprite = resolveSprite ?? throw new ArgumentNullException(nameof(resolveSprite));
_icons = icons ?? throw new ArgumentNullException(nameof(icons));
_objects = objects ?? throw new ArgumentNullException(nameof(objects));
}
public (uint tex, int w, int h) ResolveDid(uint did) =>
did == 0u ? (0u, 0, 0) : _resolveSprite(did);
public (uint tex, int w, int h) ResolveSpell(uint spellId)
{
if (spellId == 0u) return (0u, 0, 0);
uint tex = _icons.GetSpellIcon(spellId);
return tex == 0u ? (0u, 0, 0) : (tex, 32, 32);
}
public (uint tex, int w, int h) ResolveItem(uint objectId)
{
if (objectId == 0u) return (0u, 0, 0);
ClientObject? item = _objects.Get(objectId);
if (item is null || item.IconId == 0u) return (0u, 0, 0);
uint tex = _icons.GetIcon(
item.Type, item.IconId, item.IconUnderlayId, item.IconOverlayId, item.Effects);
return tex == 0u ? (0u, 0, 0) : (tex, 32, 32);
}
}

View file

@ -3,6 +3,7 @@ using System.Globalization;
using System.Numerics;
using System.Reflection;
using System.Xml.Linq;
using AcDream.Plugin.Abstractions;
namespace AcDream.App.UI;
@ -29,9 +30,19 @@ public static class MarkupDocument
/// their text through the same glyph path as authored panels; without it
/// they fall back to the development bitmap font and look foreign.
/// </param>
/// <param name="icons">
/// Slice B (<c>docs/plans/2026-09-06-plugin-shelf-and-dat-icons.md</c>):
/// resolves <c>&lt;icon&gt;</c>, <c>&lt;button icon&gt;</c>, and
/// <c>&lt;list icons&gt;</c> ids to drawable DAT icons. Null (the
/// default, and what every pre-Slice-B caller still passes) makes those
/// three surfaces resolve to nothing rather than throwing — a panel
/// authored against Slice B markup still loads under a host/test that
/// has not wired icon resolution.
/// </param>
public static UiNineSlicePanel Build(
string xml, object binding, Func<uint, (uint, int, int)> resolve,
ControlsIni? style = null, UiDatFont? datFont = null)
ControlsIni? style = null, UiDatFont? datFont = null,
IMarkupIconResolver? icons = null)
{
var root = XDocument.Parse(xml).Root ?? throw new FormatException("empty markup");
if (root.Name.LocalName != "panel")
@ -80,7 +91,7 @@ public static class MarkupDocument
}
foreach (var el in root.Elements())
AddElement(panel, el, binding, resolve, datFont);
AddElement(panel, el, binding, resolve, datFont, icons);
return panel;
}
@ -89,7 +100,8 @@ public static class MarkupDocument
XElement el,
object binding,
Func<uint, (uint, int, int)> resolve,
UiDatFont? datFont)
UiDatFont? datFont,
IMarkupIconResolver? icons)
{
switch (el.Name.LocalName)
{
@ -114,7 +126,7 @@ public static class MarkupDocument
ApplyCommon(group, el, binding);
parent.AddChild(group);
foreach (XElement child in el.Elements())
AddElement(group, child, binding, resolve, datFont);
AddElement(group, child, binding, resolve, datFont, icons);
break;
case "meter":
@ -200,12 +212,60 @@ public static class MarkupDocument
if (el.Attribute("border") is not null)
button.BorderColor = Color(
(string?)el.Attribute("border"));
// Slice B: <button icon="..." iconkind="did|spell|item">.
string? buttonIcon = (string?)el.Attribute("icon");
if (buttonIcon is not null)
{
Func<uint> buttonIconReader =
BindUintLiteralOrBinding(buttonIcon, binding, "button icon");
button.IconSource = BuildIconSource(
(string?)el.Attribute("iconkind"),
buttonIconReader,
icons);
}
ApplyCommon(button, el, binding);
if (onClick is not null)
button.Click += onClick;
parent.AddChild(button);
break;
case "icon":
{
string? didAttr = (string?)el.Attribute("did");
string? spellAttr = (string?)el.Attribute("spell");
string? itemAttr = (string?)el.Attribute("item");
int sourceCount = (didAttr is not null ? 1 : 0)
+ (spellAttr is not null ? 1 : 0)
+ (itemAttr is not null ? 1 : 0);
if (sourceCount != 1)
{
throw new FormatException(
"<icon> requires exactly one of did/spell/item");
}
string iconKind = didAttr is not null ? "did"
: spellAttr is not null ? "spell"
: "item";
string iconExpression = didAttr ?? spellAttr ?? itemAttr!;
Func<uint> iconReader = BindUintLiteralOrBinding(
iconExpression, binding, $"icon {iconKind}");
var icon = new UiMarkupIcon
{
Left = F(el, "x"),
Top = F(el, "y"),
Width = FOr(el, "w", 32f),
Height = FOr(el, "h", 32f),
IconSource = BuildIconSource(iconKind, iconReader, icons),
};
ApplyCommon(icon, el, binding);
// A tooltip needs this element to be a real hit-test
// target — see UiMarkupIcon's own doc comment.
if (el.Attribute("tooltip") is not null)
icon.ClickThrough = false;
parent.AddChild(icon);
break;
}
case "tab":
string? tabClickName = (string?)el.Attribute("onclick");
Action? tabClick = BindAction(tabClickName, binding);
@ -427,12 +487,109 @@ public static class MarkupDocument
"list selected"),
SelectionChanged = listChanged,
};
// Slice B: <list icons="{IconIds}" iconkind="did|spell|item">.
string? listIcons = (string?)el.Attribute("icons");
if (!string.IsNullOrWhiteSpace(listIcons))
{
list.IconIdsSource = BindUintList(listIcons, binding, "list icons");
list.IconResolve = BuildRowIconResolve(
(string?)el.Attribute("iconkind"), icons);
}
ApplyCommon(list, el, binding);
parent.AddChild(list);
break;
}
}
/// <summary>
/// Builds the zero-argument icon resolver every markup icon sink (the
/// <c>&lt;icon&gt;</c> element and <c>&lt;button icon&gt;</c>) shares:
/// dispatch by <c>iconkind</c> (default <c>"did"</c>) to the matching
/// <see cref="IMarkupIconResolver"/> method, normalizing <c>did</c>
/// through <see cref="PluginIcons.Normalize"/>
/// (spell/item ids are never DAT RenderSurface DIDs, so they never pass
/// through it). Null <paramref name="icons"/> (no resolver wired) always
/// resolves to nothing rather than throwing.
/// </summary>
private static Func<(uint tex, int w, int h)> BuildIconSource(
string? iconKind, Func<uint> idReader, IMarkupIconResolver? icons)
{
if (icons is null)
return static () => (0u, 0, 0);
return (iconKind ?? "did") switch
{
"did" => () => icons.ResolveDid(
PluginIcons.Normalize(idReader())),
"spell" => () => icons.ResolveSpell(idReader()),
"item" => () => icons.ResolveItem(idReader()),
var other => throw new FormatException(
$"unknown iconkind \"{other}\" (expected did, spell, or item)"),
};
}
/// <summary>
/// Same dispatch as <see cref="BuildIconSource"/>, shaped for
/// <c>&lt;list icons&gt;</c>'s per-row resolve (the row's own icon id is
/// the argument rather than a captured reader).
/// </summary>
private static Func<uint, (uint tex, int w, int h)>? BuildRowIconResolve(
string? iconKind, IMarkupIconResolver? icons)
{
if (icons is null)
return null;
return (iconKind ?? "did") switch
{
"did" => id => icons.ResolveDid(
PluginIcons.Normalize(id)),
"spell" => icons.ResolveSpell,
"item" => icons.ResolveItem,
var other => throw new FormatException(
$"unknown iconkind \"{other}\" (expected did, spell, or item)"),
};
}
/// <summary>
/// Resolves a <c>did</c>/<c>spell</c>/<c>item</c> attribute to a live
/// <see cref="uint"/> reader: a <c>{Prop}</c> binding re-reads a
/// <see cref="uint"/> property every frame; a literal accepts hex
/// (<c>0x...</c>) or decimal, matching every other markup id attribute's
/// "malformed literal throws at Build" rule.
/// </summary>
private static Func<uint> BindUintLiteralOrBinding(
string expression, object binding, string context)
{
if (!IsBinding(expression))
{
uint literal = ParseUintLiteral(expression, context);
return () => literal;
}
PropertyInfo? property = binding.GetType().GetProperty(expression[1..^1]);
if (property is null || property.PropertyType != typeof(uint))
{
throw new FormatException(
$"{expression} did not resolve to a uint property on "
+ binding.GetType().Name + $" ({context})");
}
return () => (uint)property.GetValue(binding)!;
}
private static uint ParseUintLiteral(string text, string context)
{
string trimmed = text.Trim();
if (trimmed.StartsWith("0x", StringComparison.OrdinalIgnoreCase))
{
if (uint.TryParse(trimmed.AsSpan(2), NumberStyles.HexNumber,
CultureInfo.InvariantCulture, out uint hex))
return hex;
}
else if (uint.TryParse(trimmed, NumberStyles.Integer,
CultureInfo.InvariantCulture, out uint dec))
{
return dec;
}
throw new FormatException($"{context}=\"{text}\" is not a valid uint literal");
}
/// <summary>
/// Resolves <c>{PropName}</c> to a live string reader, or returns the
/// literal text unchanged. The indirection matters: binding to a

View file

@ -756,7 +756,12 @@ public sealed class PluginSidePanel : UiPanel, IDisposable, IRetainedWindowState
{
_handle = handle;
_resolve = resolve;
_iconSurfaceId = descriptor.IconSurfaceId;
// Slice B (docs/plans/2026-09-06-plugin-shelf-and-dat-icons.md item
// 6): normalize through the shared grammar so a Decal-style bare
// portal index (MosswartMassacre's convention) resolves the same
// way every markup icon sink does, instead of silently drawing
// nothing because it was never a RenderSurface DID to begin with.
_iconSurfaceId = PluginIcons.Normalize(descriptor.IconSurfaceId);
_tooltip = string.Equals(descriptor.Title, ownerDisplayName,
StringComparison.Ordinal)
? descriptor.Title

View file

@ -4712,6 +4712,18 @@ public sealed class RetailUiRuntime : IDisposable
private void MountPlugins()
{
if (_bindings.Plugins is null) return;
// Slice B (docs/plans/2026-09-06-plugin-shelf-and-dat-icons.md item
// 7): one resolver, shared by every plugin panel mounted this pass,
// built from the SAME sprite resolve, IconComposer, and
// ClientObjectTable the rest of the retained UI already uses — see
// RetailMarkupIconResolver's own doc comment for exactly which
// existing bindings field supplies the object table.
IMarkupIconResolver iconResolver = new RetailMarkupIconResolver(
_bindings.Assets.ResolveSprite,
_bindings.Assets.Icons,
_bindings.Toolbar.Objects);
foreach (var panel in _bindings.Plugins.Drain())
{
try
@ -4723,7 +4735,8 @@ public sealed class RetailUiRuntime : IDisposable
panel.Binding,
_bindings.Assets.ResolveSprite,
_bindings.Assets.Controls,
_bindings.Assets.DefaultFont);
_bindings.Assets.DefaultFont,
iconResolver);
if (Host.WindowManager.TryGet(panel.WindowName, out _))
{

View file

@ -0,0 +1,48 @@
using System.Numerics;
namespace AcDream.App.UI;
/// <summary>
/// Plugin markup's <c>&lt;icon&gt;</c> element (Slice B,
/// <c>docs/plans/2026-09-06-plugin-shelf-and-dat-icons.md</c>). Draw-only: a
/// plugin never receives the resolved GL texture, only the resolved-or-not
/// outcome baked into <see cref="IconSource"/> by
/// <see cref="MarkupDocument.Build"/>. Aspect is preserved and the sprite is
/// centered inside the element's box, nearest-filtered (the same convention
/// every other 32x32 DAT icon in the client draws with).
/// </summary>
public sealed class UiMarkupIcon : UiElement
{
/// <summary>
/// Resolves to (GL texture, native width, native height) each draw.
/// <c>tex == 0</c> (or a non-positive extent) draws nothing — never
/// throws, matching every other markup binding's "unresolvable at
/// runtime is silent" rule (only a malformed literal throws, at Build).
/// </summary>
public Func<(uint tex, int w, int h)> IconSource { get; set; } =
static () => (0u, 0, 0);
/// <summary>
/// <see langword="true"/> unless the element has an authored tooltip —
/// a tooltip needs this element to be a real hit-test target (see
/// <see cref="UiElement.ClickThrough"/>'s doc: a click-through element
/// never becomes the hovered element a tooltip attaches to).
/// <see cref="MarkupDocument"/> flips this to <see langword="false"/>
/// when <c>tooltip=</c> is present.
/// </summary>
public UiMarkupIcon() => ClickThrough = true;
protected override void OnDraw(UiRenderContext ctx)
{
(uint tex, int w, int h) = IconSource();
if (tex == 0u || w <= 0 || h <= 0 || Width <= 0f || Height <= 0f)
return;
float scale = MathF.Min(Width / w, Height / h);
float drawWidth = w * scale;
float drawHeight = h * scale;
float x = (Width - drawWidth) * 0.5f;
float y = (Height - drawHeight) * 0.5f;
ctx.DrawSprite(tex, x, y, drawWidth, drawHeight, 0f, 0f, 1f, 1f, Vector4.One);
}
}

View file

@ -14,6 +14,22 @@ public sealed class UiMarkupList : UiElement
static () => Array.Empty<string>();
public Func<IReadOnlyList<uint>> ItemColorsSource { get; set; } =
static () => Array.Empty<uint>();
/// <summary>
/// Plugin markup's <c>&lt;list icons="..."&gt;</c> (Slice B,
/// <c>docs/plans/2026-09-06-plugin-shelf-and-dat-icons.md</c>): one icon
/// id per row, parallel to <see cref="ItemsSource"/>. Null (the default)
/// means no icon column at all — every list built without an <c>icons</c>
/// attribute behaves exactly as before. A row past the end of this list,
/// or an id that resolves to nothing, draws no icon (never a placeholder).
/// </summary>
public Func<IReadOnlyList<uint>>? IconIdsSource { get; set; }
/// <summary>
/// Resolves one <see cref="IconIdsSource"/> entry to a drawable icon.
/// <see cref="MarkupDocument"/> builds this from the same
/// <see cref="IMarkupIconResolver"/> every other markup icon sink uses,
/// selected by the element's <c>iconkind</c> attribute.
/// </summary>
public Func<uint, (uint tex, int w, int h)>? IconResolve { get; set; }
public Func<int> SelectedIndexSource { get; set; } = static () => -1;
public Action<int>? SelectionChanged { get; set; }
public UiDatFont? DatFont { get; set; }
@ -32,6 +48,13 @@ public sealed class UiMarkupList : UiElement
{
IReadOnlyList<string> items = ItemsSource();
IReadOnlyList<uint> itemColors = ItemColorsSource();
IReadOnlyList<uint>? iconIds = IconIdsSource?.Invoke();
// Decal's IconColumn: a leading square per row, RowHeight - 2 wide,
// reserved only while the list actually carries an icons= binding —
// an ordinary text-only list keeps its full-width text column.
float iconColumn = iconIds is not null
? MathF.Max(0f, RowHeight - 2f)
: 0f;
int visibleRows = VisibleRows;
int selected = SelectedIndexSource();
if (selected >= 0 && selected < items.Count)
@ -51,16 +74,40 @@ public sealed class UiMarkupList : UiElement
float y = (index - _topRow) * RowHeight;
if (index == selected)
context.DrawFill(1f, y + 1f, Width - 2f, RowHeight - 1f, SelectedColor);
if (iconIds is not null && index < iconIds.Count && IconResolve is { } resolve)
{
uint iconId = iconIds[index];
if (iconId != 0u)
{
(uint tex, int w, int h) = resolve(iconId);
if (tex != 0u && w > 0 && h > 0)
{
float extent = MathF.Max(0f, iconColumn - 2f);
float scale = MathF.Min(extent / w, extent / h);
float drawWidth = w * scale;
float drawHeight = h * scale;
context.DrawSprite(
tex,
1f + (extent - drawWidth) * 0.5f,
y + (RowHeight - drawHeight) * 0.5f,
drawWidth, drawHeight,
0f, 0f, 1f, 1f, Vector4.One);
}
}
}
string text = items[index];
Vector4 textColor = index < itemColors.Count
? Rgb(itemColors[index])
: TextColor;
float textX = Padding + iconColumn;
float textY = y + MathF.Max(0f,
(RowHeight - (DatFont?.LineHeight ?? 14f)) * 0.5f);
if (DatFont is { } font)
context.DrawStringDat(font, text, Padding, textY, textColor, true);
context.DrawStringDat(font, text, textX, textY, textColor, true);
else
context.DrawString(text, Padding, textY, textColor);
context.DrawString(text, textX, textY, textColor);
}
}

View file

@ -176,6 +176,19 @@ public class UiSimpleButton : UiPanel
/// <summary>Two-plane glyph outline, as retail draws interface text.</summary>
public bool Outline { get; set; } = true;
/// <summary>
/// Plugin markup's <c>&lt;button icon="..."&gt;</c> (Slice B,
/// <c>docs/plans/2026-09-06-plugin-shelf-and-dat-icons.md</c>). Null (the
/// default) draws no icon and behaves exactly as before — every existing
/// caller of this widget (the plugin shelf's own toggle/minimize buttons
/// included) leaves this unset. When set, the icon draws flush left and
/// the caption's centering region shifts right by the reserved icon
/// column, so an icon-and-text button never overlaps them; an empty
/// <see cref="Text"/>/<see cref="TextSource"/> with this set is a valid
/// icon-only button.
/// </summary>
public Func<(uint tex, int w, int h)>? IconSource { get; set; }
public event System.Action? Click;
/// <summary>
@ -208,22 +221,50 @@ public class UiSimpleButton : UiPanel
protected override void OnDraw(UiRenderContext ctx)
{
base.OnDraw(ctx);
// Slice B icon column: reserved only when an icon actually resolved
// this frame, so a bound id that goes to 0 (or the resolver returning
// no texture) collapses back to the un-iconed centering — never a
// permanent blank gap.
float iconColumn = 0f;
if (IconSource is { } iconSource)
{
(uint tex, int w, int h) = iconSource();
if (tex != 0u && w > 0 && h > 0)
{
float extent = MathF.Max(0f, MathF.Min(Width, Height) - 6f);
float scale = MathF.Min(extent / w, extent / h);
float drawWidth = w * scale;
float drawHeight = h * scale;
ctx.DrawSprite(
tex,
3f + (extent - drawWidth) * 0.5f,
(Height - drawHeight) * 0.5f,
drawWidth, drawHeight,
0f, 0f, 1f, 1f, Vector4.One);
iconColumn = extent + 6f;
}
}
string caption = TextSource?.Invoke() ?? Text;
if (caption.Length == 0) return;
float captionAreaX = iconColumn;
float captionAreaWidth = MathF.Max(0f, Width - iconColumn);
if (DatFont is { } dat)
{
float datW = dat.MeasureWidth(caption);
ctx.DrawStringDat(
dat, caption,
(Width - datW) * 0.5f, (Height - dat.LineHeight) * 0.5f,
captionAreaX + (captionAreaWidth - datW) * 0.5f,
(Height - dat.LineHeight) * 0.5f,
TextColor, Outline);
return;
}
if (ctx.DefaultFont is null) return;
float textW = ctx.DefaultFont.MeasureWidth(caption);
float tx = (Width - textW) * 0.5f;
float tx = captionAreaX + (captionAreaWidth - textW) * 0.5f;
float ty = (Height - ctx.DefaultFont.LineHeight) * 0.5f;
ctx.DrawString(caption, tx, ty, TextColor);
}

View file

@ -84,6 +84,17 @@ public readonly record struct PluginSpellInfo(
/// </summary>
public int? QualityOverride { get; init; }
public int Quality => QualityOverride ?? Difficulty;
/// <summary>
/// Raw retail SpellTable (portal.dat <c>0x0E00000E</c>) icon RenderSurface
/// DID — Decal's <c>SpellTable.GetById(id).Icon</c>. This is the spell's
/// OWN art asset id, distinct from the composited icon a plugin markup
/// <c>&lt;icon spell="..."&gt;</c>/<c>iconkind="spell"</c> draws (which
/// layers power-level backing + tint + self/fellow overlay on top of it,
/// matching retail's <c>ClientMagicSystem::CompositeSpellIcon</c>) — a
/// plugin that wants the plain art tile rather than the composited badge
/// draws this id directly through <c>iconkind="did"</c>. 0 when unknown.
/// </summary>
public uint IconId { get; init; }
}
/// <summary>One enchantment currently in force on the local player.</summary>
@ -115,6 +126,19 @@ public readonly record struct PluginSkillInfo(
{
/// <summary>Unenchanted retail skill level before vitae and spell mods.</summary>
public uint Base { get; init; } = Current;
/// <summary>
/// Retail SkillTable (portal.dat <c>0x0E000004</c>) icon RenderSurface
/// DID — the <c>SkillBase.IconId</c> field (Chorizite.DatReaderWriter
/// <c>DatReaderWriter.Types.SkillBase</c>; verified via reflection over
/// the installed package, since the XML doc comments don't cover it: the
/// type carries public fields <c>Description</c>, <c>Name</c>,
/// <c>IconId</c> (uint), <c>TrainedCost</c>, <c>SpecializedCost</c>,
/// <c>Category</c>, <c>ChargenUse</c>, <c>MinLevel</c>, <c>Formula</c>,
/// <c>UpperBound</c>, <c>LowerBound</c>, <c>LearnMod</c> — no separate
/// XML-doc member exists because these are Pack/Unpack-generated public
/// fields, not properties). 0 when unknown.
/// </summary>
public uint IconId { get; init; }
}
/// <summary>One primary attribute. <paramref name="Kind"/> is 0..5.</summary>

View file

@ -91,6 +91,13 @@ public readonly record struct PluginInventoryItem(
public PluginObjectClass ObjectClass { get; init; }
public IReadOnlyList<PluginPaletteInfo> Palettes { get; init; } =
Array.Empty<PluginPaletteInfo>();
/// <summary>
/// Retail <c>ClientObject.IconId</c> RenderSurface DID (base icon, before
/// the type-default underlay/custom-underlay/overlay/effect compositing
/// a plugin markup <c>iconkind="item"</c> draws through
/// <c>IconComposer.GetIcon</c>). 0 when unknown.
/// </summary>
public uint IconId { get; init; }
}
/// <summary>

View file

@ -0,0 +1,49 @@
namespace AcDream.Plugin.Abstractions;
/// <summary>
/// The one icon-id grammar every host icon sink normalizes through
/// (descriptor <see cref="PluginPanelDescriptor.IconSurfaceId"/>, plugin
/// markup's <c>&lt;icon did&gt;</c>, <c>&lt;button icon&gt;</c>, and
/// <c>&lt;list icons&gt;</c>). Decal/VirindiViewService plugins (the
/// reference usage: MosswartMassacre's <c>HudPictureBox.Image</c> assignments)
/// hand out bare portal.dat indices — small integers below the
/// <c>0x06xxxxxx</c> RenderSurface DID range, the same numbers Decal's
/// <c>FileService.SpellTable</c>/<c>SkillTable</c> icon columns return. A
/// host that draws those literally as RenderSurface DIDs resolves nothing.
/// </summary>
/// <remarks>
/// This grammar is deliberately host-side and applied at the SINK, not at
/// every plugin-facing record: <see cref="PluginSpellInfo.IconId"/>,
/// <see cref="PluginSkillInfo.IconId"/>, <see cref="PluginInventoryItem.IconId"/>
/// and <see cref="PluginWorldObject.IconId"/> already carry full retail
/// RenderSurface DIDs (read straight from the client's SpellTable/SkillTable/
/// object state), so normalizing them again would be a no-op — but a plugin
/// author who only has a Decal-style bare index (typed literally in markup,
/// or echoed from an external metadata source) still needs the same
/// conversion, which is exactly what happens at the markup <c>did</c> sink.
/// </remarks>
public static class PluginIcons
{
/// <summary>
/// The first RenderSurface DID range (portal.dat's <c>0x06000000</c>
/// block). Any value below this is treated as a bare index needing the
/// block prefix; any value at or above it is assumed to already be a
/// resolvable DID (RenderSurface or otherwise) and is returned unchanged.
/// </summary>
private const uint RenderSurfaceBlock = 0x06000000u;
/// <summary>
/// Normalizes one plugin-supplied icon id: <c>0</c> stays <c>0</c> (no
/// icon); a bare index below <see cref="RenderSurfaceBlock"/> becomes
/// <c>0x06000000 + value</c> (Decal/VVS convention); anything else is
/// returned unchanged (already a full DID, of whatever DBTYPE the caller
/// intends — the host resolves it against the space its own sink
/// expects).
/// </summary>
public static uint Normalize(uint idOrIndex) =>
idOrIndex == 0u
? 0u
: idOrIndex < RenderSurfaceBlock
? RenderSurfaceBlock + idOrIndex
: idOrIndex;
}

View file

@ -83,6 +83,12 @@ public readonly record struct PluginWorldObject(
public int ContainersCapacity { get; init; }
public IReadOnlyList<uint> SpellIds { get; init; } = Array.Empty<uint>();
public IReadOnlyList<uint> ActiveSpellIds { get; init; } = Array.Empty<uint>();
/// <summary>
/// Retail <c>ClientObject.IconId</c> RenderSurface DID (base icon, before
/// compositing). Same id a plugin markup <c>iconkind="item"</c> resolves
/// through <c>IconComposer.GetIcon</c>. 0 when unknown.
/// </summary>
public uint IconId { get; init; }
}
/// <summary>

View file

@ -0,0 +1,116 @@
using AcDream.Plugin.Abstractions;
namespace AcDream.Plugins.Smoke;
/// <summary>
/// Slice B proof panel (<c>docs/plans/2026-09-06-plugin-shelf-and-dat-icons.md</c>
/// item 9): exercises every new plugin-markup icon surface in one place —
/// registered from in-memory KSML via <see cref="IUiRegistry.RegisterPanelContent"/>
/// (no plugin-side XML file), with the descriptor's own
/// <see cref="PluginPanelDescriptor.IconSurfaceId"/> also set to the bare
/// Decal-style index used below, so the shelf button and the panel's own
/// <c>&lt;icon did="7735"&gt;</c> prove
/// <see cref="PluginIcons.Normalize"/> the same way at both sinks.
/// </summary>
internal static class SmokeIconPanel
{
/// <summary>
/// A Decal/VirindiViewService-style bare portal.dat index (MosswartMassacre's
/// convention — see the plan's "Why" section) rather than a full
/// <c>0x06xxxxxx</c> RenderSurface DID. Deliberately used on BOTH the
/// descriptor and the first <c>&lt;icon&gt;</c> below to prove the host
/// normalizes it identically at each sink.
/// </summary>
public const uint BareIndexIconId = 7735u;
/// <summary>A literal, already-normalized RenderSurface DID.</summary>
private const uint LiteralDidIconId = 0x06002D14u;
/// <summary>
/// Retail's Strength Other I — the plan's named fallback when the local
/// character has not learned any self-castable buff yet.
/// </summary>
private const uint FallbackSpellId = 1u;
public static readonly PluginPanelDescriptor Descriptor = new("icons", "Icon Smoke")
{
IconSurfaceId = BareIndexIconId,
StartVisible = false,
ShowInSidePanel = true,
};
public const string Markup = """
<panel x="60" y="60" w="280" h="220" title="Icon Smoke">
<icon x="12" y="28" w="32" h="32" did="7735" tooltip="bare Decal index"/>
<icon x="52" y="28" w="32" h="32" did="0x06002D14" tooltip="literal RenderSurface DID"/>
<icon x="92" y="28" w="32" h="32" spell="{SpellId}" tooltip="composited spell icon"/>
<button x="12" y="68" w="120" h="24" text="Report" icon="0x06002D14" onclick="{Report}"/>
<list x="12" y="100" w="256" h="108" items="{SpellRows}" icons="{SpellIconIds}"
iconkind="spell" selected="{SelectedIndex}"/>
</panel>
""";
/// <summary>Binding object for <see cref="Markup"/>. Reads live host state
/// on every frame the same way any BCL-only plugin binding would.</summary>
internal sealed class Binding
{
private readonly IPluginHost _host;
public Binding(IPluginHost host) => _host = host;
public int SelectedIndex { get; set; } = -1;
/// <summary>The first spell a plugin markup <c>&lt;icon spell=...&gt;</c>
/// draws — the character's first known self-buff, falling back to
/// <see cref="FallbackSpellId"/> (Strength Other I) when nothing is
/// learned yet (fresh character, or no live session).</summary>
public uint SpellId
{
get
{
IReadOnlyList<PluginSpellInfo> known = _host.Automation.Spells.KnownSelfBuffs;
return known.Count > 0 ? known[0].SpellId : FallbackSpellId;
}
}
/// <summary>Parallel icon-id column for <see cref="SpellRows"/>: the
/// first five known self-buffs' raw SpellTable icon DIDs.</summary>
public IEnumerable<uint> SpellIconIds
{
get
{
var ids = new List<uint>();
foreach (PluginSpellInfo spell in _host.Automation.Spells.KnownSelfBuffs)
{
ids.Add(spell.IconId);
if (ids.Count == 5)
break;
}
return ids;
}
}
/// <summary>Row text for the first five known self-buffs, with each
/// spell's <see cref="PluginSpellInfo.IconId"/> printed alongside its
/// name so the icon column and the raw id are both visible in one
/// look.</summary>
public IEnumerable<string> SpellRows
{
get
{
var rows = new List<string>();
foreach (PluginSpellInfo spell in _host.Automation.Spells.KnownSelfBuffs)
{
rows.Add($"{spell.Name} (icon 0x{spell.IconId:X8})");
if (rows.Count == 5)
break;
}
return rows;
}
}
public Action Report =>
() => _host.Log.Info(
$"smoke icon panel: SpellId={SpellId:X8}, {SpellRows.Count()} spellbook rows");
}
}

View file

@ -6,6 +6,7 @@ public sealed class SmokePlugin : IAcDreamPlugin
{
private IPluginHost? _host;
private int _entitiesSeen;
private IDisposable? _iconPanel;
public void Initialize(IPluginHost host)
{
@ -20,6 +21,14 @@ public sealed class SmokePlugin : IAcDreamPlugin
{
_host.Events.EntitySpawned += OnEntitySpawned;
_host.Log.Info($"smoke plugin sees {_entitiesSeen} entities (replay count at subscribe)");
// Slice B proof panel (docs/plans/2026-09-06-plugin-shelf-and-dat-icons.md
// item 9): in-memory KSML, no plugin-side XML file, exercising
// every new icon markup surface.
_iconPanel = _host.Ui.RegisterPanelContent(
SmokeIconPanel.Descriptor,
SmokeIconPanel.Markup,
new SmokeIconPanel.Binding(_host));
}
}
@ -27,6 +36,8 @@ public sealed class SmokePlugin : IAcDreamPlugin
{
if (_host is not null)
_host.Events.EntitySpawned -= OnEntitySpawned;
_iconPanel?.Dispose();
_iconPanel = null;
_host?.Log.Info($"smoke plugin disabled (saw {_entitiesSeen} entities total)");
}