acdream/docs/plugin-ui-markup.md
Erik ce05c4fb03 fix(plugin-ui): Slice B residuals - shelf icon sink without magenta, validated icon bindings, negative ids, memoized DID resolves
Bounded residual round on the Slice B review fix commit (466272ec5),
docs/plans/2026-09-06-plugin-shelf-and-dat-icons.md.

N1 (BLOCKING): PluginSidePanel's shelf entries resolved icons through
_bindings.Assets.ResolveSprite (= ResolveChrome =
TextureCache.GetOrUploadRenderSurface), which returns a non-zero 1x1
magenta placeholder for a missing id - so PluginShelfButton's initials
fallback could never fire in production. RetailUiRuntime.MountPlugins
now passes the same iconResolver.ResolveDid every markup icon sink
already uses, which returns (0,0,0) for an unresolvable id. Re-pointed
the existing initials-fallback unit test at a resolver matching
ResolveDid's real contract, and added an InstalledDat-lane test
(ShelfButton_BogusDescriptorId_OnTheRealResolver_FallsBackToInitials)
proving a bogus descriptor id on the REAL resolver yields initials.

N2: MarkupDocument's <button icon>/<list icons> handling only built the
uint reader (BindUintLiteralOrBinding/BindUintList) when an
IMarkupIconResolver was wired, so a malformed icon="{Typo}" or
icons="notabinding" silently loaded instead of throwing at Build on a
resolver-less host. Both readers now build unconditionally (same rule
ValidateIconKind already followed); only the IconSource/IconIdsSource
assignment stays gated on icons is not null.

N3: a negative bound icon id threw OverflowException out of
Convert.ToUInt32 every frame from inside UiSimpleButton.OnDraw (scalar
path), while the list's IEnumerable<int> path silently wrapped -1 to
0xFFFFFFFF (unchecked reinterpret). Both paths now map any
out-of-range value (negative, or above uint.MaxValue) to 0u instead -
Decal's own "no icon" convention - via a shared ToUintOrZero helper
that catches exactly the OverflowException Convert.ToUInt32 already
throws for both cases.

N4 (perf): RetailMarkupIconResolver.ResolveDid probed Portal/HighRes
(two cache misses + two B-tree lookups under the database lock) on
EVERY call for an unresolvable id, and re-entered the DAT lock on
every resolve of a hit too. Memoizes the resolved (tex,w,h) tuple per
DID, including the (0,0,0) miss, in a plain Dictionary.

