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>
This commit is contained in:
Erik 2026-09-06 14:52:23 +02:00
parent 718005b210
commit 8217a349e0
21 changed files with 1440 additions and 16 deletions

View file

@ -92,6 +92,11 @@ document in the same change; do not leave both claims standing.
before trusting any measurement. Enforced by
`LaunchOptionsDocumentationTests`: a flag without a row fails the build, and
so does a row whose read site was deleted.
- [`plugin-ui-markup.md`](plugin-ui-markup.md) is the SSOT for the plugin
markup vocabulary (elements, attributes, `{Binding}` rules) plus the
Decal/VirindiViewService-compatible DAT icon grammar (Slice B,
`plans/2026-09-06-plugin-shelf-and-dat-icons.md`) and the movable/
collapsible plugin shelf (Slice A of the same plan).
- [`audit/`](audit/) contains completion and conformance audits.
- [`reference/ace-commands.md`](reference/ace-commands.md) preserves the local
ACE server's complete in-game command catalog and points to the authoritative

View file

@ -203,9 +203,18 @@ plugin. No-window hosts retain the plugin session but expose the no-op UI
capability.
The retained markup vocabulary includes panels, nested groups, labels,
buttons, meters, tabs, lamp-style toggles, and scalar sliders. Controls bind to
BCL-visible properties/actions on the plugin binding object; visible controls
must correspond to real behavior, never placeholders that report success.
buttons, meters, tabs, lamp-style toggles, scalar sliders, editable fields,
dropdown menus, scrollable lists, and (Slice B,
`docs/plans/2026-09-06-plugin-shelf-and-dat-icons.md`) DAT icons — a standalone
`<icon>` element plus icon-bearing extensions of `<button>` and `<list>`
resolved from a Decal/VirindiViewService-compatible icon-id grammar
(`AcDream.Plugin.Abstractions.PluginIcons.Normalize`) against raw RenderSurface
DIDs, retail's composited spell icon, or a live object's composited item icon.
Controls bind to BCL-visible properties/actions on the plugin binding object;
visible controls must correspond to real behavior, never placeholders that
report success. Full grammar and binding rules: `docs/plugin-ui-markup.md`.
The Slice A movable/collapsible plugin shelf (`plugin-shelf`, drag grip,
Shift+Ctrl+F1 hide/show) is documented there too.
The following was the original pre-D.2b proposal and remains historical
context, not the shipped plugin contract:

230
docs/plugin-ui-markup.md Normal file
View file

@ -0,0 +1,230 @@
# 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`.