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>
230 lines
11 KiB
Markdown
230 lines
11 KiB
Markdown
# 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`.
|