N5 (nit): documented in TextureCache.GetOrUploadRenderSurface that the
(id, nearest) cache key uploads the same RenderSurface twice when both
samplers are wanted (chrome via ResolveChrome, plugin icons via
ResolveDid's nearest:true) - GetOrCreateLinearUiTwin exists but only
shares in the nearest-registered-first direction, so wiring it through
here is left as a documented nit rather than a behavior change.

N6 (nit): corrected stale SampleData.cs:64 citations to :69 (Melee
Defense's real line after the file grew) across SmokeIconPanel.cs,
PluginSidePanelTests.cs, RetailMarkupIconResolverInstalledDatTests.cs,
and docs/plugin-ui-markup.md (including the 64-82 range, now 69-83).

N7 (nit): <icon iconkind="..."> was silently ignored (icon derives its
kind from which of did/spell/item is set, unlike button/list). Now
throws FormatException at Build with a message naming the correct
surfaces; documented in plugin-ui-markup.md.

N8 (nit): the truth-table's `list colors` row now says IEnumerable<uint>
or IEnumerable<int> (shared BindUintList), matching `list icons`.

N9 (ship check): added a "Before shipment" line to the plan's Review
ledger. Confirmed the Smoke plugin (including its auto-open Icon Smoke
panel) IS included in the launcher's client-<rid>.zip release payload:
tools/publish-bin.ps1's New-PayloadZip zips App's entire publish
directory unfiltered, and AcDream.App.csproj's
CopySmokePluginToPublishOutput target runs unconditionally
AfterTargets="Publish". No behavior changed per instruction - flagged
for follow-up after the owner's connected gate.

Verification: dotnet build AcDream.slnx -c Release green; filtered
test command 108/108 passed (0 skipped) including the InstalledDat
lane; full AcDream.App.Tests suite 7362 passed / 97 skipped / 36
failed (all 36 pre-existing, same names, none touching
Markup/PluginSidePanel/RetailMarkupIconResolver/TextureCache/PluginIcons);
AcDream.Plugins.MossTank.Tests 337/337 passed.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-06 16:28:13 +02:00

18 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 failure's severity is per-attribute, not one blanket rule — see the table below. An unrecognized {Prop} that resolves loudly (any row marked "Throws") throws FormatException at Build, the same moment any other malformed attribute throws, never silently at draw time. The attributes marked "Silent" instead fall back to something visible-but-harmless at runtime (the literal text, null, or 0) — a plugin author who typos one of those sees a wrong-looking value on screen rather than a crash, so double check those four against the markup by eye.

Attribute(s) On a missing/mistyped {Prop} Bound CLR type
label text, field text, menu selected, tooltip (any element) SilentBindString falls back to the literal attribute text itself (a typo'd {Typo} renders as the literal string {Typo}) string (via .ToString())
meter cur, meter max SilentBindUint returns null (the meter shows no cur/max) uint? (accepts any integral type)
meter fill, slider value SilentBindFloat returns 0 float?/float
list items, menu items Throws IEnumerable<string>
list colors Silent if omitted (no color override); throws if present but mistyped IEnumerable<uint> or IEnumerable<int> (shared BindUintList)
list icons (Slice B) Throws if present but mistyped; omitting it entirely means no icon column at all. A negative int element is silent: it maps to 0u (no icon for that row), matching the scalar did/spell/item row above IEnumerable<uint> or IEnumerable<int>
<icon>/<button icon> did/spell/item bindings (Slice B) Throws (missing/mistyped property) — but a resolved value that is negative or above uint.MaxValue is silent: it maps to 0u (draws nothing) rather than throwing OverflowException at draw time any integral type (uint, int, long, ushort, a nullable of one, …) via Convert.ToUInt32
list selected Throws (required int reader) int
tab selected, toggle checked Throws (required bool reader) bool
root panel visible Throws (required bool reader; see the root-only note below) bool
onclick (button/tab/toggle) Throws Action
slider onchange Throws Action<float>
field onchange, field onsubmit, menu onchange Throws Action<string>
list onchange Throws Action<int>

Elements

Every element name is validated at Build: an unknown or miscased tag (e.g. <Icon>, <butotn>) throws FormatException rather than silently vanishing from the built tree.

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, empty/whitespace treated as no tooltip). The root <panel> is the one exception: it does not go through ApplyCommon (no name/enabled/tooltip), and its visible attribute accepts a {Binding} only — a literal visible="true" on the root is not parsed (unlike every child element, where a literal is fine).

LIMITATION: <list> has exactly one text column (plus the optional Slice B icon column) — there is no multi-column list yet. A plugin that needs tabular rows today pads its own fixed-width text ($"{name,-16}{value,6}"). Real multi-column support is deferred to the MossTank plugin work.

list colors' values are 0xRRGGBB (opaque, no alpha channel), while every color=/background=/border= attribute elsewhere is #AARRGGBB (alpha first) — the two grammars look similar but are not interchangeable.

Every hex literal (did, 0x id bindings, list colors entries) requires the 0x prefix to parse as hex; an all-digit string with no prefix (did="165") parses as decimal, not hex — did="165" and did="0x165" are different ids.

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)
//   0x00FFFFFF       -> 0x06FFFFFF       (largest bare index, just below the boundary)
//   0x01000000       -> 0x01000000       (AT the boundary -> already a DID, unchanged)
//   0x06002D14       -> 0x06002D14       (already a DID, unchanged)

The boundary is 0x01000000: any value below it is treated as a bare Decal-style index and gets the 0x06000000 RenderSurface block prefix added; any value at or above it (including 0x01000000 itself) is assumed to already be a resolvable DID and passes through 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.

Do NOT add 0x06000000 to any of the four IconId records above by hand — they are already full DIDs, not bare indices. PluginSkillInfo.IconId in particular comes straight from SkillBase.IconId, and retail's own UIRegion::SetImageByDID(SkillBase._iconID) (@0x004f150e) draws that field directly as a DID with no +0x06000000 step of its own — adding the block prefix again would double-normalize it and resolve nothing (see src/AcDream.App/UI/Layout/SampleData.cs:69-83 for the real values: Melee Defense is 0x06000165, never 7735/0x165, in that field).

API-v1 note: IconId is a positional/init member on each of the four records above, so it participates in record equality (Equals/GetHashCode) along with every other field. Plugin code that compares two PluginSpellInfo/PluginSkillInfo/PluginInventoryItem/PluginWorldObject values for equality now also compares their IconId — harmless for code built against the new host (both sides fill it identically), but worth knowing if you see an equality check that used to succeed start failing against a host that populates IconId where an older one left it 0.

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

<icon> derives its kind from WHICH of did/spell/item is set — unlike <button>/<list>, it has no per-element iconkind to disambiguate. Putting iconkind on an <icon> throws FormatException at Build ("iconkind applies to button and list; icon derives its kind from did/spell/item") rather than silently ignoring 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="{SpellIds}" iconkind="spell"
      selected="{SelectedIndex}"/>

icons is an IEnumerable<uint> (or IEnumerable<int>) 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).

iconkind is per-<list>, not per-row: every id in one list's icons binding is resolved the same way (all did, all spell, or all item). There is no way to mix kinds within a single list. Two consequences:

  • If a plugin's data genuinely mixes id spaces (some rows are raw DIDs, some are spell ids needing a composited badge), it must pre-normalize/pre-resolve outside the markup and expose ONE consistent IEnumerable<uint> of did-space ids — iconkind="did" on the list.
  • retail's composited spell badge (power-level backing + tint + self/fellow overlay) can only be reached through iconkind="spell" with real spell ids — there is no DID that already IS the composited result, so a list that wants the badge look has no did-space escape hatch.

MosswartMassacre-style example — a list column composited from spell ids, with the spell's own raw art DID printed alongside the name for comparison (AcDream.Plugins.Smoke's own proof panel, SmokeIconPanel.cs, is exactly this pattern):

// iconkind="spell": the values MUST be spell ids (what ResolveSpell composites
// a badge from), NOT the spell's raw IconId — those are different id spaces.
public IEnumerable<uint> SpellIds =>
    host.Automation.Spells.KnownSelfBuffs.Select(s => s.SpellId);
public IEnumerable<string> SpellRows =>
    host.Automation.Spells.KnownSelfBuffs.Select(
        s => $"{s.Name} (icon 0x{s.IconId:X8})");
<list items="{SpellRows}" icons="{SpellIds}" iconkind="spell" .../>

(A list backed by PluginSpellInfo.IconId directly — the spell's own raw art tile, no composited badge — uses iconkind="did" instead, with icons yielding IconId rather than SpellId.)

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 (expanded shows >, collapsed shows <) shrinks the shelf to a 28px-tall, button-sized tab (deliberately findable-sized, not a thin sliver) rather than just the grip band; 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.