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>
11 KiB
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;
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
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 atBuildtime against the binding object's public properties/Action/Action<T>members via reflection, then re-read every frame through aFunc<T>(or invoked live for actions). A plugin updates its panel by assigning a property; it never touchesUiElementobjects directly, and never from a thread other than the one that callsTick.
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:
// 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>
<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">
<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">
<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:
public IEnumerable<uint> SpellIconIds =>
host.Automation.Spells.KnownSelfBuffs.Select(s => s.IconId);
public IEnumerable<string> SpellRows =>
host.Automation.Spells.KnownSelfBuffs.Select(s => s.Name);
<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.