acdream/docs/plugin-ui-markup.md
Erik ece2104189 chore(plugins): remove the Smoke gate plugin; MossTank shelf icon 0x06002C41
Owner direction 2026-09-06: the owner visually accepted all three connected
gates from the plugin-shelf/DAT-icon work — the movable plugin shelf
(Slice A), the DAT icon markup (Slice B), and the retained-UI outline-order
fix (761a7519f). With that gate passed, the Smoke plugin's job as the gate
artifact is done, so it is deleted outright rather than merely hidden:
src/AcDream.Plugins.Smoke/ (SmokePlugin.cs, SmokeIconPanel.cs, csproj, lock
files).

Reference sites cleaned:
- AcDream.slnx: removed the project entry.
- src/AcDream.App/AcDream.App.csproj: removed the Smoke ProjectReference and
  the CopySmokePluginToBuildOutput/CopySmokePluginToPublishOutput targets;
  the MossTank equivalents are untouched.
- .github/workflows/headless-portability.yml: the Linux package-contract
  step no longer asserts a Smoke plugin.dll/plugin.json pair — repointed at
  MossTank's, since the step's job was to prove the plugin-copy packaging
  mechanism works end to end, not specifically to prove Smoke.
- tests/AcDream.Core.Tests/Plugins/PluginManifestTests.cs: the inline JSON
  fixture used Smoke's manifest values as arbitrary test data; swapped for
  MossTank's so the parser test still proves the same thing.
- tests/AcDream.App.Tests/Rendering/LinuxPlatformBoundaryTests.cs: the
  shipped-plugin-copy shape test counted 4 GetTargetPath targets (Smoke +
  MossTank, build + publish); now 2 (MossTank only).
- tests/AcDream.App.Tests/Plugins/AppAutomationSurfaceIconInstalledDatTests.cs:
  reworded a doc comment that named the now-deleted SmokeIconPanel.
- README.md, docs/plugin-ui-markup.md: dropped Smoke-specific mentions,
  kept the icon markup example/grammar (now citing MossTank's own real
  IconSurfaceId).
- docs/plans/2026-09-06-plugin-shelf-and-dat-icons.md: recorded the owner's
  2026-09-06 acceptance and the Smoke removal in the review ledger; deleted
  the now-moot "before shipment" Smoke-in-release-zip warning.
- docs/reviews/coverage-ledger.md, docs/reviews/findings-ledger.md: left
  untouched — both are frozen audit snapshots ("complete for baseline
  <hash>"), so their Smoke rows are historical record, not live claims.
- docs/ISSUES.md: left untouched — its Smoke mentions are inside closed
  issue #193's historical write-up of a past investigation.

MossTank plugin shelf icon: MossTankPlugin.cs's PluginPanelDescriptor now
sets IconSurfaceId = 0x06002C41 (IconText = "MT" remains the fallback).
Verified against the installed retail DAT with a new InstalledDat-lane test,
tests/AcDream.App.Tests/UI/MossTankIconInstalledDatTests.cs, mirroring
RetailMarkupIconResolverInstalledDatTests's convention: confirms the id is a
real Portal/HighRes RenderSurface and that RetailMarkupIconResolver.ResolveDid
returns a non-zero texture for it.

Verified: dotnet build AcDream.slnx -c Release green; a stale
plugins/AcDream.Plugins.Smoke output folder from a prior build was deleted
and a fresh build does not recreate it. Full App suite: 7,363 passed / 97
skipped / 36 failed (was 7,362/97/36) — the failing set is unchanged and
none are plugin-related; the one new pass is the MossTank DAT-icon test.

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

320 lines
18 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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 = 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 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) | **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) | 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:
```csharp
// 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>`
```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).
`<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">`
```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="{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:
```csharp
// 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})");
```
```xml
<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`.