fix(mosstank): unclickable button, empty skill list, dev font, and chat output

Four defects from the first in-world look, three of them with a definite root
cause rather than a plausible one.

**The Buff button did nothing.** Not a hit-testing problem -- the pointer found
the button perfectly. UiRoot's press handling asks the pressed widget whether
it owns the pointer; a widget that does not claim the press falls through to
"move the ancestor window", and a window drag returns early on release without
ever emitting a Click. UiButton and UiClickablePanel both override
HandlesClick for exactly this reason; UiSimpleButton never did. Latent since
that class was written, and invisible until it was put inside a draggable
window -- which is precisely what a markup plugin panel is.

Found by reproducing it headlessly through the real UiRoot dispatcher rather
than by reasoning about it: MarkupPanelClickTests drives press-and-release over
the button and asserts the bound action ran, with a separate test asserting the
pointer finds the button at all, so a future failure says which half broke.
My earlier guess -- that a modal at character select was swallowing the click
-- was wrong, and the screenshot of the panel live in world disproved it.

**"0 trained skills".** The skill-name table was read in OnLoad *before*
GameWindowCompositionPipeline.Run, which is what publishes the DAT collection,
so _dats was still null, the whole block was skipped, and the surface reported
an empty skill list with nothing to explain it. Bound in PublishDatCollection
instead -- the moment the data exists -- so it cannot run early again whatever
the phase ordering does, and a genuinely missing SkillTable now says so.

**Plugin text used the development bitmap font.** UiLabel and UiSimpleButton
gained a DatFont, and MarkupDocument now takes the retail interface font from
the host, so plugin panels render through the same glyph path (including
retail's two-plane outline) as authored panels.

**MossTank now writes to chat.** New BCL-only IPluginChat routes to retail's
ClientLocal log type (0x1A) -- the channel the client uses for its own notices,
local to this client, so a plugin cannot speak in the player's name. MossTank
announces the start, the finish with a cast count, and a stall.

Not addressed here: the cursor showing blue rather than amber. Traced but not
fixed -- CursorFeedbackController picks the cursor family from combat mode, and
CombatMode.Magic selects the blue Magic cursor where Default is amber. That is
a combat-mode question, unrelated to this change, and worth its own look rather
than a speculative fix folded in here.

Solution builds clean; 14,437 tests pass on the standard hermetic lane filter,
0 failures.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Erik 2026-08-20 18:27:28 +02:00
parent 17ebfc434d
commit b9674b1f1e
8 changed files with 240 additions and 22 deletions

View file

@ -18,9 +18,14 @@ public static class MarkupDocument
/// <param name="binding">Object whose public properties are bound to <c>{PropName}</c> attributes.</param>
/// <param name="resolve">Surface id → (GL handle, width, height) for chrome sprites.</param>
/// <param name="style">Optional controls.ini stylesheet for the title color.</param>
/// <param name="datFont">
/// 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.
/// </param>
public static UiNineSlicePanel Build(
string xml, object binding, Func<uint, (uint, int, int)> resolve,
ControlsIni? style = null)
ControlsIni? style = null, UiDatFont? datFont = null)
{
var root = XDocument.Parse(xml).Root ?? throw new FormatException("empty markup");
if (root.Name.LocalName != "panel")
@ -62,7 +67,10 @@ public static class MarkupDocument
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 });
panel.AddChild(new UiLabel
{
Text = title, Left = 8, Top = 4, TextColor = tc, DatFont = datFont,
});
}
foreach (var el in root.Elements())
@ -102,6 +110,7 @@ public static class MarkupDocument
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"));
@ -133,6 +142,7 @@ public static class MarkupDocument
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.

View file

@ -3853,7 +3853,8 @@ public sealed class RetailUiRuntime : IDisposable
xml,
panel.Binding,
_bindings.Assets.ResolveSprite,
_bindings.Assets.Controls);
_bindings.Assets.Controls,
_bindings.Assets.DefaultFont);
Host.Root.AddChild(element);
_bindings.Plugins.CompleteMount(panel, Host.Root, element);
Console.WriteLine($"[D.2b] plugin UI panel loaded: {panel.MarkupPath}");

View file

@ -78,10 +78,26 @@ public class UiLabel : UiElement
/// </summary>
public Func<string?>? TextSource { get; set; }
/// <summary>
/// Retail dat font. When set the label renders through the same glyph path
/// every authored panel uses, so plugin text matches the rest of the
/// interface instead of falling back to the development bitmap font.
/// </summary>
public UiDatFont? DatFont { get; set; }
/// <summary>Two-plane glyph outline, as retail draws interface text.</summary>
public bool Outline { get; set; } = true;
public UiLabel() { ClickThrough = true; }
protected override void OnDraw(UiRenderContext ctx)
=> ctx.DrawString(TextSource?.Invoke() ?? Text, 0, 0, TextColor);
{
string text = TextSource?.Invoke() ?? Text;
if (DatFont is { } dat)
ctx.DrawStringDat(dat, text, 0, 0, TextColor, Outline);
else
ctx.DrawString(text, 0, 0, TextColor);
}
}
/// <summary>
@ -104,8 +120,26 @@ public class UiSimpleButton : UiPanel
/// without the binding object touching UI objects.
/// </summary>
public Func<string?>? TextSource { get; set; }
/// <summary>Retail dat font for the caption; see <see cref="UiLabel.DatFont"/>.</summary>
public UiDatFont? DatFont { get; set; }
/// <summary>Two-plane glyph outline, as retail draws interface text.</summary>
public bool Outline { get; set; } = true;
public event System.Action? Click;
/// <summary>
/// Without this the button is unclickable inside any draggable window.
/// UiRoot's press handling asks the pressed widget whether it owns the
/// pointer; a widget that does not claim the press falls through to
/// "move the ancestor window", which swallows the release and never emits
/// a Click. <see cref="UiButton"/> and <see cref="UiClickablePanel"/>
/// already declare it — this one did not, which is why a markup plugin
/// panel's button did nothing while its hit-test was perfectly fine.
/// </summary>
public override bool HandlesClick => true;
public UiSimpleButton()
{
BackgroundColor = new Vector4(0.1f, 0.1f, 0.15f, 0.8f);
@ -126,8 +160,19 @@ public class UiSimpleButton : UiPanel
{
base.OnDraw(ctx);
string caption = TextSource?.Invoke() ?? Text;
if (caption.Length == 0 || ctx.DefaultFont is null) return;
if (caption.Length == 0) return;
if (DatFont is { } dat)
{
float datW = dat.MeasureWidth(caption);
ctx.DrawStringDat(
dat, caption,
(Width - datW) * 0.5f, (Height - dat.LineHeight) * 0.5f,
TextColor, Outline);
return;
}
if (ctx.DefaultFont is null) return;
float textW = ctx.DefaultFont.MeasureWidth(caption);
float tx = (Width - textW) * 0.5f;
float ty = (Height - ctx.DefaultFont.LineHeight) * 0.5f;