acdream/docs/plugin-ui-markup.md
Erik 8217a349e0 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>
2026-09-06 14:52:23 +02:00

11 KiB
Raw Blame History

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 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:

// 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.