- Split the two hermetic RetailMarkupIconResolver memoization tests (and their counting fakes) out of the Lane=InstalledDat class into a new untagged RetailMarkupIconResolverMemoizationTests.cs so CI's portable filter (Lane!=InstalledDat) actually runs them. - PluginSidePanel: move the entry button's Anchors = AnchorEdges.None from the Add() call site into PluginShelfButton's own constructor (same comment carried over) so a second construction path cannot miss it. - UiRectOutlinePainterOrderTests: assert the back panel's border segment carries exactly 4 quads (24 vertices, FloatsPerVertex each) so a partial outline cannot pass the painter-order check. - RetailMarkupIconResolver: document the type as UI-thread-only (every caller is a draw-time icon source) and bound the MISS cache to 256 entries with FIFO eviction — HIT entries stay unbounded (bounded by the DAT's own surface count already). New test proves the 257th distinct miss evicts the first (re-probe count rises); verified failing first against the un-bounded code (Expected 258, Actual 257) before restoring the fix. - docs/plugin-ui-markup.md: split the icon-binding row's failure mode into Build-time (missing property only — the binder never checks CLR type) vs. draw-time (a resolved value that cannot convert to a number throws from the draw, not from Build). - docs/ISSUES.md: filed #486 (credits picture scroll frozen by the per-draw anchor pass) and #487 (radar compass tokens candidate, same mechanism, unconfirmed); corrected #461's causality — the graceful logout/reveal-cancel log lines are printed by LiveSessionController.Tick's catch -> StopAfterFailure -> StopCore AFTER the motion-update exception, then it rethrows, so the logout is a consequence of the crash, not its cause; real chain is the #462 stalled login-reveal materialization leaving PlayerMovementController in RuntimeOwnedDormant outside its SetPosition ground phase when an inbound 0xF74C arrives. - Plan doc: recorded the three fix-round commits' verdicts (all PASS) and the Smoke-plugin cleanup commit SHA in the Review ledger, plus a pointer to the two newly filed issues. Verified: dotnet build AcDream.slnx -c Release (0/0), targeted filter 85/0/0, full App suite 7364 passed / 97 skipped / 36 failed (36 pre-existing InstalledDat/Manual/Linux-only failures, unchanged by name from baseline; net +1 passed test from the new eviction test). Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
18 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 = 0x06002C41, // 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 to reach for 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 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) |
Silent — BindString 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 |
Silent — BindUint returns null (the meter shows no cur/max) |
uint? (accepts any integral type) |
meter fill, slider value |
Silent — BindFloat 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) |
Build-time: throws only for a missing bound property (or, for a literal, one that isn't valid hex/decimal) — the binder never checks the property's static CLR type. Draw-time: a resolved value that is negative or above uint.MaxValue is silent (maps to 0u, draws nothing); a resolved value that cannot convert to a number at all throws InvalidCastException/FormatException from the draw, not from Build |
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> |
The icon-id row is the one binding here whose failure mode depends on WHEN you look: a typo'd property name is caught immediately at Build, but a property that exists yet holds the wrong kind of value at runtime is only ever discovered later, from inside a live draw.
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>ofdid-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 nodid-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:
// 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.