acdream/docs/plugin-ui-markup.md
Erik 279de7c2db Merge branch 'claude/latest-main-sync-497549' into worktree-agent-a46673911c3cc2a31
# Conflicts:
#	docs/plans/2026-09-07-campaign-vt-slice7-tabs.md
#	docs/plugin-ui-markup.md
#	src/AcDream.App/UI/UiMarkupList.cs
2026-09-07 18:34:16 +02:00

42 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 = 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 — <panel resizable="true">, see "Resizable panels and anchors" below), 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) 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>
column items (type="text") Throws — REQUIRED, unlike the single-column list's own items sugar it mirrors IReadOnlyList<string>
column colors (type="text") Silent if omitted (no per-row override, same rule as list colors); throws if present but mistyped IReadOnlyList<uint> or IReadOnlyList<int>
column onclick (type="text") Silent if omitted (keeps the original select-the-row behavior); throws if present but mistyped Action<int> (row index)
column values (type="check") Throws — REQUIRED (there is no "no check column" fallback the way list icons has "no icon column") IReadOnlyList<bool>
column values (type="icon") Throws — REQUIRED IReadOnlyList<uint> or IReadOnlyList<int>
column onchange (type="check") Throws — REQUIRED (unlike the list's own optional onchange) Action<int> (row index)
column onclick (type="icon") Throws — REQUIRED Action<int> (row index)
column width (any type, NOT the list's last column) Throws if missing, unparseable, or <= 0 — UNLESS it is the literal "*" float, or the literal "*" for auto
column width (the list's LAST column) Silent — never validated, never used for layout (it always absorbs the remainder), UNLESS it is the literal "*" (then it joins the auto-sharing group instead of taking 100% of the remainder alone) float, or the literal "*"

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 resizable minw minh visible
group Transparent layout container x y w h background border visible anchor
label Static or bound text x y text color anchor
button Clickable rect + caption (+ Slice B icon) x y w h text color background border onclick icon iconkind anchor
icon Slice B: a standalone DAT icon x y w h did spell item tooltip anchor
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 anchor
toggle Lamp-style checkbox x y w h text checked onclick color anchor
slider Horizontal scalar x y w h value onchange min max style anchor
field Single-line editable text x y w h text maxlength clearonsubmit onchange onsubmit color background anchor
menu Dropdown selector x y w h items selected onchange rows rowheight openupward style anchor
list Scrollable row list (+ Slice B icon column, + Campaign VT slice 1 multi-column) x y w h selected onchange rowheight selectionband anchor + either the single-column items colors icons iconkind, or one-to-many <column> children (see "Columns" below) — never both

menu style is plain (the default) or retail: retail's gold pushbutton art read as an out-of-place "big yellow button" next to a plugin's own dark list boxes (owner live-client report, 2026-09-07), so a plugin <menu> now draws the flat VTank/Decal HudCombo box (list-matching fill/border, a left-aligned value, and a small ▾) by default; style="retail" opts back into the gold face for a panel that genuinely wants it. Any other value throws FormatException at Build. The plain style covers the WHOLE menu, closed and open: a follow-up owner report (still 2026-09-07 — "Drop down menus look horrible, there is also a checkmark on the text there") found the OPEN popup still drew retail's tan/orange gradient panel, its ornate gold scrollbar, and a baked checkmark glyph on the current entry even with style="plain". The open popup now matches <list>'s own chrome too: a flat fill + 1px border, one row per entry in the list text color, the current entry filled like a list selection, the hovered entry a slightly lighter fill, and no checkmark. A <menu> popup always scrolls a single column (rather than wrapping into more grid columns) once its item count exceeds rows; a further owner directive (still 2026-09-07 — "For scrollable dropdown or the meta window we use the same assets as we do in for example chat or inventory window") moved that overflow scrollbar to retail's own chrome — the exact sprites the chat window's transcript and the inventory list already use — while the rows themselves stay the flat style described above; a menu with too few items to overflow shows no bar at all. style="retail" keeps the sprite popup rows (gradient panel, checkmark-bearing row art) exactly as before, unchanged — only the scrollbar chrome is shared between the two styles.

slider style is the same plain/retail grammar (fix round B item 11, 2026-09-07): plain (the default) draws a flat dark track, a 1px border, and a small flat nub — no DAT scrollbar art, no SpriteResolve dependency at all — via UiScrollbar.RetailArt=false's DrawPlainScalar; style= "retail" keeps RetailScrollbarChrome.ApplyHorizontal's sprite track/thumb exactly as before. Any other value throws FormatException at Build, same rule as menu style.

Common to every element via ApplyCommon: name/id (a stable control name), visible (literal true/false or a bound bool property), enabled (same rule), tooltip (a literal string or {Binding} shown through retail's own runtime tooltip popup, empty/whitespace treated as no tooltip), and anchor (which edges of the element's PARENT it keeps a fixed margin to on resize — see "Resizable panels and anchors" below). The root <panel> is the one exception: it does not go through ApplyCommon (no name/enabled/tooltip/anchor — a top-level window is never anchored to its own parent, only dragged/resized directly), 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).

Multi-column lists are real (Campaign VT slice 1 Part B, below) — a <list> with <column> children is no longer limited to one padded text column. A <list> with no <column> children stays exactly the older single-column form (items/colors/icons/iconkind on the element itself); the two forms are mutually exclusive on one element.

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.

Resizable panels and anchors

A plugin panel is fixed-size by default — this matches every panel shipped before 2026-09-07 (e.g. mosstank.xml's resize="none"). A window opts into real user drag-resize with <panel resizable="true">, and every non-root element opts its OWN geometry into following that resize with anchor="...". The two attributes are independent: a resizable panel whose children have no anchor just gets bigger/smaller with empty space at the bottom-right (today's default placement, Left|Top); a panel with anchored children but resizable left at its default false never actually resizes, so the anchors never have anything to react to.

Attribute Element Meaning
resizable panel (root) "true" arms the window for user drag-resize on both axes (edges + corners, same mechanism chat windows use); default false — fixed size, exactly as before this attribute existed
minw / minh panel (root) The floor a drag-resize (and a persisted-layout restore) will not shrink below. Default: the panel's own authored w/h — a resizable panel never shrinks past the layout its author actually tested. Only meaningful when resizable="true"
resize panel (root) Pre-existing per-axis lock (x/y/both/none) that narrows resizable="true" to one axis; has no effect on its own now that resizable (default false) is the master switch
anchor group list menu field label button icon (and meter/tab/toggle/slider) Space-separated subset of left top right bottom (case-insensitive), naming which edges of the element's direct parent it keeps a fixed margin to as that parent resizes. Default (attribute absent) is left top — today's fixed placement, unchanged

anchor semantics are exactly AcDream.App.UI.UiElement.Anchors/ AnchorEdges/ApplyAnchor (already used by every retail-imported window):

  • left top (the default) — pinned top-left at a fixed size; never stretches.
  • left right — stretches WIDTH to track the parent (both side margins stay fixed).
  • top bottom — stretches HEIGHT the same way, vertically.
  • left top right bottom — stretches on both axes.
  • right alone (no left) — pins to the parent's right edge at a FIXED width, moving as the parent resizes rather than stretching. bottom alone is the same, vertically.

An element's parent is whatever markup element directly contains it — for a <group>'s children, that is the GROUP, not the panel. This is how a group propagates resize to its own contents: give the group anchor="left top right bottom" so it stretches with the panel, and give a <list> inside it anchor="left right" so the list stretches with the GROUP's width in turn. An unrecognized token (a typo like anchor="left rihgt") throws FormatException at Build, naming the offending element by its name/id — the same "malformed markup throws" rule every other attribute in this grammar follows.

No other markup or host wiring is needed to make a panel resizable: once resizable="true" sets the window's Resizable/ResizeX/ResizeY/ MinWidth/MinHeight, the SAME drag-resize, persistence (save/restore across sessions, clamped to minw/minh), and UI-lock behavior every other retained window already has just applies. Changing a panel's authored w/ h/minw/minh/resizable in a later plugin update resets every user's stored SIZE to the new authored default exactly once (their saved position is kept and re-clamped to the screen) — the host derives a stable revision from that tuple automatically, so a plugin author never needs to bump one by hand (#490 part 2).

<panel x="0" y="0" w="420" h="320" title="MossTank" resizable="true" minw="360" minh="260">
  <group anchor="left top right bottom" x="8" y="8" w="404" h="304" border="#FF4A3A14">
    <label x="4" y="4" text="Monsters"/>
    <list anchor="left right top bottom" x="4" y="24" w="396" h="276"
          items="{MonsterNames}" selected="{SelectedMonster}" onchange="{SelectMonster}"/>
  </group>
</panel>

Here the outer <group> stretches with the panel on every edge, and the <list> inside it stretches with the GROUP on every edge in turn — dragging the window's corner grows the whole list, not just empty panel background.

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.

A did icon is not blitted raw. It is drawn the way retail draws every icon it composites (IconData::RenderIcons with no overlay and no effects): the art's pure-white pixels are the DAT's "recolor me" key and are replaced with the solid-black fallback tile, exactly as a mundane item in the inventory. Raw art shows a white ring around the icon (Decal's HudPictureBox draws it that way); acdream does not. Art without any pure-white pixel is unaffected.

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

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

Columns (Campaign VT slice 1 Part B)

VVS's HudList (the VirindiViewService list control VTank's own mainView.xml uses) supports N independently-typed columns per row — text, checkbox, and icon cells side by side in one scrolling grid, each with its own Click(row, col). acdream's <list> matches this by letting a <list> declare <column> children instead of the single-column items/colors/icons attributes:

<list x="8" y="24" w="256" h="120" rowheight="18"
      selected="{SelectedMonster}" onchange="{SelectMonster}">
  <column type="check" width="20"  values="{MonsterFester}"  onchange="{ToggleFester}"/>
  <column type="text"  width="127" items="{MonsterNames}" onclick="{PingMonster}"/>
  <column type="icon"  width="*"   iconkind="did" values="{MonsterIcons}" onclick="{MoveMonsterUp}"/>
</list>

VVS lists draw no persistent row-selection fill, so <list> matches that by default in both the single-column and <column> forms — a plugin that wants a visible band anyway sets selectionband="true" (row selection itself, including scroll-into-view, is unaffected either way).

This mirrors VTank's own Monsters tab (several boolean flag columns, a name column, and icon-button columns) — see docs/research/vtank-kb/08-ui-views.md §3's "Multi-column lists with typed columns" gap and its proposed extension, which this implements verbatim. The rowheight="18" above is the widget's own default (UiMarkupList.RowHeight), chosen to match VVS's own row pitch exactly: Padding*2 + ControlHeight = 1*2 + 16 = 18 (docs/research/vtank-kb/08-ui-views.md's HudList row: Padding=1px, ControlHeight=16px) — an author who omits rowheight entirely already gets VVS's pitch for free.

<column> attribute grammar

Attribute Applies to Required Meaning
type every column yes text, check, or icon — any other value throws FormatException at Build
width every column see below Column width in px, or "*" for auto. See "Width semantics" below — the rules differ for the LAST column in a <list> vs. every other column
items type="text" yes {IReadOnlyList<string>} — one row of text per index
colors type="text" no {IReadOnlyList<uint>}, 0xRRGGBB per row (same grammar as the single-column list's own colors); omitted rows (or the whole attribute) fall back to the list's TextColor
onclick type="text" no {Action<int>} — fired with the ROW INDEX on a click anywhere in the cell INSTEAD of selecting the row. Omitted (the default) keeps the original select-the-row behavior; present but malformed throws FormatException at Build. None of VTank's eight lists actually relies on row selection — every real text cell in mainView.xml is wired as an action target — so a new column is usually written WITH an onclick
values type="check" yes {IReadOnlyList<bool>} — the checked state per row
onchange type="check" yes {Action<int>} — fired with the ROW INDEX on a click anywhere in the cell; the plugin flips its own bool, the column never mutates values' backing collection itself
values type="icon" yes {IReadOnlyList<uint>} (or IReadOnlyList<int>) — one icon id per row, same id-space rules as list icons
iconkind type="icon" no (defaults "did") did/spell/item, same three-source dispatch as <list icons iconkind> above — one kind per column, not per row
onclick type="icon" yes {Action<int>} — fired with the ROW INDEX on a click anywhere in the cell

Unlike the single-column list's optional icons/onchange, a check/icon column's own values and onchange/onclick are required — a column that can never fire anything, or has nothing to draw, is a Build-time author error, not a silently-inert control. A <column> with an unrecognized type, a missing required binding for its type, or any <list> child element that isn't <column> at all, throws FormatException at Build. A <list> with <column> children cannot ALSO use the single-column items/colors/icons attributes on the <list> element itself — pick one form per list. Every column-attribute throw message identifies the offending column by position and declared type — column[2] type="check" values, not just "column values" — so a list with several columns of the same type still points at the right one.

Width semantics

width="*" means AUTO: this column shares the list's remaining width EQUALLY with every other auto column, VVS's own "0-width column auto-sizes" rule (docs/research/vtank-kb/08-ui-views.md's HudList row: "a 0-width text/button/edit/list/fixedlayout/notebook column auto-sizes... share the remaining width equally"). width="*" is legal on ANY column, including the last.

The LAST column in a <list> is special: it is ALWAYS treated as auto — sharing the remaining width like every other auto column when one or more earlier columns also declare width="*", or absorbing 100% of the remainder by itself when no other column does (the original, still-default behavior). Its own declared width (or omitting width entirely) is never validated and never used for layout — only an explicit width="*" on the last column actually changes anything (it makes the last column share evenly with earlier auto columns instead of taking the whole remainder alone). When two or more columns end up sharing, integer-division remainder goes to the LAST one — e.g. three columns sharing 100px split 33/33/34, not 33/33/33 with 1px unaccounted for.

Every OTHER (non-last) column's declared width is validated at Build: a missing, unparseable, or non-positive width throws FormatException naming the column's index and declared type (column[0] type="text" width must be a positive number or "*", got (missing)) — UNLESS it is width="*".

At layout time (recomputed every frame off the list's live width, so a resizable list re-flows like every other retained widget), a declared width that would overflow the list's total width is CLAMPED to whatever room is actually left, walked left to right — every column after the overflow point gets 0 width and draws nothing (a <=0-width cell is skipped entirely, the same as today).

Row count, selection, and clicks

Row count is the longest bound column (a text column with 20 rows next to a check column with only 5 simply draws 15 rows past its own data — see "Short columns past their own row count" below for what each column kind does there). The list's own selected/onchange attributes keep exactly their single-column meaning: a click in a text column without its own onclick selects that row (and fires the list's onchange with the row index, same as today). A click in a check or icon column, or a text column that DOES declare its own onclick, instead fires that column's own onchange/onclick and does not change the list's selection — VVS's per-cell Click(row, col) folded into a per-column callback, since acdream's binding model is per-attribute rather than per-cell. A click landing on a row past that SPECIFIC column's own bound data (even though the row is valid for the list overall, because some OTHER column has more rows) fires nothing — no callback, no crash. Scrolling works exactly as the single-column list already does.

No header row

VVS's HudList has no built-in header row either — the column-caption glyphs seen in VTank's own mainView.xml (e.g. the Monsters tab's single-letter "F"/"B"/"G"/"I"/… flag headers) are ordinary StaticText controls placed manually above the list. acdream matches this for free: put a <label> (or several, one per column, hand-positioned) directly above the <list> — there is no dedicated header markup to learn.

Check-column glyph

A type="check" cell draws with the exact same five-band lamp glyph as <toggle> (both now share one UiCheckLamp primitive — its checked/ unchecked colors and Draw method), so a column checkbox reads identically to every other checkbox in the client rather than a bespoke box-and-tick. The glyph is centered horizontally in its cell (matching how an icon cell already centers its sprite) — a check column is routinely declared wider than the glyph itself under the PITCH convention below.

Short columns past their own row count

A text or icon cell past its own column's row count draws nothing — there is no sensible default string or icon to show. A check cell past its own column's row count still draws the lamp, UNCHECKED — VVS materializes every cell in a row regardless of which columns actually have data for it, and there is always a sensible default for a boolean (false).

The PITCH convention for transcribing a VTank column table

VVS's own HudList reserves geometry acdream's column model doesn't have a separate concept for: WPaddingOuter=3px (the list's own left/right margin), WPadding=7px (a gap BETWEEN columns), and a themed VScrollBarButtonSize=16px (scrollbar width, reserved on the right). It also forces every CheckColumn to a fixed 13px regardless of its declared fixedwidth. acdream's column model has no separate gap/forced-width concept — every column's declared width is its full cell width, columns sit directly adjacent with no gap, and a check column uses whatever width it's given like any other column. The 16px scrollbar column IS now automatic (owner directive, 2026-09-07 — see "Scrollbar" below): a plugin author never reserves it by hand.

To transcribe a real VTank column table (as in refs/vtank/uTank2.ViewXML.mainView.xml) faithfully, declare each column's PITCH instead of its raw fixedwidth: pitch = fixedwidth + 7 (baking VVS's inter-column WPadding into the cell width itself, since acdream has no separate gap). For a CheckColumn, use VVS's forced 13px as the fixedwidth regardless of whatever fixedwidth the source XML declares (16 -> 13 + 7 = 20, not 16 + 7 = 23). Do NOT also fold VVS's 16px scrollbar width into the last column's pitch or the list's total w — the list reserves that width itself, automatically, only while its rows actually overflow (see "Scrollbar" below); doing both would double-reserve it and starve the last column once the list has few enough rows to hide the bar.

Scrollbar

Once a <list>'s rows overflow its own height (either the single-column or the <column> form), it reserves a 16px column at its right edge — VVS's own VScrollBarButtonSize placement — and draws retail's scrollbar chrome there: the same sprite ids the chat window's transcript and the inventory list already draw through (owner live-client report 2026-09-07: "For scrollable dropdown or the meta window we use the same assets as we do in for example chat or inventory window"). Mouse wheel keeps working as before; the bar itself is also fully interactive (up/down arrow clicks, track paging, and thumb drag). A list whose rows all fit reserves no width and draws no bar at all — the reservation and the chrome both come and go together with actual overflow, never present "just in case."

Backward compatibility

A <list> with no <column> children is byte-for-byte the original single-text-column widget — every existing panel (including every current MossTank tab) keeps working unchanged; <column> is additive, not a migration.

Slider range and scrollable menus (Campaign VT slice 7)

Two small <slider>/<menu> attributes, both closing gaps identified in docs/research/vtank-kb/08-ui-views.md §3 while porting VTank's own Vitals and Profiles tabs.

<slider min max>

VVS's HudHSlider/LinearPositionControl expose an arbitrary Min/Max range (VTank's own nine Vitals sliders are minimum="0" maximum="100"). acdream's <slider> always bound a fixed 0.01.0 scalar; min/max are now optional literal attributes that declare the range the BOUND value/ onchange speak in, while the widget itself keeps working internally in 0.01.0 (drag math, click-to-jump, mouse-wheel are all unchanged):

<slider x="96" y="0" w="144" h="16" min="0" max="100"
        value="{HealPercent}" onchange="{SetHealPercent}"/>

HealPercent/SetHealPercent read and write a plain 0..100 value — no /100f scaling shim in the plugin's own ViewModel. Omitting both attributes (every <slider> written before this slice) keeps the exact historical 0.01.0 identity range — min/max default to 0/1, so (value-0)/(1-0) and 0+t*(1-0) are both no-ops. A declared min == max falls back to a range of 1 rather than dividing by zero.

<menu> is always a single scrolling column

VVS's HudCombo popup is always exactly one scrolling column, at most 10 rows visible before a scrollbar appears (HudCombo.cs:35,102-146). An earlier revision of this slice gated that behind an opt-in scroll="true" attribute; the owner's live-client report on 2026-09-07 ("scrollable dropdowns... use the SAME scrollbar/window assets as we do in for example chat or inventory window") made the scrolling, chrome-matching popup the one and only shape a plugin <menu> draws — MarkupDocument now sets UiMenu.Scrollable = true and applies RetailScrollbarChrome for every <menu> unconditionally, so a VTank Choice with many entries (the 27-option recall menu, a long named-profile list) always keeps VVS's one-column look instead of ever fanning out sideways:

<menu x="188" y="64" w="120" h="22" items="{RouteProfileNames}"
      selected="{SelectedRouteProfile}" onchange="{SelectRouteProfile}"
      rows="7"/>

There is no scroll attribute to set or omit — every <menu>, old and new, gets the scrolling popup.

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. MarkupListColumnsTests covers the Columns extension above: per-column binding-type validation (every throw naming its column by index and type), width semantics ("*" sharing, the last-column-always-auto rule, the overflow clamp), the per-column row-bound click guard, draw-level column-offset/clipping/check-glyph pins against the same recording-renderer apparatus, hit-test routing (text selects unless it has its own onclick; check/icon/onclick-text fire their own callback and never touch selection), a backward-compatibility proof that a column-less <list> is unaffected, and two full MarkupDocument.Build end-to-end tests transcribing VTank's real Monsters- and Meta-tab column shapes. MarkupResizableAnchorTests covers resizable/minw/minh parsing, the anchor grammar (default, every token combination, the unknown-token throw) across every element listed above, live re-layout against the same recording-renderer apparatus (a stretching list, a right-anchored button that moves, a group whose resize propagates to its own anchored children), and a golden proving a panel with none of these attributes draws byte-identically to itself across repeated builds. RetailWindowManagerTests/RetailWindowLayoutPersistenceTests cover a resizable markup panel through the real ResizeTo/save-restore paths (accepts within minw/minh, a fixed panel refuses, a restored size below the CURRENT floor clamps up to it).