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

@ -92,6 +92,11 @@ document in the same change; do not leave both claims standing.
before trusting any measurement. Enforced by before trusting any measurement. Enforced by
`LaunchOptionsDocumentationTests`: a flag without a row fails the build, and `LaunchOptionsDocumentationTests`: a flag without a row fails the build, and
so does a row whose read site was deleted. so does a row whose read site was deleted.
- [`plugin-ui-markup.md`](plugin-ui-markup.md) is the SSOT for the plugin
markup vocabulary (elements, attributes, `{Binding}` rules) plus the
Decal/VirindiViewService-compatible DAT icon grammar (Slice B,
`plans/2026-09-06-plugin-shelf-and-dat-icons.md`) and the movable/
collapsible plugin shelf (Slice A of the same plan).
- [`audit/`](audit/) contains completion and conformance audits. - [`audit/`](audit/) contains completion and conformance audits.
- [`reference/ace-commands.md`](reference/ace-commands.md) preserves the local - [`reference/ace-commands.md`](reference/ace-commands.md) preserves the local
ACE server's complete in-game command catalog and points to the authoritative ACE server's complete in-game command catalog and points to the authoritative

View file

@ -203,9 +203,18 @@ plugin. No-window hosts retain the plugin session but expose the no-op UI
capability. capability.
The retained markup vocabulary includes panels, nested groups, labels, The retained markup vocabulary includes panels, nested groups, labels,
buttons, meters, tabs, lamp-style toggles, and scalar sliders. Controls bind to buttons, meters, tabs, lamp-style toggles, scalar sliders, editable fields,
BCL-visible properties/actions on the plugin binding object; visible controls dropdown menus, scrollable lists, and (Slice B,
must correspond to real behavior, never placeholders that report success. `docs/plans/2026-09-06-plugin-shelf-and-dat-icons.md`) DAT icons — a standalone
`<icon>` element plus icon-bearing extensions of `<button>` and `<list>`
resolved from a Decal/VirindiViewService-compatible icon-id grammar
(`AcDream.Plugin.Abstractions.PluginIcons.Normalize`) against raw RenderSurface
DIDs, retail's composited spell icon, or a live object's composited item icon.
Controls bind to BCL-visible properties/actions on the plugin binding object;
visible controls must correspond to real behavior, never placeholders that
report success. Full grammar and binding rules: `docs/plugin-ui-markup.md`.
The Slice A movable/collapsible plugin shelf (`plugin-shelf`, drag grip,
Shift+Ctrl+F1 hide/show) is documented there too.
The following was the original pre-D.2b proposal and remains historical The following was the original pre-D.2b proposal and remains historical
context, not the shipped plugin contract: context, not the shipped plugin contract:

230
docs/plugin-ui-markup.md Normal file
View file

