diff --git a/src/AcDream.App/Plugins/AppAutomationSurface.cs b/src/AcDream.App/Plugins/AppAutomationSurface.cs
index 40ef89c9..f000d31b 100644
--- a/src/AcDream.App/Plugins/AppAutomationSurface.cs
+++ b/src/AcDream.App/Plugins/AppAutomationSurface.cs
@@ -1,3 +1,4 @@
+using AcDream.Core.Chat;
using AcDream.Core.Player;
using AcDream.Core.Spells;
using AcDream.Plugin.Abstractions;
@@ -25,11 +26,13 @@ namespace AcDream.App.Plugins;
///
///
internal sealed class AppAutomationSurface
- : IAutomationSurface, ICharacterInfo, ISpellCatalog, IMagicCommands, IDisposable
+ : IAutomationSurface, ICharacterInfo, ISpellCatalog, IMagicCommands, IPluginChat,
+ IDisposable
{
private readonly object _gate = new();
private GameRuntime? _runtime;
+ private RuntimeCommunicationState? _communication;
private RuntimeCharacterState? _character;
private RuntimeSpellCastState? _cast;
private Spellbook? _spellbook;
@@ -74,6 +77,7 @@ internal sealed class AppAutomationSurface
public ICharacterInfo Character => this;
public ISpellCatalog Spells => this;
public IMagicCommands Magic => this;
+ public IPluginChat Chat => this;
/// Bind the surface to the runtime's gameplay owners.
public void Bind(
@@ -90,6 +94,7 @@ internal sealed class AppAutomationSurface
return;
DetachLocked();
_runtime = runtime;
+ _communication = runtime.CommunicationOwner;
_character = character;
_cast = cast;
_spellbook = spellbook;
@@ -132,6 +137,7 @@ internal sealed class AppAutomationSurface
_character = null;
_cast = null;
_runtime = null;
+ _communication = null;
}
private void OnSpellbookChanged() => RebuildSpellbook();
@@ -361,6 +367,22 @@ internal sealed class AppAutomationSurface
return false;
}
+ // ── IPluginChat ───────────────────────────────────────────────────────
+ ///
+ /// Routed to retail's ClientLocal log type (0x1A) — the channel the client
+ /// uses for its own notices. Nothing reaches the server, so a plugin cannot
+ /// accidentally speak in the player's name.
+ ///
+ public void PostSystemMessage(string text)
+ {
+ if (string.IsNullOrEmpty(text))
+ return;
+ RuntimeCommunicationState? communication;
+ lock (_gate)
+ communication = _communication;
+ communication?.AddText(text, RetailLogTextType.ClientLocal);
+ }
+
// ── IMagicCommands ────────────────────────────────────────────────────
public bool IsCasting
{
diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs
index 11445173..6eba7668 100644
--- a/src/AcDream.App/Rendering/GameWindow.cs
+++ b/src/AcDream.App/Rendering/GameWindow.cs
@@ -894,9 +894,32 @@ public sealed class GameWindow :
PublishCompositionOwner(ref _cameraPointerInput, value, "camera pointer input");
void IGameWindowContentEffectsAudioPublication.PublishDatCollection(
- IDatReaderWriter value) =>
+ IDatReaderWriter value)
+ {
PublishCompositionOwner(ref _dats, value, "DAT collection");
+ // Retail skill names for the plugin automation surface, bound here
+ // because this is the moment the dats exist. Reading them earlier in
+ // OnLoad silently produced an empty table -- the composition phase that
+ // publishes the collection had not run yet -- and a plugin then saw
+ // zero skills with no error to explain it.
+ if (_automation is null)
+ return;
+ if (!value.TryGet(0x0E000004u, out var skillTable)
+ || skillTable is null)
+ {
+ Console.Error.WriteLine(
+ "plugin automation: retail SkillTable 0x0E000004 missing; "
+ + "plugins will see unnamed skills");
+ return;
+ }
+
+ var names = new Dictionary(skillTable.Skills.Count);
+ foreach (var entry in skillTable.Skills)
+ names[(uint)entry.Key] = entry.Value.Name;
+ _automation.BindSkillNames(names);
+ }
+
void IGameWindowContentEffectsAudioPublication.PublishPreparedAssetSource(
IPreparedAssetSource value) =>
PublishCompositionOwner(
@@ -1313,20 +1336,6 @@ public sealed class GameWindow :
// the executable's, so the loss is visible on every surface.
WindowIconLoader.Apply(_window!);
- // Retail skill names for the plugin automation surface. Read here
- // rather than at construction because content opens in OnLoad; a
- // plugin showing "Life Magic" instead of "skill 33" needs the same
- // table the character panel uses.
- if (_automation is not null && _dats is not null
- && _dats.TryGet(0x0E000004u, out var skillTable)
- && skillTable is not null)
- {
- var names = new Dictionary(skillTable.Skills.Count);
- foreach (var entry in skillTable.Skills)
- names[(uint)entry.Key] = entry.Value.Name;
- _automation.BindSkillNames(names);
- }
-
GameWindowCompositionPipeline.Run<
GameWindowPlatformResult,
HostInputCameraResult,
diff --git a/src/AcDream.App/UI/MarkupDocument.cs b/src/AcDream.App/UI/MarkupDocument.cs
index 45b119c3..c45d3727 100644
--- a/src/AcDream.App/UI/MarkupDocument.cs
+++ b/src/AcDream.App/UI/MarkupDocument.cs
@@ -18,9 +18,14 @@ public static class MarkupDocument
/// 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.
+ ///
public static UiNineSlicePanel Build(
string xml, object binding, Func 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.
diff --git a/src/AcDream.App/UI/RetailUiRuntime.cs b/src/AcDream.App/UI/RetailUiRuntime.cs
index b27616f1..c7ffb0ff 100644
--- a/src/AcDream.App/UI/RetailUiRuntime.cs
+++ b/src/AcDream.App/UI/RetailUiRuntime.cs
@@ -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}");
diff --git a/src/AcDream.App/UI/UiPanel.cs b/src/AcDream.App/UI/UiPanel.cs
index 9a8ba2aa..f346432f 100644
--- a/src/AcDream.App/UI/UiPanel.cs
+++ b/src/AcDream.App/UI/UiPanel.cs
@@ -78,10 +78,26 @@ public class UiLabel : UiElement
///
public Func? TextSource { get; set; }
+ ///
+ /// 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.
+ ///
+ public UiDatFont? DatFont { get; set; }
+
+ /// Two-plane glyph outline, as retail draws interface text.
+ 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);
+ }
}
///
@@ -104,8 +120,26 @@ public class UiSimpleButton : UiPanel
/// without the binding object touching UI objects.
///
public Func? TextSource { get; set; }
+
+ /// Retail dat font for the caption; see .
+ public UiDatFont? DatFont { get; set; }
+
+ /// Two-plane glyph outline, as retail draws interface text.
+ public bool Outline { get; set; } = true;
+
public event System.Action? Click;
+ ///
+ /// 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. and
+ /// 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.
+ ///
+ 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;
diff --git a/src/AcDream.Plugin.Abstractions/Automation.cs b/src/AcDream.Plugin.Abstractions/Automation.cs
index 10e85642..ab0db948 100644
--- a/src/AcDream.Plugin.Abstractions/Automation.cs
+++ b/src/AcDream.Plugin.Abstractions/Automation.cs
@@ -127,6 +127,17 @@ public interface ISpellCatalog
bool TryGet(uint spellId, out PluginSpellInfo info);
}
+/// Writing to the player's chat window.
+public interface IPluginChat
+{
+ ///
+ /// Post a client-local system line, the channel retail uses for the
+ /// client's own notices. It is local to this client: nothing is sent to the
+ /// server and no other player sees it.
+ ///
+ void PostSystemMessage(string text);
+}
+
/// Casting, with a preflight so a plugin need not guess.
public interface IMagicCommands
{
@@ -158,6 +169,7 @@ public interface IAutomationSurface
ICharacterInfo Character { get; }
ISpellCatalog Spells { get; }
IMagicCommands Magic { get; }
+ IPluginChat Chat { get; }
}
///
@@ -165,7 +177,7 @@ public interface IAutomationSurface
/// and every command refuses, so a plugin can keep one code path.
///
public sealed class NoOpAutomationSurface
- : IAutomationSurface, ICharacterInfo, ISpellCatalog, IMagicCommands
+ : IAutomationSurface, ICharacterInfo, ISpellCatalog, IMagicCommands, IPluginChat
{
public static NoOpAutomationSurface Instance { get; } = new();
@@ -177,6 +189,11 @@ public sealed class NoOpAutomationSurface
public ICharacterInfo Character => this;
public ISpellCatalog Spells => this;
public IMagicCommands Magic => this;
+ public IPluginChat Chat => this;
+
+ public void PostSystemMessage(string text)
+ {
+ }
public bool IsInWorld => false;
public uint CurrentHealth => 0;
diff --git a/src/AcDream.Plugins.MossTank/MossTankPanel.cs b/src/AcDream.Plugins.MossTank/MossTankPanel.cs
index 6d5adc94..82e5984a 100644
--- a/src/AcDream.Plugins.MossTank/MossTankPanel.cs
+++ b/src/AcDream.Plugins.MossTank/MossTankPanel.cs
@@ -94,6 +94,7 @@ internal sealed class MossTankPanel
if (_running)
{
Stop("Stopped.");
+ Announce("Stopped.");
return;
}
@@ -114,6 +115,18 @@ internal sealed class MossTankPanel
? "Checking…"
: $"Buffing 0/{_plan.Count}…";
_host.Log.Info($"MossTank: pass started, {_plan.Count} buff(s) queued");
+ Announce(_plan.Count == 0
+ ? "Buffing — checking what needs recasting."
+ : $"Buffing — {_plan.Count} spell(s) to cast.");
+ }
+
+ ///
+ /// One chat line, tagged so it reads like a client notice rather than
+ /// something the character said.
+ ///
+ private void Announce(string text)
+ {
+ _host.Automation.Chat.PostSystemMessage($"[MossTank] {text}");
}
private void Stop(string status)
@@ -163,6 +176,7 @@ internal sealed class MossTankPanel
{
Stop($"Stalled after {_castThisPass} cast(s).");
_host.Log.Warn("MossTank: pass stalled; stopping");
+ Announce($"Stopped — no progress after {_castThisPass} cast(s).");
return;
}
@@ -182,6 +196,9 @@ internal sealed class MossTankPanel
{
Stop($"Done — {_castThisPass} cast(s).");
_host.Log.Info($"MossTank: pass complete ({_castThisPass} cast)");
+ Announce(_castThisPass == 0
+ ? "Already fully buffed."
+ : $"Finished — {_castThisPass} spell(s) cast.");
return;
}
diff --git a/tests/AcDream.App.Tests/UI/MarkupPanelClickTests.cs b/tests/AcDream.App.Tests/UI/MarkupPanelClickTests.cs
new file mode 100644
index 00000000..9211efca
--- /dev/null
+++ b/tests/AcDream.App.Tests/UI/MarkupPanelClickTests.cs
@@ -0,0 +1,97 @@
+using AcDream.App.UI;
+using Xunit;
+
+namespace AcDream.App.Tests.UI;
+
+///
+/// End-to-end click routing for a markup-built plugin panel: press and release
+/// over the button, through the real dispatcher, and
+/// assert the bound action ran.
+///
+///
+/// Written because a MossTank panel rendered correctly in world and its Buff
+/// button did nothing when clicked. Unit-testing the markup builder proved the
+/// handler was bound; only driving the actual pointer path can show whether the
+/// click reaches it.
+///
+public class MarkupPanelClickTests
+{
+ private sealed class Binding
+ {
+ public int Clicks { get; private set; }
+ public Action Go => () => Clicks++;
+ public bool Shown { get; set; } = true;
+ public string Status => "ok";
+ }
+
+ private const string Markup =
+ ""
+ + " ";
+
+ private static (UiRoot Root, Binding Bound) Mount()
+ {
+ var bound = new Binding();
+ UiNineSlicePanel panel =
+ MarkupDocument.Build(Markup, bound, _ => ((uint)1, 32, 32));
+ var root = new UiRoot { Width = 1280, Height = 720 };
+ root.AddChild(panel);
+ return (root, bound);
+ }
+
+ /// Centre of the button in root space: panel(40,120) + button(12,94) + half.
+ private static (int X, int Y) ButtonCentre() => (40 + 12 + 54, 120 + 94 + 14);
+
+ [Fact]
+ public void ClickingTheButtonRunsTheBoundAction()
+ {
+ var (root, bound) = Mount();
+ (int x, int y) = ButtonCentre();
+
+ root.OnMouseDown(UiMouseButton.Left, x, y);
+ root.OnMouseUp(UiMouseButton.Left, x, y);
+
+ Assert.Equal(1, bound.Clicks);
+ }
+
+ [Fact]
+ public void ThePointerFindsTheButtonAtAll()
+ {
+ // Separated from the click test so a failure says whether the problem is
+ // hit-testing or event dispatch.
+ var (root, _) = Mount();
+ (int x, int y) = ButtonCentre();
+
+ UiElement? hit = root.Pick(x, y);
+
+ Assert.NotNull(hit);
+ Assert.IsType(hit);
+ }
+
+ [Fact]
+ public void ClickingOutsideTheButtonDoesNotRunTheAction()
+ {
+ var (root, bound) = Mount();
+
+ root.OnMouseDown(UiMouseButton.Left, 900, 600);
+ root.OnMouseUp(UiMouseButton.Left, 900, 600);
+
+ Assert.Equal(0, bound.Clicks);
+ }
+
+ [Fact]
+ public void AHiddenPanelSwallowsNothing()
+ {
+ var (root, bound) = Mount();
+ bound.Shown = false;
+ root.Tick(0.016, 0); // visibility source is evaluated on tick
+ (int x, int y) = ButtonCentre();
+
+ root.OnMouseDown(UiMouseButton.Left, x, y);
+ root.OnMouseUp(UiMouseButton.Left, x, y);
+
+ Assert.Equal(0, bound.Clicks);
+ Assert.Null(root.Pick(x, y));
+ }
+}