using System;
using System.Globalization;
using System.Numerics;
using System.Reflection;
using System.Xml.Linq;
using AcDream.Plugin.Abstractions;
namespace AcDream.App.UI;
///
/// Parses our KSML-style panel markup (mirrors retail's ElementDesc fields)
/// into a live subtree. {Binding} attribute
/// values resolve against a supplied object by property name (reflection).
/// This is the format the future LayoutDesc importer will emit. See D.2b spec §7.
///
public static class MarkupDocument
{
// Retail's generic runtime-text tooltip skin. Plugin controls have no
// LayoutDesc of their own, so a tooltip= attribute explicitly opts them
// into the same popup that game-code SetTooltip call sites use.
private const uint RuntimeTooltipRootElementId = 0x10000397u;
private const uint RuntimeTooltipLayoutDid = 0x21000041u;
/// Raw XML markup for a single panel.
/// Object whose public properties are bound to {PropName} attributes.
/// Surface id → (GL handle, width, height) for chrome sprites.
/// Optional controls.ini stylesheet for the title color.
///
/// Retail interface font. Supplied by the host so plugin panels render
/// their text through the same glyph path as authored panels; without it
/// they fall back to the development bitmap font and look foreign.
///
///
/// Slice B (docs/plans/2026-09-06-plugin-shelf-and-dat-icons.md ):
/// resolves <icon> , <button icon> , and
/// <list icons> ids to drawable DAT icons. Null (the
/// default, and what every pre-Slice-B caller still passes) makes those
/// three surfaces resolve to nothing rather than throwing — a panel
/// authored against Slice B markup still loads under a host/test that
/// has not wired icon resolution.
///
public static UiNineSlicePanel Build(
string xml, object binding, Func resolve,
ControlsIni? style = null, UiDatFont? datFont = null,
IMarkupIconResolver? icons = null)
{
var root = XDocument.Parse(xml).Root ?? throw new FormatException("empty markup");
if (root.Name.LocalName != "panel")
throw new FormatException($"root must be , got <{root.Name.LocalName}>");
var panel = new UiNineSlicePanel(resolve)
{
Left = F(root, "x"),
Top = F(root, "y"),
Width = F(root, "w"),
Height = F(root, "h"),
};
// Optional per-window resize-axis lock: resize="x" | "y" | "both" | "none".
string? resize = (string?)root.Attribute("resize");
if (resize is not null)
{
panel.ResizeX = resize is "x" or "both";
panel.ResizeY = resize is "y" or "both";
}
// Panel-level visibility binding: lets a plugin keep its window out of
// the way until it has something to act on (e.g. hidden at character
// select, shown in world).
string? visible = (string?)root.Attribute("visible");
if (visible is not null && IsBinding(visible))
{
PropertyInfo? flag = binding.GetType().GetProperty(visible[1..^1]);
if (flag is null || flag.PropertyType != typeof(bool))
{
throw new FormatException(
$" did not resolve to a bool property "
+ $"on {binding.GetType().Name}");
}
panel.VisibleSource = () => flag.GetValue(binding) is true;
}
string? title = (string?)root.Attribute("title");
if (!string.IsNullOrEmpty(title))
{
Vector4 tc = style is not null && style.TryColor("title", "color", out var c) ? c : Vector4.One;
panel.AddChild(new UiLabel
{
Text = title, Left = 8, Top = 4, TextColor = tc, DatFont = datFont,
});
}
foreach (var el in root.Elements())
AddElement(panel, el, binding, resolve, datFont, icons);
return panel;
}
private static void AddElement(
UiElement parent,
XElement el,
object binding,
Func resolve,
UiDatFont? datFont,
IMarkupIconResolver? icons)
{
switch (el.Name.LocalName)
{
case "group":
var group = new UiPanel
{
Left = F(el, "x"),
Top = F(el, "y"),
Width = F(el, "w"),
Height = F(el, "h"),
BackgroundColor = el.Attribute("background") is null
? Vector4.Zero
: Color((string?)el.Attribute("background")),
BorderColor = el.Attribute("border") is null
? Vector4.Zero
: Color((string?)el.Attribute("border")),
BorderThickness = el.Attribute("border") is null ? 0f : 1f,
// Transparent layout groups do not claim empty space, while
// their interactive descendants remain hittable.
ClickThrough = true,
};
ApplyCommon(group, el, binding);
parent.AddChild(group);
foreach (XElement child in el.Elements())
AddElement(group, child, binding, resolve, datFont, icons);
break;
case "meter":
var cur = BindUint((string?)el.Attribute("cur"), binding);
var max = BindUint((string?)el.Attribute("max"), binding);
var meter = new UiMeter
{
Left = F(el, "x"),
Top = F(el, "y"),
Width = F(el, "w"),
Height = F(el, "h"),
BarColor = Color((string?)el.Attribute("color")),
Fill = BindFloat((string?)el.Attribute("fill"), binding),
Label = () => (cur(), max()) is (uint c, uint m) ? $"{c}/{m}" : null,
Anchors = Anchor((string?)el.Attribute("anchor")),
SpriteResolve = resolve,
BackLeft = Hex((string?)el.Attribute("backleft")),
BackTile = Hex((string?)el.Attribute("backtile")),
BackRight = Hex((string?)el.Attribute("backright")),
FrontLeft = Hex((string?)el.Attribute("frontleft")),
FrontTile = Hex((string?)el.Attribute("fronttile")),
FrontRight = Hex((string?)el.Attribute("frontright")),
};
ApplyCommon(meter, el, binding);
parent.AddChild(meter);
break;
case "label":
// Text may be a literal or a {Binding}. Bound labels re-read
// their property every frame through the Func, so a plugin
// updates its status line by assigning a property rather
// than by touching UI objects from its own thread.
var label = new UiLabel
{
Left = F(el, "x"),
Top = F(el, "y"),
TextSource = BindString((string?)el.Attribute("text"), binding),
DatFont = datFont,
};
if (el.Attribute("color") is not null)
label.TextColor = Color((string?)el.Attribute("color"));
ApplyCommon(label, el, binding);
parent.AddChild(label);
break;
case "button":
// onclick binds to an Action property on the binding
// object. Resolved once at build time: a button whose
// handler silently failed to bind is a bug worth failing
// loudly for, and MarkupDocument.Build is already inside
// the host's try/catch that reports panel load failures.
string? clickName = (string?)el.Attribute("onclick");
Action? onClick = BindAction(clickName, binding);
if (clickName is not null && onClick is null)
{
throw new FormatException(
$" did not resolve to an "
+ $"Action property on {binding.GetType().Name}");
}
// UiSimpleButton, not the dat-sprite UiButton: a plugin
// panel has no LayoutDesc behind it and no StateDesc
// sprites to name, so the plain rect-and-text button is the
// one that can actually render from markup alone.
var button = new UiSimpleButton
{
Left = F(el, "x"),
Top = F(el, "y"),
Width = F(el, "w"),
Height = F(el, "h"),
Text = (string?)el.Attribute("text") ?? string.Empty,
DatFont = datFont,
};
// A bound caption lets the button re-label itself (Buff /
// Stop) from the same binding object.
string? caption = (string?)el.Attribute("text");
if (caption is not null && IsBinding(caption))
button.TextSource = BindString(caption, binding);
if (el.Attribute("color") is not null)
button.TextColor = Color((string?)el.Attribute("color"));
if (el.Attribute("background") is not null)
button.BackgroundColor = Color(
(string?)el.Attribute("background"));
if (el.Attribute("border") is not null)
button.BorderColor = Color(
(string?)el.Attribute("border"));
// Slice B: .
// Review fix round finding 4: the iconkind literal is
// validated here regardless of whether a resolver is
// wired — a typo like iconkind="spel" must throw at
// Build on every host, not only ones with icon support
// turned on. Finding 7: IconSource is only ASSIGNED when
// a resolver exists — UiSimpleButton now reserves its
// icon column whenever IconSource is non-null (see
// UiPanel.cs), so setting it to an always-empty func on
// an icons:null host would permanently reserve a column
// that never draws anything.
string? buttonIcon = (string?)el.Attribute("icon");
if (buttonIcon is not null)
{
string? buttonIconKind = (string?)el.Attribute("iconkind");
ValidateIconKind(buttonIconKind);
// Residual round finding N2: BindUintLiteralOrBinding
// must run UNCONDITIONALLY — same rule as
// ValidateIconKind just above — so a malformed
// icon="{Typo}" throws FormatException at Build on
// every host, not only ones with icons wired. Only
// the ASSIGNMENT onto button.IconSource stays gated
// on icons is not null (finding 7: an always-empty
// IconSource would permanently reserve the icon
// column on a resolver-less host).
Func buttonIconReader =
BindUintLiteralOrBinding(buttonIcon, binding, "button icon");
if (icons is not null)
{
button.IconSource = BuildIconSource(
buttonIconKind,
buttonIconReader,
icons);
}
}
ApplyCommon(button, el, binding);
if (onClick is not null)
button.Click += onClick;
parent.AddChild(button);
break;
case "icon":
{
// Residual round finding N7: unlike /,
// derives its kind from WHICH of did/spell/item is
// set (below) — iconkind is meaningless here and was
// previously silently ignored (a plugin author's
// iconkind="spell" typo on an would
// never do what it looked like it did). Reject it
// loudly instead, same "malformed markup throws at
// Build" rule every other attribute in this grammar
// follows.
if (el.Attribute("iconkind") is not null)
{
throw new FormatException(
"iconkind applies to button and list; icon derives its kind from did/spell/item");
}
string? didAttr = (string?)el.Attribute("did");
string? spellAttr = (string?)el.Attribute("spell");
string? itemAttr = (string?)el.Attribute("item");
int sourceCount = (didAttr is not null ? 1 : 0)
+ (spellAttr is not null ? 1 : 0)
+ (itemAttr is not null ? 1 : 0);
if (sourceCount != 1)
{
throw new FormatException(
" requires exactly one of did/spell/item");
}
string iconKind = didAttr is not null ? "did"
: spellAttr is not null ? "spell"
: "item";
string iconExpression = didAttr ?? spellAttr ?? itemAttr!;
Func iconReader = BindUintLiteralOrBinding(
iconExpression, binding, $"icon {iconKind}");
var icon = new UiMarkupIcon
{
Left = F(el, "x"),
Top = F(el, "y"),
Width = FOr(el, "w", 32f),
Height = FOr(el, "h", 32f),
IconSource = BuildIconSource(iconKind, iconReader, icons),
};
ApplyCommon(icon, el, binding);
// A tooltip needs this element to be a real hit-test
// target — see UiMarkupIcon's own doc comment. Review fix
// round finding 9: an EMPTY tooltip="" must not swallow
// clicks either — match ApplyCommon's own
// !IsNullOrWhiteSpace predicate rather than a bare
// attribute-presence check.
string? iconTooltip = (string?)el.Attribute("tooltip");
if (!string.IsNullOrWhiteSpace(iconTooltip))
icon.ClickThrough = false;
parent.AddChild(icon);
break;
}
case "tab":
string? tabClickName = (string?)el.Attribute("onclick");
Action? tabClick = BindAction(tabClickName, binding);
if (tabClickName is not null && tabClick is null)
{
throw new FormatException(
$" did not resolve to an "
+ $"Action property on {binding.GetType().Name}");
}
var tab = new UiMarkupTabButton
{
Left = F(el, "x"),
Top = F(el, "y"),
Width = F(el, "w"),
Height = F(el, "h"),
Text = (string?)el.Attribute("text") ?? string.Empty,
DatFont = datFont,
SelectedSource = BindRequiredBoolReader(
(string?)el.Attribute("selected"),
binding,
"tab selected"),
};
ApplyCommon(tab, el, binding);
if (tabClick is not null)
tab.Click += tabClick;
parent.AddChild(tab);
break;
case "toggle":
string? toggleClickName = (string?)el.Attribute("onclick");
Action? toggleClick = BindAction(toggleClickName, binding);
if (toggleClickName is not null && toggleClick is null)
{
throw new FormatException(
$" did not resolve to an "
+ $"Action property on {binding.GetType().Name}");
}
string? toggleCaption = (string?)el.Attribute("text");
var toggle = new UiMarkupToggle
{
Left = F(el, "x"),
Top = F(el, "y"),
Width = F(el, "w"),
Height = F(el, "h"),
Text = toggleCaption ?? string.Empty,
TextSource = BindString(toggleCaption, binding),
CheckedSource = BindRequiredBoolReader(
(string?)el.Attribute("checked"),
binding,
"toggle checked"),
DatFont = datFont,
Toggle = toggleClick,
};
if (el.Attribute("color") is not null)
toggle.TextColor = Color((string?)el.Attribute("color"));
ApplyCommon(toggle, el, binding);
parent.AddChild(toggle);
break;
case "slider":
string? changeName = (string?)el.Attribute("onchange");
Action? changed = BindFloatAction(changeName, binding);
if (changeName is not null && changed is null)
{
throw new FormatException(
$" did not resolve to an "
+ $"Action property on {binding.GetType().Name}");
}
var slider = new UiScrollbar
{
Left = F(el, "x"),
Top = F(el, "y"),
Width = F(el, "w"),
Height = F(el, "h"),
Horizontal = true,
SpriteResolve = resolve,
ScalarPositionSource = BindFloat(
(string?)el.Attribute("value"),
binding),
ScalarChanged = changed,
};
RetailScrollbarChrome.ApplyHorizontal(slider);
ApplyCommon(slider, el, binding);
parent.AddChild(slider);
break;
case "field":
string? fieldChangeName = (string?)el.Attribute("onchange");
Action? fieldChanged = BindStringAction(
fieldChangeName,
binding);
if (fieldChangeName is not null && fieldChanged is null)
{
throw new FormatException(
$" did not resolve to an "
+ $"Action property on {binding.GetType().Name}");
}
string? submitName = (string?)el.Attribute("onsubmit");
Action? submitted = BindStringAction(submitName, binding);
if (submitName is not null && submitted is null)
{
throw new FormatException(
$" did not resolve to an "
+ $"Action property on {binding.GetType().Name}");
}
var field = new UiField
{
Left = F(el, "x"),
Top = F(el, "y"),
Width = F(el, "w"),
Height = F(el, "h"),
DatFont = datFont,
BackgroundColor = el.Attribute("background") is null
? new Vector4(0f, 0f, 0f, 0.9f)
: Color((string?)el.Attribute("background")),
TextColor = el.Attribute("color") is null
? new Vector4(0.91f, 0.87f, 0.76f, 1f)
: Color((string?)el.Attribute("color")),
MaxCharacters = Math.Max(1, I(el, "maxlength", 128)),
ClearOnSubmit = B(el, "clearonsubmit", false),
RecordHistory = false,
OnTextChanged = fieldChanged,
OnSubmit = submitted,
};
field.SetText(BindString((string?)el.Attribute("text"), binding)());
ApplyCommon(field, el, binding);
parent.AddChild(field);
break;
case "menu":
string? menuChangeName = (string?)el.Attribute("onchange");
Action? menuChanged = BindStringAction(
menuChangeName,
binding);
if (menuChangeName is not null && menuChanged is null)
{
throw new FormatException(
$" did not resolve to an "
+ $"Action property on {binding.GetType().Name}");
}
Func> menuItems = BindStringList(
(string?)el.Attribute("items"),
binding,
"menu items");
Func menuSelected = BindString(
(string?)el.Attribute("selected"),
binding);
bool menuRetailButtonArt = ValidateMenuStyle((string?)el.Attribute("style"));
var menu = new UiMenu
{
Left = F(el, "x"),
Top = F(el, "y"),
Width = F(el, "w"),
Height = F(el, "h"),
DatFont = datFont,
SpriteResolve = resolve,
RowsPerColumn = Math.Max(1, I(el, "rows", 7)),
RowHeight = Math.Max(12f, FOr(el, "rowheight", 18f)),
ColumnWidth = Math.Max(20f, F(el, "w")),
OpenUpward = B(el, "openupward", false),
TextIndent = 6f,
ButtonTextIndent = 6f,
NormalSprite = 0x06004D65u,
PressedSprite = 0x06004D66u,
PopupBgSprite = 0x0600124Cu,
ItemNormalSprite = 0x0600124Eu,
ItemHighlightSprite = 0x0600124Du,
RetailButtonArt = menuRetailButtonArt,
// Owner live-client report 2026-09-07: a plugin dropdown
// scrolls a single column (VTank's own HudCombo shape)
// rather than wrapping into more grid columns once it
// overflows its "rows" window; the scrollbar itself is
// hidden entirely (0x79 semantics) while everything
// fits, matching retail's vendor category popup.
Scrollable = true,
PopupScrollbarHideWhenDisabled = true,
ButtonLabelProvider = () => menuSelected() ?? string.Empty,
OnSelect = payload =>
{
if (payload is string value)
menuChanged?.Invoke(value);
},
};
// The popup's own scrollbar always draws retail's chrome —
// "we use the same assets as we do in for example chat or
// inventory window" — regardless of RetailButtonArt (the
// owner's earlier plain-row directive only ever covered the
// ROWS, never this bar).
RetailScrollbarChrome.ApplyToMenuPopup(menu);
void RefreshMenu()
{
menu.Items = menuItems()
.Select(static value => new UiMenu.MenuItem(value, value))
.ToArray();
menu.Selected = menuSelected();
}
RefreshMenu();
menu.BeforeOpen = RefreshMenu;
ApplyCommon(menu, el, binding);
parent.AddChild(menu);
break;
case "list":
string? listChangeName = (string?)el.Attribute("onchange");
Action? listChanged = BindIntAction(listChangeName, binding);
if (listChangeName is not null && listChanged is null)
{
throw new FormatException(
$" did not resolve to an "
+ $"Action property on {binding.GetType().Name}");
}
// Campaign VT slice 1 Part B:
// (docs/research/vtank-kb/08-ui-views.md §3). A non-
// child is always malformed — the element previously had no
// children at all, so this is purely additive.
var listChildren = el.Elements().ToList();
foreach (var child in listChildren)
{
if (child.Name.LocalName != "column")
{
throw new FormatException(
$" children must all be , got <{child.Name.LocalName}>");
}
}
bool listUsesColumns = listChildren.Count > 0;
if (listUsesColumns
&& (el.Attribute("items") is not null
|| el.Attribute("icons") is not null
|| el.Attribute("colors") is not null))
{
throw new FormatException(
" with children cannot also use the "
+ "items/icons/colors attributes (Slice B's own single-column "
+ "form) — express every row source as a instead");
}
var list = new UiMarkupList
{
Left = F(el, "x"),
Top = F(el, "y"),
Width = F(el, "w"),
Height = F(el, "h"),
RowHeight = Math.Max(12f, FOr(el, "rowheight", 18f)),
DatFont = datFont,
// Owner live-client report 2026-09-07: an overflowing
// draws the same retail scrollbar chrome the chat
// window and inventory use — resolved through the same
// sprite resolver every other markup sink already uses.
SpriteResolve = resolve,
SelectedIndexSource = BindRequiredIntReader(
(string?)el.Attribute("selected"),
binding,
"list selected"),
SelectionChanged = listChanged,
};
if (listUsesColumns)
{
int lastColumnIndex = listChildren.Count - 1;
list.Columns = listChildren
.Select((columnEl, index) => BuildListColumn(
columnEl, binding, icons, index, index == lastColumnIndex))
.ToList();
}
else
{
list.ItemsSource = BindStringList(
(string?)el.Attribute("items"),
binding,
"list items");
list.ItemColorsSource = BindUintList(
(string?)el.Attribute("colors"),
binding,
"list colors");
// Slice B: .
// Same two rules as above: iconkind validates
// regardless of resolver wiring (finding 4), and
// IconIdsSource/IconResolve are only set when a resolver
// exists (finding 7) — UiMarkupList already reserves its
// icon column whenever IconIdsSource is non-null.
string? listIcons = (string?)el.Attribute("icons");
if (!string.IsNullOrWhiteSpace(listIcons))
{
string? listIconKind = (string?)el.Attribute("iconkind");
ValidateIconKind(listIconKind);
// Residual round finding N2: same rule as the button's
// icon reader above — BindUintList must run
// UNCONDITIONALLY so icons="notabinding" (a malformed,
// non-{Binding} literal — list icons has no literal
// grammar) throws FormatException at Build even with no
// resolver wired. Only the assignment stays gated.
Func> listIconIdsReader =
BindUintList(listIcons, binding, "list icons");
if (icons is not null)
{
list.IconIdsSource = listIconIdsReader;
list.IconResolve = BuildRowIconResolve(listIconKind, icons);
}
}
}
ApplyCommon(list, el, binding);
parent.AddChild(list);
break;
default:
// Review fix round finding 11: an unknown or miscased
// element name previously vanished silently (the switch had
// no default arm) — the same "malformed markup throws at
// Build" rule every other element already follows.
throw new FormatException($"unknown element <{el.Name.LocalName}>");
}
}
///
/// Review fix round finding 4: validates an iconkind attribute
/// (default "did" ) UNCONDITIONALLY — before either
/// or 's
/// null-resolver early return, so iconkind="spel" throws
/// at Build on every host, even one
/// with no wired at all. A malformed
/// attribute is a Build-time author error regardless of what the host
/// happens to support.
///
///
/// Fix round item 5: (default "iconkind"
/// for the non-column call sites — <icon> , <button
/// icon> , <list icons> ) prefixes the throw message so
/// <column type="icon"> 's own call site can identify which
/// column failed (column[2] type="icon" iconkind ).
///
///
private static string ValidateIconKind(string? iconKind, string context = "iconkind") =>
(iconKind ?? "did") switch
{
"did" or "spell" or "item" => iconKind ?? "did",
var other => throw new FormatException(
$"{context} must be did, spell, or item (got \"{other}\")"),
};
///
/// Owner live-client report 2026-09-07 ("Those BIG gold/yellow buttons HAS
/// to go. That is not how vtank looks."): validates <menu
/// style="..."> and returns the
/// value it selects. Default (attribute absent, or explicit
/// style="plain" ) is the flat VTank/Decal HudCombo box
/// (false ) — retail's gold pushbutton art is now an explicit
/// style="retail" opt-in for a plugin panel that genuinely wants
/// it. Any other value is a Build-time author error, same rule as
/// .
///
private static bool ValidateMenuStyle(string? style) => style switch
{
null or "plain" => false,
"retail" => true,
var other => throw new FormatException(
$" must be plain or retail"),
};
///
/// Builds the zero-argument icon resolver the <icon> element
/// uses: dispatch by iconkind (default "did" ) to the
/// matching method, normalizing
/// did through (spell/item ids
/// are never DAT RenderSurface DIDs, so they never pass through it).
/// Null (no resolver wired) always resolves to
/// nothing rather than throwing — a standalone <icon> draws
/// nothing either way, so there is no column-reservation concern here
/// the way there is for <button icon> /<list icons>
/// (see their own call sites in ).
///
private static Func<(uint tex, int w, int h)> BuildIconSource(
string? iconKind, Func idReader, IMarkupIconResolver? icons)
{
string kind = ValidateIconKind(iconKind);
if (icons is null)
return static () => (0u, 0, 0);
return kind switch
{
"did" => () => icons.ResolveDid(
PluginIcons.Normalize(idReader())),
"spell" => () => icons.ResolveSpell(idReader()),
"item" => () => icons.ResolveItem(idReader()),
_ => throw new InvalidOperationException(
"unreachable — ValidateIconKind already rejected anything else"),
};
}
///
/// Same dispatch as , shaped for
/// <list icons> 's per-row resolve (the row's own icon id is
/// the argument rather than a captured reader). Callers only invoke this
/// after confirming is non-null (see the
/// <list> case in ) so
/// is never set to an
/// always-empty delegate.
///
private static Func BuildRowIconResolve(
string? iconKind, IMarkupIconResolver icons)
{
string kind = ValidateIconKind(iconKind);
return kind switch
{
"did" => id => icons.ResolveDid(
PluginIcons.Normalize(id)),
"spell" => icons.ResolveSpell,
"item" => icons.ResolveItem,
_ => throw new InvalidOperationException(
"unreachable — ValidateIconKind already rejected anything else"),
};
}
///
/// Resolves a did /spell /item attribute to a live
/// reader: a {Prop} binding re-reads a property
/// every frame; a literal accepts hex (0x... ) or decimal, matching
/// every other markup id attribute's "malformed literal throws at Build"
/// rule.
///
///
/// Review fix round finding 5: accepts ANY integral property type
/// ( , , ,
/// , a nullable of any of those, …), not only an
/// exact match — matching 's own
/// leniency below. Decal-facing bindings are commonly int end to
/// end (e.g. MosswartMassacre's HudPictureBox.Image ), so requiring
/// a literal uint property rejected every one of them at Build. A
/// property whose runtime value cannot convert (a non-numeric type) still
/// throws — just from
/// at read time rather than a type check at Build, the same tradeoff
/// already makes.
///
///
/// Residual round finding N3: a NEGATIVE bound value (Decal's own
/// convention for "no icon" — e.g. HudPictureBox.Image = -1 ) used
/// to throw straight out of
/// — every frame,
/// from inside UiSimpleButton.OnDraw . DECIDED: any value that does
/// not fit in a — negative, or above
/// — maps to 0u (draws nothing) rather
/// than throwing at draw time.
/// already throws exactly for both of
/// those cases (never for a non-numeric type, which still throws
/// / as
/// before), so catching only that one exception type is sufficient.
///
private static Func BindUintLiteralOrBinding(
string expression, object binding, string context)
{
if (!IsBinding(expression))
{
uint literal = ParseUintLiteral(expression, context);
return () => literal;
}
PropertyInfo? property = binding.GetType().GetProperty(expression[1..^1]);
if (property is null)
{
throw new FormatException(
$"{expression} did not resolve to a property on "
+ binding.GetType().Name + $" ({context})");
}
return () => property.GetValue(binding) switch
{
uint u => u,
null => 0u,
var v => ToUintOrZero(v),
};
}
///
/// , mapping an
/// out-of-range value (negative, or above ) to
/// 0u instead of propagating . See
/// 's finding N3 remark.
///
private static uint ToUintOrZero(object value)
{
try
{
return Convert.ToUInt32(value, CultureInfo.InvariantCulture);
}
catch (OverflowException)
{
return 0u;
}
}
private static uint ParseUintLiteral(string text, string context)
{
string trimmed = text.Trim();
if (trimmed.StartsWith("0x", StringComparison.OrdinalIgnoreCase))
{
if (uint.TryParse(trimmed.AsSpan(2), NumberStyles.HexNumber,
CultureInfo.InvariantCulture, out uint hex))
return hex;
}
else if (uint.TryParse(trimmed, NumberStyles.Integer,
CultureInfo.InvariantCulture, out uint dec))
{
return dec;
}
throw new FormatException($"{context}=\"{text}\" is not a valid uint literal");
}
///
/// Resolves {PropName} to a live string reader, or returns the
/// literal text unchanged. The indirection matters: binding to a
/// rather than copying the value once is what makes a
/// plugin's status text update as its state changes.
///
private static Func BindString(string? attribute, object binding)
{
if (attribute is null)
return static () => null;
if (!IsBinding(attribute))
return () => attribute;
string name = attribute[1..^1];
PropertyInfo? property = binding.GetType().GetProperty(name);
if (property is null)
return () => attribute;
return () => property.GetValue(binding)?.ToString();
}
/// Resolves {PropName} to an property.
private static Action? BindAction(string? attribute, object binding)
{
if (attribute is null || !IsBinding(attribute))
return null;
string name = attribute[1..^1];
PropertyInfo? property = binding.GetType().GetProperty(name);
if (property is null || !typeof(Action).IsAssignableFrom(property.PropertyType))
return null;
// Read through on each click rather than capturing the delegate now, so
// a binding object may swap its handler (or null it out while busy)
// without rebuilding the panel.
return () => (property.GetValue(binding) as Action)?.Invoke();
}
private static Action? BindFloatAction(
string? attribute,
object binding)
{
if (attribute is null || !IsBinding(attribute))
return null;
string name = attribute[1..^1];
PropertyInfo? property = binding.GetType().GetProperty(name);
if (property is null
|| !typeof(Action).IsAssignableFrom(property.PropertyType))
return null;
return value => (property.GetValue(binding) as Action)?.Invoke(value);
}
private static Action? BindStringAction(
string? attribute,
object binding)
{
if (attribute is null || !IsBinding(attribute))
return null;
string name = attribute[1..^1];
PropertyInfo? property = binding.GetType().GetProperty(name);
if (property is null
|| !typeof(Action).IsAssignableFrom(property.PropertyType))
{
return null;
}
return value => (property.GetValue(binding) as Action)?.Invoke(value);
}
private static Action? BindIntAction(string? attribute, object binding)
{
if (attribute is null || !IsBinding(attribute))
return null;
PropertyInfo? property = binding.GetType().GetProperty(attribute[1..^1]);
if (property is null
|| !typeof(Action).IsAssignableFrom(property.PropertyType))
{
return null;
}
return value => (property.GetValue(binding) as Action)?.Invoke(value);
}
private static Func> BindStringList(
string? expression,
object binding,
string context)
{
if (string.IsNullOrWhiteSpace(expression) || !IsBinding(expression))
throw new FormatException($"{context} must be a string-list binding");
PropertyInfo? property = binding.GetType().GetProperty(expression[1..^1]);
if (property is null
|| !typeof(IEnumerable).IsAssignableFrom(property.PropertyType))
{
throw new FormatException(
$"{expression} did not resolve to an IEnumerable property on "
+ binding.GetType().Name);
}
return () => property.GetValue(binding) is IEnumerable values
? values.ToArray()
: Array.Empty();
}
///
/// Review fix round finding 5: also accepts
/// of — Decal is int end to end
/// (MosswartMassacre's FlagTrackerView.cs feeds
/// HudPictureBox.Image from int ids), so a plugin porting
/// that convention hands the host IEnumerable<int> , not
/// IEnumerable<uint> .
///
///
/// Residual round finding N3: the per-element conversion used to be an
/// UNCHECKED reinterpret (-1 silently wrapped to 0xFFFFFFFF
/// — a bogus, almost-certainly-unresolvable id drawn as if it were a real
/// one, rather than the "no icon" 0u the same negative value maps
/// to on the scalar path — see ). DECIDED: a
/// negative element now maps to 0u here too, matching the scalar
/// path's contract exactly (both "no icon" conventions agree).
///
private static Func> BindUintList(
string? expression,
object binding,
string context)
{
if (string.IsNullOrWhiteSpace(expression))
return static () => Array.Empty();
if (!IsBinding(expression))
throw new FormatException($"{context} must be a uint-list binding");
PropertyInfo? property = binding.GetType().GetProperty(expression[1..^1]);
if (property is null)
{
throw new FormatException(
$"{expression} did not resolve to an IEnumerable or "
+ "IEnumerable property on " + binding.GetType().Name);
}
if (typeof(IEnumerable).IsAssignableFrom(property.PropertyType))
{
return () => property.GetValue(binding) is IEnumerable values
? values.ToArray()
: Array.Empty();
}
if (typeof(IEnumerable).IsAssignableFrom(property.PropertyType))
{
return () => property.GetValue(binding) is IEnumerable values
? values.Select(static v => v < 0 ? 0u : (uint)v).ToArray()
: Array.Empty();
}
throw new FormatException(
$"{expression} did not resolve to an IEnumerable or "
+ "IEnumerable property on " + binding.GetType().Name);
}
///
/// <column type="check" values="{IReadOnlyList<bool>}">
/// (Campaign VT slice 1 Part B). Required — unlike 's
/// "silent if omitted" carve-out for the optional list colors
/// attribute, a check column with no values binding is a Build-time
/// author error (there is nothing sensible to draw).
///
private static Func> BindBoolList(
string? expression, object binding, string context)
{
if (string.IsNullOrWhiteSpace(expression) || !IsBinding(expression))
throw new FormatException($"{context} must be a bool-list binding");
PropertyInfo? property = binding.GetType().GetProperty(expression[1..^1]);
if (property is null
|| !typeof(IEnumerable).IsAssignableFrom(property.PropertyType))
{
throw new FormatException(
$"{expression} did not resolve to an IEnumerable property on "
+ binding.GetType().Name + $" ({context})");
}
return () => property.GetValue(binding) is IEnumerable values
? values.ToArray()
: Array.Empty();
}
///
/// Same grammar as but REQUIRED — used by
/// <column type="icon" values="..."> , where (unlike the
/// single-column list's optional icons attribute) there is no
/// "no icon column at all" fallback: an icon column with no
/// values binding is a Build-time author error.
///
private static Func> BindRequiredUintList(
string? expression, object binding, string context)
{
if (string.IsNullOrWhiteSpace(expression))
throw new FormatException($"{context} must be a uint-list binding");
return BindUintList(expression, binding, context);
}
///
/// <column> 's onchange (check)/onclick (icon) —
/// unlike every other Action<int> sink in this file (the
/// list's own onchange , which is optional), a column callback is
/// REQUIRED: a check/icon column that never fires anything is a
/// Build-time author error, not a silently-inert control.
///
private static Action BindRequiredIntAction(
string? attribute, object binding, string context)
{
if (attribute is null || !IsBinding(attribute))
throw new FormatException($"{context} must be an Action binding");
PropertyInfo? property = binding.GetType().GetProperty(attribute[1..^1]);
if (property is null || !typeof(Action).IsAssignableFrom(property.PropertyType))
{
throw new FormatException(
$"{attribute} did not resolve to an Action property on "
+ binding.GetType().Name + $" ({context})");
}
return value => (property.GetValue(binding) as Action)?.Invoke(value);
}
///
/// Builds one from a <column>
/// child of <list> (Campaign VT slice 1 Part B —
/// docs/research/vtank-kb/08-ui-views.md §3's proposed extension).
/// follows the same "validate iconkind
/// unconditionally, wire the resolver only when one exists" rule as the
/// legacy <list icons> path ( 's
/// own call site above): a malformed iconkind throws at Build even
/// on a resolver-less host, but
/// stays null (draws nothing) rather than ever pointing at a null resolver.
///
private static UiMarkupListColumn BuildListColumn(
XElement columnEl, object binding, IMarkupIconResolver? icons, int index, bool isLast)
{
string? type = (string?)columnEl.Attribute("type");
(float width, bool isAutoWidth) = ParseColumnWidth(columnEl, index, type, isLast);
switch (type)
{
case "text":
{
var textSource = BindStringList(
(string?)columnEl.Attribute("items"), binding, ColumnContext(index, "text", "items"));
string? colorsAttr = (string?)columnEl.Attribute("colors");
Func>? colorsSource = colorsAttr is null
? null
: BindUintList(colorsAttr, binding, ColumnContext(index, "text", "colors"));
// Fix round finding 1: optional onclick — a text cell that
// declares one fires it with the row index instead of
// selecting; one that doesn't keeps the original
// select-on-click behavior. Same "resolve if binding-shaped,
// throw only if malformed" rule as the list's own onchange
// above — omitting the attribute entirely is fine.
string? textOnClickAttr = (string?)columnEl.Attribute("onclick");
Action? textOnClick = BindIntAction(textOnClickAttr, binding);
if (textOnClickAttr is not null && textOnClick is null)
{
throw new FormatException(
$"{ColumnContext(index, "text", "onclick")} did not resolve to an "
+ $"Action property on {binding.GetType().Name}");
}
return UiMarkupListColumn.Text(width, textSource, colorsSource, textOnClick, isAutoWidth);
}
case "check":
{
var checkSource = BindBoolList(
(string?)columnEl.Attribute("values"), binding, ColumnContext(index, "check", "values"));
var onChange = BindRequiredIntAction(
(string?)columnEl.Attribute("onchange"), binding, ColumnContext(index, "check", "onchange"));
return UiMarkupListColumn.Check(width, checkSource, onChange, isAutoWidth);
}
case "icon":
{
var valuesSource = BindRequiredUintList(
(string?)columnEl.Attribute("values"), binding, ColumnContext(index, "icon", "values"));
string? iconKind = (string?)columnEl.Attribute("iconkind");
ValidateIconKind(iconKind, ColumnContext(index, "icon", "iconkind"));
var onClick = BindRequiredIntAction(
(string?)columnEl.Attribute("onclick"), binding, ColumnContext(index, "icon", "onclick"));
Func? resolve = icons is not null
? BuildRowIconResolve(iconKind, icons)
: null;
return UiMarkupListColumn.Icon(width, valuesSource, resolve, onClick, isAutoWidth);
}
default:
throw new FormatException(
$"column[{index}] has unknown type=\"{type}\" (expected text, check, or icon)");
}
}
///
/// Fix round finding 5: every column-attribute throw message identifies
/// the offending column by position and declared type
/// (column[2] type="check" values ) rather than the generic
/// "column values" the initial slice used — a plugin author with
/// several columns of the same type needs the index to find which
/// one is wrong.
///
private static string ColumnContext(int index, string type, string attribute) =>
$"column[{index}] type=\"{type}\" {attribute}";
///
/// Fix round item 2: <column width> semantics. "*"
/// (any column, including the last) means auto — this column shares the
/// list's remaining width equally with every other auto column at
/// layout time (see 's column-layout helper).
/// A NON-last column with a missing, unparseable, or non-positive width
/// is a Build-time author error (there is nothing sensible to lay out).
/// The LAST column is exempt from this validation entirely — it always
/// absorbs whatever room remains regardless of its own declared width,
/// so an invalid value there is harmless and never thrown; only an
/// explicit "*" there is actually meaningful (it makes the last
/// column share evenly with any OTHER auto columns instead of taking
/// 100% of the remainder alone — see 's
/// own doc for exactly how the last column's implicit auto-ness
/// combines with an explicit one).
///
private static (float width, bool isAutoWidth) ParseColumnWidth(
XElement columnEl, int index, string? type, bool isLast)
{
string? raw = (string?)columnEl.Attribute("width");
if (raw == "*")
return (0f, true);
if (isLast)
return (F(columnEl, "width"), false);
if (!float.TryParse(raw, NumberStyles.Float, CultureInfo.InvariantCulture, out float width)
|| width <= 0f)
{
throw new FormatException(
$"{ColumnContext(index, type ?? "(missing)", "width")} must be a positive "
+ "number or \"*\", got " + (raw is null ? "(missing)" : $"\"{raw}\""));
}
return (width, false);
}
private static bool IsBinding(string value) =>
value.Length > 2 && value[0] == '{' && value[^1] == '}';
private static void ApplyCommon(
UiElement element,
XElement source,
object binding)
{
element.Name = (string?)source.Attribute("name")
?? (string?)source.Attribute("id");
BindBool((string?)source.Attribute("visible"), binding,
value => element.Visible = value,
sourceReader => element.VisibleSource = sourceReader);
BindBool((string?)source.Attribute("enabled"), binding,
value => element.Enabled = value,
sourceReader => element.EnabledSource = sourceReader);
string? tooltip = (string?)source.Attribute("tooltip");
if (!string.IsNullOrWhiteSpace(tooltip))
{
element.RuntimeTooltipTextSource = BindString(tooltip, binding);
element.AuthoredTooltipRootElementId = RuntimeTooltipRootElementId;
element.AuthoredTooltipLayoutDid = RuntimeTooltipLayoutDid;
element.AuthoredTooltipEnabled = true;
}
}
private static void BindBool(
string? expression,
object binding,
Action setLiteral,
Action> setSource)
{
if (string.IsNullOrWhiteSpace(expression))
return;
if (!IsBinding(expression))
{
if (bool.TryParse(expression, out bool literal))
setLiteral(literal);
return;
}
PropertyInfo? property = binding.GetType().GetProperty(expression[1..^1]);
if (property is null || property.PropertyType != typeof(bool))
{
throw new FormatException(
$"{expression} did not resolve to a bool property on "
+ binding.GetType().Name);
}
setSource(() => property.GetValue(binding) is true);
}
private static Func BindRequiredBoolReader(
string? expression,
object binding,
string context)
{
if (string.IsNullOrWhiteSpace(expression) || !IsBinding(expression))
throw new FormatException($"{context} must be a bool binding");
PropertyInfo? property = binding.GetType().GetProperty(expression[1..^1]);
if (property is null || property.PropertyType != typeof(bool))
{
throw new FormatException(
$"{expression} did not resolve to a bool property on "
+ binding.GetType().Name);
}
return () => property.GetValue(binding) is true;
}
private static Func BindRequiredIntReader(
string? expression,
object binding,
string context)
{
if (string.IsNullOrWhiteSpace(expression) || !IsBinding(expression))
throw new FormatException($"{context} must be an int binding");
PropertyInfo? property = binding.GetType().GetProperty(expression[1..^1]);
if (property is null || property.PropertyType != typeof(int))
{
throw new FormatException(
$"{expression} did not resolve to an int property on "
+ binding.GetType().Name);
}
return () => property.GetValue(binding) is int value ? value : -1;
}
private static float F(XElement e, string attr)
=> float.TryParse((string?)e.Attribute(attr), NumberStyles.Float,
CultureInfo.InvariantCulture, out var v) ? v : 0f;
private static float FOr(XElement e, string attr, float fallback)
=> float.TryParse((string?)e.Attribute(attr), NumberStyles.Float,
CultureInfo.InvariantCulture, out float value) ? value : fallback;
private static int I(XElement e, string attr, int fallback)
=> int.TryParse((string?)e.Attribute(attr), NumberStyles.Integer,
CultureInfo.InvariantCulture, out int value) ? value : fallback;
private static bool B(XElement e, string attr, bool fallback)
=> bool.TryParse((string?)e.Attribute(attr), out bool value)
? value
: fallback;
///
/// Parses #AARRGGBB → RGBA (alpha first, matching
/// controls.ini convention). Falls back to opaque white on bad input.
///
private static Vector4 Color(string? hex)
{
if (hex is { Length: 9 } && hex[0] == '#'
&& uint.TryParse(hex.AsSpan(1), NumberStyles.HexNumber,
CultureInfo.InvariantCulture, out uint argb))
return new Vector4(
((argb >> 16) & 0xFF) / 255f,
((argb >> 8) & 0xFF) / 255f,
(argb & 0xFF) / 255f,
((argb >> 24) & 0xFF) / 255f);
return Vector4.One;
}
private static Func BindFloat(string? expr, object binding)
{
var pi = Prop(expr, binding);
if (pi is null) return () => 0f;
return () => pi.GetValue(binding) switch
{
float f => f,
null => (float?)null,
var v => Convert.ToSingle(v, CultureInfo.InvariantCulture),
};
}
private static Func BindUint(string? expr, object binding)
{
var pi = Prop(expr, binding);
if (pi is null) return () => null;
return () => pi.GetValue(binding) switch
{
uint u => u,
null => (uint?)null,
var v => Convert.ToUInt32(v, CultureInfo.InvariantCulture),
};
}
private static PropertyInfo? Prop(string? expr, object binding)
{
if (expr is null || expr.Length < 3 || expr[0] != '{' || expr[^1] != '}') return null;
return binding.GetType().GetProperty(expr[1..^1]);
}
private static uint Hex(string? s)
{
if (string.IsNullOrWhiteSpace(s)) return 0;
var t = s.Trim();
if (t.StartsWith("0x", System.StringComparison.OrdinalIgnoreCase)) t = t[2..];
return uint.TryParse(t, System.Globalization.NumberStyles.HexNumber,
System.Globalization.CultureInfo.InvariantCulture, out var v) ? v : 0u;
}
private static AnchorEdges Anchor(string? csv)
{
if (string.IsNullOrWhiteSpace(csv)) return AnchorEdges.Left | AnchorEdges.Top;
var a = AnchorEdges.None;
foreach (var part in csv.Split(',', System.StringSplitOptions.TrimEntries | System.StringSplitOptions.RemoveEmptyEntries))
a |= part.ToLowerInvariant() switch
{
"left" => AnchorEdges.Left,
"top" => AnchorEdges.Top,
"right" => AnchorEdges.Right,
"bottom" => AnchorEdges.Bottom,
_ => AnchorEdges.None,
};
return a == AnchorEdges.None ? AnchorEdges.Left | AnchorEdges.Top : a;
}
}