@ -0,0 +1,230 @@
# Plugin UI markup
SSOT for `AcDream.Plugin.Abstractions.IUiRegistry`'s markup vocabulary — every
element and attribute a plugin can put in the KSML-style XML it hands the host
via `AddPanel`/`RegisterPanel`/`RegisterPanelContent`, the `{Binding}` rules
those attributes follow, the DAT-icon grammar (Slice B), and the movable
plugin shelf (Slice A). Both slices are recorded in
[`plans/2026-09-06-plugin-shelf-and-dat-icons.md`](plans/2026-09-06-plugin-shelf-and-dat-icons.md);
this page is the day-to-day reference for writing a panel, that plan is the
design record.
Plugins stay BCL-only: nothing in `AcDream.Plugin.Abstractions` references
App/UI or Core.Items types. A plugin hands the host raw ids (spell ids,
object guids, DAT indices); the host owns every texture, every composited
icon, and the parser that turns markup into a live `UiElement` tree
(`AcDream.App.UI.MarkupDocument`).
## Registering a panel
```csharp
host.Ui.AddPanel(
new PluginPanelDescriptor("main", "MossTank")
{
IconText = "MT", // fallback initials if IconSurfaceId is 0
IconSurfaceId = 7735, // Decal-style bare index OR a full DID — both normalize
StartVisible = true,
ShowInSidePanel = true,
},
Path.Combine(pluginDirectory, "mosstank.xml"),
binding);
```
`RegisterPanel` (same signature, returns `IDisposable`) removes the window
independently of the plugin's own lifetime. `RegisterPanelContent` takes an
in-memory KSML string instead of a file path — the route `AcDream.Plugins.Smoke`
uses for its icon-surface proof panel (`SmokeIconPanel.cs`), when a panel is
small enough not to need its own shipped `.xml` asset.
Every registered window gets a stable persisted key
(`plugin:{pluginId}:{windowId}`), drag, resize (where the markup opts in),
the global UI lock, and a button in the shared plugin shelf
(`ShowInSidePanel = true`, the default). Hiding or minimizing a window never
disables the plugin or pauses its `Tick`.
## The `{Binding}` rule
Every attribute that isn't a plain literal is either:
- a **literal** — a number, color, or string typed directly in the markup, or
- a **binding**`{PropertyName}`, resolved once at `Build` time against the
binding object's public properties/`Action`/`Action<T>` members via
reflection, then **re-read every frame** through a `Func<T>` (or invoked
live for actions). A plugin updates its panel by assigning a property; it
never touches `UiElement` objects directly, and never from a thread other
than the one that calls `Tick`.
A binding that resolves to the wrong CLR type, or names a property that
doesn't exist, throws `FormatException` **at `Build`** — the same moment any
other malformed attribute throws — never silently at draw time. A resolved
binding that returns an out-of-range or default value (0, empty, null) at
*runtime* draws nothing/looks empty; it never throws after the panel has
loaded.
## Elements
| Element | Purpose | Key attributes |
|---|---|---|
| `panel` (root) | The window itself | `x y w h title resize visible` |
| `group` | Transparent layout container | `x y w h background border visible` |
| `label` | Static or bound text | `x y text color` |
| `button` | Clickable rect + caption (+ Slice B icon) | `x y w h text color background border onclick icon iconkind` |
| `icon` | Slice B: a standalone DAT icon | `x y w h did spell item tooltip` |
| `meter` | Retail-style nine-slice bar | `x y w h fill cur max color anchor backleft/backtile/backright frontleft/fronttile/frontright` |
| `tab` | Selectable tab button | `x y w h text selected onclick` |
| `toggle` | Lamp-style checkbox | `x y w h text checked onclick color` |
| `slider` | Horizontal scalar | `x y w h value onchange` |
| `field` | Single-line editable text | `x y w h text maxlength clearonsubmit onchange onsubmit color background` |
| `menu` | Dropdown selector | `x y w h items selected onchange rows rowheight openupward` |
| `list` | Scrollable row list (+ Slice B icon column) | `x y w h items colors selected onchange rowheight icons iconkind` |
Common to every element via `ApplyCommon`: `name`/`id` (a stable control
name), `visible` (literal `true`/`false` or a bound `bool` property),
`enabled` (same rule), and `tooltip` (a literal string or `{Binding}` shown
through retail's own runtime tooltip popup).
## The icon-id grammar (Slice B)
Decal/VirindiViewService plugins (the reference usage this ported:
MosswartMassacre's `HudPictureBox.Image` assignments, fed from Decal's
`FileService.SpellTable`/`SkillTable` icon columns) hand out **bare portal.dat
indices** — small integers, not full `0x06xxxxxx` RenderSurface DIDs. acdream's
host normalizes every icon id through one function so both styles work
everywhere an icon id is accepted:
```csharp
// AcDream.Plugin.Abstractions.PluginIcons
static uint Normalize(uint idOrIndex);
// 0 -> 0 (no icon)
// 7735 -> 0x06001E37 (bare index -> RenderSurface DID)
// 0x06002D14 -> 0x06002D14 (already a DID, unchanged)
```
The host applies `Normalize` at **every** `did`-shaped sink: the descriptor's
`IconSurfaceId` (drawn on the plugin shelf button), and every `<icon did>` /
`<button icon>` (`iconkind="did"`) / `<list icons>` (`iconkind="did"`) value —
literal or bound, re-normalized every frame for a bound value. A plugin never
needs to call `Normalize` itself; handing the host either a Decal-style index
or a full DID produces the same drawn icon.
Plugin-facing records that already carry full retail RenderSurface DIDs
(`PluginSpellInfo.IconId`, `PluginSkillInfo.IconId`,
`PluginInventoryItem.IconId`, `PluginWorldObject.IconId`) are **not**
re-normalized — they are already in DID space, read straight from the
client's SpellTable/SkillTable/object state. `Normalize` only matters at a
markup `did` sink, where a plugin author might type a bare index by hand.
## The three icon sources
Every icon-bearing attribute (`<icon>`'s `did`/`spell`/`item`, `<button icon>`,
`<list icons>`) resolves through one of three sources, selected by which
attribute is set (`<icon>`) or by `iconkind` (`<button>`/`<list>`, default
`"did"`):
| Source | What it draws | Backing API |
|---|---|---|
| `did` | The raw RenderSurface art at that DID, nothing composited on top | `IMarkupIconResolver.ResolveDid` (a plain sprite resolve, after `PluginIcons.Normalize`) |
| `spell` | Retail's **composited** spell icon: power-level backing + spell art + reversed/normal tint + self/fellow-targeted overlay | `IconComposer.GetSpellIcon` (retail `ClientMagicSystem::CompositeSpellIcon`) |
| `item` | Retail's **composited** item icon for a *live* object id: type-default underlay + custom underlay + base icon + custom overlay + effect recolor | `IconComposer.GetIcon`, reading the id's fields from the same `ClientObjectTable` the inventory UI already uses |
`did` accepts a literal (`did="7735"` decimal, or `did="0x06002D14"` hex) or a
binding (`did="{IconDid}"`, a `uint` property re-read every frame). `spell`
and `item` are almost always bindings (`spell="{SpellId}"`,
`item="{ObjectId}"`) but accept the same literal grammar. Any of the three
resolving to 0, or the resolver returning no texture, draws nothing — never a
placeholder, never a throw.
### `<icon>`
```xml
<icon x="8" y="8" w="32" h="32" did="7735" tooltip="Decal-style index"/>
<icon x="48" y="8" w="32" h="32" did="0x06002D14"/>
<icon x="88" y="8" w="32" h="32" spell="{SpellId}" tooltip="{SpellName}"/>
```
Exactly one of `did`/`spell`/`item` must be present — two sources on one
`<icon>` throws `FormatException` at `Build`. `w`/`h` default to 32 (retail's
standard icon size) when omitted. The sprite is drawn nearest-filtered,
aspect-preserved, and centered inside the `w`×`h` box — a non-square source
never stretches. A `tooltip` attribute makes the icon a real hit-test target
(it is click-through otherwise, so it never steals clicks meant for something
underneath it).
### `<button icon="..." iconkind="did|spell|item">`
```xml
<button x="12" y="68" w="120" h="24" text="Report"
icon="0x06002D14" onclick="{Report}"/>
```
The icon draws flush left inside the button; the caption's centering region
shifts right to make room. `text` may be empty for an icon-only button.
`iconkind` defaults to `"did"`.
### `<list icons="{IconIds}" iconkind="did|spell|item">`
```xml
<list x="12" y="100" w="256" h="108"
items="{SpellRows}" icons="{SpellIconIds}" iconkind="spell"
selected="{SelectedIndex}"/>
```
`icons` is an `IEnumerable<uint>` binding parallel to `items` — Decal's
`IconColumn` convention: a leading square column, `RowHeight - 2` pixels wide,
one icon per row. A row past the end of the icons list, or an id that
resolves to nothing, draws no icon for that row (the text still draws, just
without an icon). Omitting `icons` entirely keeps the list exactly as it was
before Slice B (full-width text, no column).
MosswartMassacre-style example — a list column fed straight from
`PluginSpellInfo.IconId`:
```csharp
public IEnumerable<uint> SpellIconIds =>
host.Automation.Spells.KnownSelfBuffs.Select(s => s.IconId);
public IEnumerable<string> SpellRows =>
host.Automation.Spells.KnownSelfBuffs.Select(s => s.Name);
```
```xml
<list items="{SpellRows}" icons="{SpellIconIds}" iconkind="did" .../>
```
(`did`, not `spell`, here — `PluginSpellInfo.IconId` is the spell's own raw art
tile; use `iconkind="spell"` only when the binding hands the host a **spell
id** and wants retail's composited badge instead of the plain art.)
## The plugin shelf (Slice A)
The shelf (`AcDream.App.UI.PluginSidePanel`) is the right-edge strip of
per-plugin-window buttons. It is a real retained window
(`RetailWindowManager` key `plugin-shelf`), so it gets drag, the global UI
lock, and persisted position/visibility/collapsed state for free, exactly
like every other window.
- **Drag**: a grip strip across its top (three short dashes) is the move
handle. Dragging elsewhere on the shelf (the buttons themselves, the
padding between them) does not move the window.
- **Collapse**: a small `»`/`«` toggle at the grip's right end shrinks the
shelf to just the grip; button entries stay laid out underneath so
expanding is instant. Persists through the same window-state channel as
position/visibility.
- **Hide/show**: `Shift+Ctrl+F1` (retail's plugin-manager chord,
`InputAction.TogglePluginManager` — acdream has no separate plugin manager,
so this is its honest home). Hiding the shelf never disables a plugin or
touches any individual plugin window's own visibility; a new plugin window
registering while the shelf is hidden does not un-hide it. If no plugin has
registered a shelf entry yet, the chord reports "No plugin windows are
registered." instead.
- **Default dock**: with no saved layout, the shelf sits at the right screen
edge, top 116px — until the user drags it (or a saved layout restores a
different position), after which it stays put and growth preserves whatever
corner it's anchored from.
## Testing conventions
`MarkupDocumentTests`/`MarkupIconTests` build panels with a fake
`resolve`/`IMarkupIconResolver` (`_ => (1u, 32, 32)` for sprites; a small
in-test class recording which id/kind it was asked to resolve) rather than a
live DAT — see `tests/AcDream.App.Tests/UI/`. `PluginSidePanelTests` exercises
the shelf's drag/collapse/hide/persistence behavior against a bare `UiRoot`.

View file

@ -64,6 +64,8 @@ internal sealed class AppAutomationSurface
private MagicCatalog _magicCatalog = MagicCatalog.Empty; private MagicCatalog _magicCatalog = MagicCatalog.Empty;
private IReadOnlyDictionary<uint, string> _skillNames = private IReadOnlyDictionary<uint, string> _skillNames =
new Dictionary<uint, string>(); new Dictionary<uint, string>();
private IReadOnlyDictionary<uint, uint> _skillIcons =
new Dictionary<uint, uint>();
private Func<int, string> _speciesName = static _ => string.Empty; private Func<int, string> _speciesName = static _ => string.Empty;
private IChargenPaletteColorSource? _paletteColors; private IChargenPaletteColorSource? _paletteColors;
private Func<uint, uint, bool>? _equip; private Func<uint, uint, bool>? _equip;
@ -381,6 +383,18 @@ internal sealed class AppAutomationSurface
_skillNames = skillNames; _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> /// <summary>Supply the immutable retail spell/component DAT catalog.</summary>
public void BindMagicCatalog(MagicCatalog catalog) public void BindMagicCatalog(MagicCatalog catalog)
{ {
@ -766,6 +780,7 @@ internal sealed class AppAutomationSurface
BaseRangeConstant = meta.BaseRangeConstant, BaseRangeConstant = meta.BaseRangeConstant,
BaseRangeModifier = meta.BaseRangeModifier, BaseRangeModifier = meta.BaseRangeModifier,
FormulaComponentIds = meta.FormulaComponents, FormulaComponentIds = meta.FormulaComponents,
IconId = meta.IconId,
}; };
/// <summary> /// <summary>
@ -929,10 +944,12 @@ internal sealed class AppAutomationSurface
{ {
RuntimeCharacterState? character; RuntimeCharacterState? character;
IReadOnlyDictionary<uint, string> names; IReadOnlyDictionary<uint, string> names;
IReadOnlyDictionary<uint, uint> icons;
lock (_gate) lock (_gate)
{ {
character = _character; character = _character;
names = _skillNames; names = _skillNames;
icons = _skillIcons;
} }
if (character is null || names.Count == 0) if (character is null || names.Count == 0)
return Array.Empty<PluginSkillInfo>(); return Array.Empty<PluginSkillInfo>();
@ -940,7 +957,8 @@ internal sealed class AppAutomationSurface
var built = new List<PluginSkillInfo>(names.Count); var built = new List<PluginSkillInfo>(names.Count);
foreach (KeyValuePair<uint, string> pair in names) 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.Add(skill);
} }
built.Sort(static (a, b) => string.CompareOrdinal(a.Name, b.Name)); built.Sort(static (a, b) => string.CompareOrdinal(a.Name, b.Name));
@ -952,22 +970,25 @@ internal sealed class AppAutomationSurface
{ {
RuntimeCharacterState? character; RuntimeCharacterState? character;
IReadOnlyDictionary<uint, string> names; IReadOnlyDictionary<uint, string> names;
IReadOnlyDictionary<uint, uint> icons;
lock (_gate) lock (_gate)
{ {
character = _character; character = _character;
names = _skillNames; names = _skillNames;
icons = _skillIcons;
} }
if (character is not null) if (character is not null)
{ {
string name = names.TryGetValue(skillId, out string? n) ? n : string.Empty; 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; skill = default;
return false; return false;
} }
private static bool TryProjectSkill( private static bool TryProjectSkill(
RuntimeCharacterState character, uint skillId, string name, RuntimeCharacterState character, uint skillId, string name, uint iconId,
out PluginSkillInfo skill) out PluginSkillInfo skill)
{ {
if (!character.View.TryGetSkill(skillId, out var snapshot)) if (!character.View.TryGetSkill(skillId, out var snapshot))
@ -984,6 +1005,7 @@ internal sealed class AppAutomationSurface
skillId, name, Training(snapshot.Status), currentLevel) skillId, name, Training(snapshot.Status), currentLevel)
{ {
Base = baseLevel, Base = baseLevel,
IconId = iconId,
}; };
return true; return true;
} }
@ -1867,6 +1889,7 @@ internal sealed class AppAutomationSurface
? item.AppraisedSpellIds.ToArray() ? item.AppraisedSpellIds.ToArray()
: Array.Empty<uint>(), : Array.Empty<uint>(),
ActiveSpellIds = activeSpells, ActiveSpellIds = activeSpells,
IconId = item?.IconId ?? 0u,
}; };
} }
@ -2376,6 +2399,7 @@ internal sealed class AppAutomationSurface
MaterialType = item.MaterialType ?? 0u, MaterialType = item.MaterialType ?? 0u,
ObjectClass = ClassifyObject(item), ObjectClass = ClassifyObject(item),
Palettes = ProjectPalettes(runtime, item.ObjectId), Palettes = ProjectPalettes(runtime, item.ObjectId),
IconId = item.IconId,
}); });
} }
built.Sort(static (left, right) => 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 names = new Dictionary<uint, string>(skillTable.Skills.Count);
var icons = new Dictionary<uint, uint>(skillTable.Skills.Count);
foreach (var entry in skillTable.Skills) foreach (var entry in skillTable.Skills)
{
names[(uint)entry.Key] = entry.Value.Name; names[(uint)entry.Key] = entry.Value.Name;
icons[(uint)entry.Key] = entry.Value.IconId;
}
_automation.BindSkillNames(names); _automation.BindSkillNames(names);
_automation.BindSkillIcons(icons);
} }
void IGameWindowContentEffectsAudioPublication.PublishPreparedAssetSource( 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.Numerics;
using System.Reflection; using System.Reflection;
using System.Xml.Linq; using System.Xml.Linq;
using AcDream.Plugin.Abstractions;
namespace AcDream.App.UI; namespace AcDream.App.UI;
@ -29,9 +30,19 @@ public static class MarkupDocument
/// their text through the same glyph path as authored panels; without it /// their text through the same glyph path as authored panels; without it
/// they fall back to the development bitmap font and look foreign. /// they fall back to the development bitmap font and look foreign.
/// </param> /// </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( public static UiNineSlicePanel Build(
string xml, object binding, Func<uint, (uint, int, int)> resolve, 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"); var root = XDocument.Parse(xml).Root ?? throw new FormatException("empty markup");
if (root.Name.LocalName != "panel") if (root.Name.LocalName != "panel")
@ -80,7 +91,7 @@ public static class MarkupDocument
} }
foreach (var el in root.Elements()) foreach (var el in root.Elements())
AddElement(panel, el, binding, resolve, datFont); AddElement(panel, el, binding, resolve, datFont, icons);
return panel; return panel;
} }
@ -89,7 +100,8 @@ public static class MarkupDocument
XElement el, XElement el,
object binding, object binding,
Func<uint, (uint, int, int)> resolve, Func<uint, (uint, int, int)> resolve,
UiDatFont? datFont) UiDatFont? datFont,
IMarkupIconResolver? icons)
{ {
switch (el.Name.LocalName) switch (el.Name.LocalName)
{ {
@ -114,7 +126,7 @@ public static class MarkupDocument
ApplyCommon(group, el, binding); ApplyCommon(group, el, binding);
parent.AddChild(group); parent.AddChild(group);
foreach (XElement child in el.Elements()) foreach (XElement child in el.Elements())
AddElement(group, child, binding, resolve, datFont); AddElement(group, child, binding, resolve, datFont, icons);
break; break;
case "meter": case "meter":
@ -200,12 +212,60 @@ public static class MarkupDocument
if (el.Attribute("border") is not null) if (el.Attribute("border") is not null)
button.BorderColor = Color( button.BorderColor = Color(
(string?)el.Attribute("border")); (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); ApplyCommon(button, el, binding);
if (onClick is not null) if (onClick is not null)
button.Click += onClick; button.Click += onClick;
parent.AddChild(button); parent.AddChild(button);
break; 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": case "tab":
string? tabClickName = (string?)el.Attribute("onclick"); string? tabClickName = (string?)el.Attribute("onclick");
Action? tabClick = BindAction(tabClickName, binding); Action? tabClick = BindAction(tabClickName, binding);
@ -427,12 +487,109 @@ public static class MarkupDocument
"list selected"), "list selected"),
SelectionChanged = listChanged, 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); ApplyCommon(list, el, binding);
parent.AddChild(list); parent.AddChild(list);
break; 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> /// <summary>
/// Resolves <c>{PropName}</c> to a live string reader, or returns the /// Resolves <c>{PropName}</c> to a live string reader, or returns the
/// literal text unchanged. The indirection matters: binding to a /// literal text unchanged. The indirection matters: binding to a

View file

@ -756,7 +756,12 @@ public sealed class PluginSidePanel : UiPanel, IDisposable, IRetainedWindowState
{ {
_handle = handle; _handle = handle;
_resolve = resolve; _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, _tooltip = string.Equals(descriptor.Title, ownerDisplayName,
StringComparison.Ordinal) StringComparison.Ordinal)
? descriptor.Title ? descriptor.Title

View file

@ -4712,6 +4712,18 @@ public sealed class RetailUiRuntime : IDisposable
private void MountPlugins() private void MountPlugins()
{ {
if (_bindings.Plugins is null) return; 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()) foreach (var panel in _bindings.Plugins.Drain())
{ {
try try
@ -4723,7 +4735,8 @@ public sealed class RetailUiRuntime : IDisposable
panel.Binding, panel.Binding,
_bindings.Assets.ResolveSprite, _bindings.Assets.ResolveSprite,
_bindings.Assets.Controls, _bindings.Assets.Controls,
_bindings.Assets.DefaultFont); _bindings.Assets.DefaultFont,
iconResolver);
if (Host.WindowManager.TryGet(panel.WindowName, out _)) 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>(); static () => Array.Empty<string>();
public Func<IReadOnlyList<uint>> ItemColorsSource { get; set; } = public Func<IReadOnlyList<uint>> ItemColorsSource { get; set; } =
static () => Array.Empty<uint>(); 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 Func<int> SelectedIndexSource { get; set; } = static () => -1;
public Action<int>? SelectionChanged { get; set; } public Action<int>? SelectionChanged { get; set; }
public UiDatFont? DatFont { get; set; } public UiDatFont? DatFont { get; set; }
@ -32,6 +48,13 @@ public sealed class UiMarkupList : UiElement
{ {
IReadOnlyList<string> items = ItemsSource(); IReadOnlyList<string> items = ItemsSource();
IReadOnlyList<uint> itemColors = ItemColorsSource(); 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 visibleRows = VisibleRows;
int selected = SelectedIndexSource(); int selected = SelectedIndexSource();
if (selected >= 0 && selected < items.Count) if (selected >= 0 && selected < items.Count)
@ -51,16 +74,40 @@ public sealed class UiMarkupList : UiElement
float y = (index - _topRow) * RowHeight; float y = (index - _topRow) * RowHeight;
if (index == selected) if (index == selected)
context.DrawFill(1f, y + 1f, Width - 2f, RowHeight - 1f, SelectedColor); 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]; string text = items[index];
Vector4 textColor = index < itemColors.Count Vector4 textColor = index < itemColors.Count
? Rgb(itemColors[index]) ? Rgb(itemColors[index])
: TextColor; : TextColor;
float textX = Padding + iconColumn;
float textY = y + MathF.Max(0f, float textY = y + MathF.Max(0f,
(RowHeight - (DatFont?.LineHeight ?? 14f)) * 0.5f); (RowHeight - (DatFont?.LineHeight ?? 14f)) * 0.5f);
if (DatFont is { } font) if (DatFont is { } font)
context.DrawStringDat(font, text, Padding, textY, textColor, true); context.DrawStringDat(font, text, textX, textY, textColor, true);
else 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> /// <summary>Two-plane glyph outline, as retail draws interface text.</summary>
public bool Outline { get; set; } = true; 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; public event System.Action? Click;
/// <summary> /// <summary>
@ -208,22 +221,50 @@ public class UiSimpleButton : UiPanel
protected override void OnDraw(UiRenderContext ctx) protected override void OnDraw(UiRenderContext ctx)
{ {
base.OnDraw(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; string caption = TextSource?.Invoke() ?? Text;
if (caption.Length == 0) return; if (caption.Length == 0) return;
float captionAreaX = iconColumn;
float captionAreaWidth = MathF.Max(0f, Width - iconColumn);
if (DatFont is { } dat) if (DatFont is { } dat)
{ {
float datW = dat.MeasureWidth(caption); float datW = dat.MeasureWidth(caption);
ctx.DrawStringDat( ctx.DrawStringDat(
dat, caption, dat, caption,
(Width - datW) * 0.5f, (Height - dat.LineHeight) * 0.5f, captionAreaX + (captionAreaWidth - datW) * 0.5f,
(Height - dat.LineHeight) * 0.5f,
TextColor, Outline); TextColor, Outline);
return; return;
} }
if (ctx.DefaultFont is null) return; if (ctx.DefaultFont is null) return;
float textW = ctx.DefaultFont.MeasureWidth(caption); 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; float ty = (Height - ctx.DefaultFont.LineHeight) * 0.5f;
ctx.DrawString(caption, tx, ty, TextColor); ctx.DrawString(caption, tx, ty, TextColor);
} }

View file

@ -84,6 +84,17 @@ public readonly record struct PluginSpellInfo(
/// </summary> /// </summary>
public int? QualityOverride { get; init; } public int? QualityOverride { get; init; }
public int Quality => QualityOverride ?? Difficulty; 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> /// <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> /// <summary>Unenchanted retail skill level before vitae and spell mods.</summary>
public uint Base { get; init; } = Current; 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> /// <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 PluginObjectClass ObjectClass { get; init; }
public IReadOnlyList<PluginPaletteInfo> Palettes { get; init; } = public IReadOnlyList<PluginPaletteInfo> Palettes { get; init; } =
Array.Empty<PluginPaletteInfo>(); 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> /// <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 int ContainersCapacity { get; init; }
public IReadOnlyList<uint> SpellIds { get; init; } = Array.Empty<uint>(); public IReadOnlyList<uint> SpellIds { get; init; } = Array.Empty<uint>();
public IReadOnlyList<uint> ActiveSpellIds { 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> /// <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 IPluginHost? _host;
private int _entitiesSeen; private int _entitiesSeen;
private IDisposable? _iconPanel;
public void Initialize(IPluginHost host) public void Initialize(IPluginHost host)
{ {
@ -20,6 +21,14 @@ public sealed class SmokePlugin : IAcDreamPlugin
{ {
_host.Events.EntitySpawned += OnEntitySpawned; _host.Events.EntitySpawned += OnEntitySpawned;
_host.Log.Info($"smoke plugin sees {_entitiesSeen} entities (replay count at subscribe)"); _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) if (_host is not null)
_host.Events.EntitySpawned -= OnEntitySpawned; _host.Events.EntitySpawned -= OnEntitySpawned;
_iconPanel?.Dispose();
_iconPanel = null;
_host?.Log.Info($"smoke plugin disabled (saw {_entitiesSeen} entities total)"); _host?.Log.Info($"smoke plugin disabled (saw {_entitiesSeen} entities total)");
} }

View file

@ -0,0 +1,63 @@
using AcDream.App.Plugins;
using AcDream.App.Tests.Rendering;
using AcDream.Content;
using AcDream.Core.Spells;
using AcDream.Plugin.Abstractions;
using DatReaderWriter;
using DatReaderWriter.Options;
using Xunit;
namespace AcDream.App.Tests.Plugins;
/// <summary>
/// Slice B item 11 (<c>docs/plans/2026-09-06-plugin-shelf-and-dat-icons.md</c>):
/// <see cref="PluginSpellInfo.IconId"/> must carry the REAL installed
/// portal.dat SpellTable (<c>0x0E00000E</c>) icon field through
/// <see cref="AppAutomationSurface"/> unchanged, not just a synthetic
/// <see cref="SpellMetadata"/> fixture. Mirrors the
/// <see cref="AcDream.App.Tests.Rendering.ChargenPreviewEntityBuilderTests"/>
/// installed-DAT gate convention: <see cref="InstalledDatTestPath"/> +
/// <c>[Trait("Lane", "InstalledDat")]</c>, <c>Assert.Fail</c> with the
/// standard message when no DAT is configured (see docs/release-gate.md).
/// </summary>
[Trait("Lane", "InstalledDat")]
public sealed class AppAutomationSurfaceIconInstalledDatTests
{
/// <summary>Retail's Strength Other I — the same well-known spell id
/// SmokeIconPanel falls back to when the local character has learned
/// nothing yet.</summary>
private const uint KnownSpellId = 1u;
[Fact]
public void KnownSpell_IconId_MatchesTheInstalledSpellTablesIconFieldExactly()
{
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);
MagicCatalog catalog = MagicCatalog.Load(adapter);
Assert.True(
catalog.SpellTable.TryGet(KnownSpellId, out SpellMetadata expected),
$"expected spell {KnownSpellId} (Strength Other I) to exist in the installed SpellTable");
// A real production install's SpellTable always has non-zero art for
// an ordinary named spell; a zero here would mean the projector
// silently dropped SpellBase.Icon rather than proving the pipeline.
Assert.NotEqual(0u, expected.IconId);
using var runtime = GameRuntimeTestFactory.Create();
runtime.CharacterOwner.InstallSpellMetadata(catalog.SpellTable);
runtime.CharacterOwner.Spellbook.OnSpellLearned(KnownSpellId);
using var surface = new AppAutomationSurface();
surface.Bind(runtime, runtime.CharacterOwner, runtime.ActionOwner.SpellCast);
Assert.True(surface.Spells.TryGet(KnownSpellId, out PluginSpellInfo info));
Assert.Equal(expected.IconId, info.IconId);
}
}

View file

@ -0,0 +1,25 @@
using AcDream.Plugin.Abstractions;
using Xunit;
namespace AcDream.App.Tests.Plugins;
/// <summary>
/// <see cref="PluginIcons.Normalize"/> — the one grammar every host icon sink
/// (descriptor <see cref="PluginPanelDescriptor.IconSurfaceId"/>, markup
/// <c>&lt;icon did&gt;</c>/<c>&lt;button icon&gt;</c>/<c>&lt;list icons&gt;</c>)
/// applies. See <c>docs/plans/2026-09-06-plugin-shelf-and-dat-icons.md</c>
/// Slice B item 1.
/// </summary>
public sealed class PluginIconsTests
{
[Theory]
[InlineData(0u, 0u)]
[InlineData(7735u, 0x06001E37u)] // Decal-style bare index -> RenderSurface DID
[InlineData(0x00FFFFFFu, 0x06FFFFFFu)] // largest bare index -> still gets the block prefix
[InlineData(0x06002D14u, 0x06002D14u)] // already a RenderSurface DID -> unchanged
[InlineData(0x0600FFFFu, 0x0600FFFFu)] // already at/above the block -> unchanged
public void Normalize_MapsAccordingToTheGrammar(uint input, uint expected)
{
Assert.Equal(expected, PluginIcons.Normalize(input));
}
}

View file

@ -0,0 +1,452 @@
using System.Numerics;
using AcDream.App.Rendering;
using AcDream.App.Rendering.Gpu;
using AcDream.App.Tests.Rendering.Gpu;
using AcDream.App.UI;
using DatReaderWriter.Types;
using Xunit;
namespace AcDream.App.Tests.UI;
/// <summary>
/// Slice B (<c>docs/plans/2026-09-06-plugin-shelf-and-dat-icons.md</c>):
/// <c>&lt;icon&gt;</c>, <c>&lt;button icon&gt;</c>, and <c>&lt;list icons&gt;</c>
/// markup, backed by a fake <see cref="IMarkupIconResolver"/> that records
/// every call so the resolver-dispatch (did/spell/item, normalized vs raw)
/// is directly assertable, plus draw-level pins (via the same
/// <see cref="RecordingGpuDevice"/>/<see cref="TextRenderer"/> apparatus
/// <c>UiAncestorClipTests</c>/<c>UiRenderContextDrawStringDatOutlineTests</c>
/// use) for "draws nothing when unresolvable" and "button/list icon shifts
/// the caption/text column".
/// </summary>
public sealed class MarkupIconTests
{
private sealed class FakeIconResolver : IMarkupIconResolver
{
public readonly List<(string Method, uint Id)> Calls = new();
public uint DidTexture = 5u;
public uint SpellTexture = 6u;
public uint ItemTexture = 7u;
public (uint tex, int w, int h) ResolveDid(uint did)
{
Calls.Add(("did", did));
return did == 0u ? (0u, 0, 0) : (DidTexture, 32, 32);
}
public (uint tex, int w, int h) ResolveSpell(uint spellId)
{
Calls.Add(("spell", spellId));
return spellId == 0u ? (0u, 0, 0) : (SpellTexture, 32, 32);
}
public (uint tex, int w, int h) ResolveItem(uint objectId)
{
Calls.Add(("item", objectId));
return objectId == 0u ? (0u, 0, 0) : (ItemTexture, 32, 32);
}
}
private sealed class IconBinding
{
public uint IconDid { get; set; } = 0x06001234u;
public uint SpellId { get; set; } = 42u;
public uint ObjectId { get; set; } = 0x50000001u;
public Action Go => () => { };
}
private static (uint, int, int) Sprite(uint id) => (1u, 32, 32);
// ── <icon>: source dispatch + the Build-time contract ────────────────────
[Fact]
public void Icon_LiteralBareIndex_NormalizesBeforeResolving()
{
const string xml =
"<panel x=\"0\" y=\"0\" w=\"100\" h=\"60\">" +
"<icon x=\"0\" y=\"0\" did=\"7735\"/>" +
"</panel>";
var resolver = new FakeIconResolver();
var panel = MarkupDocument.Build(xml, new object(), Sprite, icons: resolver);
var icon = Assert.IsType<UiMarkupIcon>(panel.Children[0]);
(uint tex, int w, int h) = icon.IconSource();
Assert.Equal(resolver.DidTexture, tex);
Assert.Equal(32, w);
Assert.Equal(32, h);
Assert.Equal(("did", 0x06001E37u), resolver.Calls[^1]); // 0x06000000 + 7735
}
[Fact]
public void Icon_LiteralHexDid_AlreadyInRangeIsUnchangedByNormalize()
{
const string xml =
"<panel x=\"0\" y=\"0\" w=\"100\" h=\"60\">" +
"<icon x=\"0\" y=\"0\" did=\"0x06002D14\"/>" +
"</panel>";
var resolver = new FakeIconResolver();
var panel = MarkupDocument.Build(xml, new object(), Sprite, icons: resolver);
var icon = Assert.IsType<UiMarkupIcon>(panel.Children[0]);
icon.IconSource();
Assert.Equal(("did", 0x06002D14u), resolver.Calls[^1]);
}
[Fact]
public void Icon_BoundDid_ReReadsTheBindingEveryFrame()
{
const string xml =
"<panel x=\"0\" y=\"0\" w=\"100\" h=\"60\">" +
"<icon x=\"0\" y=\"0\" did=\"{IconDid}\"/>" +
"</panel>";
var resolver = new FakeIconResolver();
var binding = new IconBinding();
var panel = MarkupDocument.Build(xml, binding, Sprite, icons: resolver);
var icon = Assert.IsType<UiMarkupIcon>(panel.Children[0]);
icon.IconSource();
Assert.Equal(("did", binding.IconDid), resolver.Calls[^1]);
binding.IconDid = 0x06005678u;
icon.IconSource();
Assert.Equal(("did", 0x06005678u), resolver.Calls[^1]);
}
[Fact]
public void Icon_Spell_RoutesToResolveSpellWithTheBoundIdUnnormalized()
{
const string xml =
"<panel x=\"0\" y=\"0\" w=\"100\" h=\"60\">" +
"<icon x=\"0\" y=\"0\" spell=\"{SpellId}\"/>" +
"</panel>";
var resolver = new FakeIconResolver();
var binding = new IconBinding();
var panel = MarkupDocument.Build(xml, binding, Sprite, icons: resolver);
var icon = Assert.IsType<UiMarkupIcon>(panel.Children[0]);
(uint tex, _, _) = icon.IconSource();
Assert.Equal(resolver.SpellTexture, tex);
Assert.Equal(("spell", binding.SpellId), resolver.Calls[^1]);
}
[Fact]
public void Icon_Item_RoutesToResolveItemWithTheBoundId()
{
const string xml =
"<panel x=\"0\" y=\"0\" w=\"100\" h=\"60\">" +
"<icon x=\"0\" y=\"0\" item=\"{ObjectId}\"/>" +
"</panel>";
var resolver = new FakeIconResolver();
var binding = new IconBinding();
var panel = MarkupDocument.Build(xml, binding, Sprite, icons: resolver);
var icon = Assert.IsType<UiMarkupIcon>(panel.Children[0]);
(uint tex, _, _) = icon.IconSource();
Assert.Equal(resolver.ItemTexture, tex);
Assert.Equal(("item", binding.ObjectId), resolver.Calls[^1]);
}
[Fact]
public void Icon_ZeroId_ResolvesToNothing()
{
const string xml =
"<panel x=\"0\" y=\"0\" w=\"100\" h=\"60\">" +
"<icon x=\"0\" y=\"0\" did=\"0\"/>" +
"</panel>";
var resolver = new FakeIconResolver();
var panel = MarkupDocument.Build(xml, new object(), Sprite, icons: resolver);
var icon = Assert.IsType<UiMarkupIcon>(panel.Children[0]);
(uint tex, int w, int h) = icon.IconSource();
Assert.Equal(0u, tex);
}
[Fact]
public void Icon_NoResolverWired_ResolvesToNothingRatherThanThrowing()
{
const string xml =
"<panel x=\"0\" y=\"0\" w=\"100\" h=\"60\">" +
"<icon x=\"0\" y=\"0\" did=\"7735\"/>" +
"</panel>";
var panel = MarkupDocument.Build(xml, new object(), Sprite);
var icon = Assert.IsType<UiMarkupIcon>(panel.Children[0]);
(uint tex, int w, int h) = icon.IconSource();
Assert.Equal(0u, tex);
}
[Fact]
public void Icon_TwoSources_ThrowsFormatExceptionAtBuild()
{
const string xml =
"<panel x=\"0\" y=\"0\" w=\"100\" h=\"60\">" +
"<icon x=\"0\" y=\"0\" did=\"7735\" spell=\"{SpellId}\"/>" +
"</panel>";
Assert.Throws<FormatException>(
() => MarkupDocument.Build(xml, new IconBinding(), Sprite, icons: new FakeIconResolver()));
}
[Fact]
public void Icon_NoSources_ThrowsFormatExceptionAtBuild()
{
const string xml =
"<panel x=\"0\" y=\"0\" w=\"100\" h=\"60\">" +
"<icon x=\"0\" y=\"0\"/>" +
"</panel>";
Assert.Throws<FormatException>(
() => MarkupDocument.Build(xml, new object(), Sprite));
}
[Fact]
public void Icon_WidthHeightDefaultTo32WhenOmitted()
{
const string xml =
"<panel x=\"0\" y=\"0\" w=\"100\" h=\"60\">" +
"<icon x=\"0\" y=\"0\" did=\"1\"/>" +
"</panel>";
var panel = MarkupDocument.Build(xml, new object(), Sprite);
var icon = Assert.IsType<UiMarkupIcon>(panel.Children[0]);
Assert.Equal(32f, icon.Width);
Assert.Equal(32f, icon.Height);
}
// ── <button icon>: resolver dispatch + draw-level "text shifts right" ────
[Fact]
public void ButtonIcon_DefaultsToDidKind_AndDrawsThroughTheResolver()
{
const string xml =
"<panel x=\"0\" y=\"0\" w=\"100\" h=\"60\">" +
"<button x=\"0\" y=\"0\" w=\"60\" h=\"20\" text=\"Go\" icon=\"7735\"/>" +
"</panel>";
var resolver = new FakeIconResolver();
var panel = MarkupDocument.Build(xml, new IconBinding(), Sprite, icons: resolver);
var button = Assert.IsType<UiSimpleButton>(panel.Children[0]);
Assert.NotNull(button.IconSource);
(uint tex, _, _) = button.IconSource!();
Assert.Equal(resolver.DidTexture, tex);
Assert.Equal(("did", 0x06001E37u), resolver.Calls[^1]);
}
[Fact]
public void ButtonIcon_KindSpell_RoutesToResolveSpell()
{
const string xml =
"<panel x=\"0\" y=\"0\" w=\"100\" h=\"60\">" +
"<button x=\"0\" y=\"0\" w=\"60\" h=\"20\" text=\"Buff\" " +
"icon=\"{SpellId}\" iconkind=\"spell\"/>" +
"</panel>";
var resolver = new FakeIconResolver();
var binding = new IconBinding();
var panel = MarkupDocument.Build(xml, binding, Sprite, icons: resolver);
var button = Assert.IsType<UiSimpleButton>(panel.Children[0]);
button.IconSource!();
Assert.Equal(("spell", binding.SpellId), resolver.Calls[^1]);
}
[Fact]
public void ButtonWithoutIconAttribute_HasNoIconSource()
{
const string xml =
"<panel x=\"0\" y=\"0\" w=\"100\" h=\"60\">" +
"<button x=\"0\" y=\"0\" w=\"60\" h=\"20\" text=\"Go\"/>" +
"</panel>";
var panel = MarkupDocument.Build(xml, new object(), Sprite);
var button = Assert.IsType<UiSimpleButton>(panel.Children[0]);
Assert.Null(button.IconSource);
}
// ── <list icons>: resolver dispatch + column reservation ──────────────────
private sealed class ListIconBinding
{
public IReadOnlyList<string> Items { get; } = new[] { "First", "Second" };
// Row 0 resolves; row 1's id is 0 (unresolvable) — "missing entries
// draw no icon" per the plan.
public IReadOnlyList<uint> IconIds { get; } = new[] { 7735u, 0u };
public int Selected { get; set; } = -1;
}
[Fact]
public void ListIcons_ReservesTheColumn_AndResolvesEachRowThroughTheSharedResolver()
{
const string xml =
"<panel x=\"0\" y=\"0\" w=\"200\" h=\"100\">" +
"<list x=\"0\" y=\"0\" w=\"180\" h=\"60\" rowheight=\"18\" " +
"items=\"{Items}\" icons=\"{IconIds}\" iconkind=\"did\" selected=\"{Selected}\"/>" +
"</panel>";
var resolver = new FakeIconResolver();
var binding = new ListIconBinding();
var panel = MarkupDocument.Build(xml, binding, Sprite, icons: resolver);
var list = Assert.IsType<UiMarkupList>(panel.Children[0]);
Assert.NotNull(list.IconIdsSource);
Assert.Equal(binding.IconIds, list.IconIdsSource!());
Assert.NotNull(list.IconResolve);
(uint tex, _, _) = list.IconResolve!(7735u);
Assert.Equal(resolver.DidTexture, tex);
Assert.Equal(("did", 0x06001E37u), resolver.Calls[^1]);
(uint missTex, _, _) = list.IconResolve!(0u);
Assert.Equal(0u, missTex);
}
[Fact]
public void ListWithoutIconsAttribute_HasNoIconColumn()
{
const string xml =
"<panel x=\"0\" y=\"0\" w=\"200\" h=\"100\">" +
"<list x=\"0\" y=\"0\" w=\"180\" h=\"60\" items=\"{Items}\" selected=\"{Selected}\"/>" +
"</panel>";
var panel = MarkupDocument.Build(xml, new ListIconBinding(), Sprite);
var list = Assert.IsType<UiMarkupList>(panel.Children[0]);
Assert.Null(list.IconIdsSource);
}
// ── Draw-level: "draws nothing" / "shifts text" pinned against real quads ─
private sealed class NullGpuFrameSource : ICurrentGpuFrameSource
{
public IGpuFrame? CurrentFrame => null;
}
private static (TextRenderer renderer, UiRenderContext ctx) MakeContext(float w, float h)
{
var device = new RecordingGpuDevice();
var renderer = new TextRenderer(device, new NullGpuFrameSource(), "unused");
renderer.Begin(new Vector2(w, h));
var ctx = new UiRenderContext(renderer, new Vector2(w, h));
return (renderer, ctx);
}
[Fact]
public void UiMarkupIcon_ResolvedTexture_DrawsASpriteAspectPreservedAndCentered()
{
// 32x16 source in a 32x32 box: scale = min(32/32, 32/16) = 1, so the
// drawn quad stays 32x16 (never stretched to fill the box) and is
// vertically centered: y = (32-16)/2 = 8.
var icon = new UiMarkupIcon
{
Width = 32f,
Height = 32f,
IconSource = () => (9u, 32, 16),
};
var (renderer, ctx) = MakeContext(200f, 200f);
icon.DrawSelfAndChildren(ctx);
var seg = Assert.Single(renderer.DebugSpriteSegmentVerts, s => s.Texture == 9u);
// First vertex = top-left (x,y); AppendQuad's V0..V5 layout, 8 floats/vertex.
Assert.Equal(0f, seg.Verts[0], 3);
Assert.Equal(8f, seg.Verts[1], 3);
}
[Fact]
public void UiMarkupIcon_UnresolvedTexture_DrawsNothing()
{
var icon = new UiMarkupIcon
{
Width = 32f,
Height = 32f,
IconSource = () => (0u, 0, 0),
};
var (renderer, ctx) = MakeContext(200f, 200f);
icon.DrawSelfAndChildren(ctx);
Assert.Empty(renderer.DebugSpriteSegmentVerts);
}
[Fact]
public void UiSimpleButton_WithIcon_DrawsTheIconSprite_AndShiftsTheCaptionRight()
{
var glyphs = new Dictionary<char, FontCharDesc>
{
['G'] = new FontCharDesc { Unicode = 'G', Width = 8, Height = 8 },
};
var font = new UiDatFont(
fgTex: 1u, fgW: 32, fgH: 32,
bgTex: 0, bgW: 0, bgH: 0,
lineHeight: 16f, baselineOffset: 12f,
glyphs);
var withoutIcon = new UiSimpleButton
{
Width = 60f, Height = 20f, Text = "G", DatFont = font, Outline = false,
BackgroundColor = default, BorderColor = default,
};
var withIcon = new UiSimpleButton
{
Width = 60f, Height = 20f, Text = "G", DatFont = font, Outline = false,
IconSource = () => (9u, 32, 32),
BackgroundColor = default, BorderColor = default,
};
var (rendererWithout, ctxWithout) = MakeContext(200f, 200f);
withoutIcon.DrawSelfAndChildren(ctxWithout);
var (rendererWith, ctxWith) = MakeContext(200f, 200f);
withIcon.DrawSelfAndChildren(ctxWith);
// The icon sprite itself drew.
Assert.Contains(rendererWith.DebugSpriteSegmentVerts, s => s.Texture == 9u);
Assert.DoesNotContain(rendererWithout.DebugSpriteSegmentVerts, s => s.Texture == 9u);
// Both buttons draw exactly one glyph quad (font texture 1u); the
// icon-bearing button's caption starts strictly to the right of the
// icon-less button's, because its centering region was shifted by
// the reserved icon column.
var textWithout = Assert.Single(rendererWithout.DebugSpriteSegmentVerts, s => s.Texture == 1u);
var textWith = Assert.Single(rendererWith.DebugSpriteSegmentVerts, s => s.Texture == 1u);
Assert.True(
textWith.Verts[0] > textWithout.Verts[0],
$"expected the icon-bearing button's caption ({textWith.Verts[0]}) to start right of the icon-less caption ({textWithout.Verts[0]})");
}
[Fact]
public void UiMarkupList_IconColumn_DrawsResolvedRowIcon_AndSkipsAMissingOne()
{
var list = new UiMarkupList
{
Width = 180f, Height = 60f, RowHeight = 18f,
ItemsSource = () => new[] { "First", "Second" },
IconIdsSource = () => new uint[] { 9u, 0u },
IconResolve = id => id == 0u ? (0u, 0, 0) : (id, 16, 16),
BackgroundColor = default, BorderColor = default,
};
var (renderer, ctx) = MakeContext(200f, 200f);
list.DrawSelfAndChildren(ctx);
// Row 0's icon (id 9u) drew; row 1 has no icon id (0u -> unresolvable)
// and therefore emits no sprite for that row's column.
Assert.Contains(renderer.DebugSpriteSegmentVerts, s => s.Texture == 9u);
var iconQuad = Assert.Single(renderer.DebugSpriteSegmentVerts, s => s.Texture == 9u);
// The icon column is RowHeight - 2 wide; the drawn quad's right edge
// (the second AppendQuad vertex's x, at float offset 8 — see
// UiRenderContextDrawStringDatOutlineTests.DecodeQuads for the same
// 8-floats/vertex, 6-vertices/quad layout) must sit entirely inside
// it, never overlapping where row text starts.
Assert.True(iconQuad.Verts[8] <= list.RowHeight - 2f + 0.01f);
}
}