diff --git a/AcDream.slnx b/AcDream.slnx
index b1153876..34d093fb 100644
--- a/AcDream.slnx
+++ b/AcDream.slnx
@@ -11,6 +11,7 @@
+
@@ -46,6 +47,7 @@
+
diff --git a/src/AcDream.App/AcDream.App.csproj b/src/AcDream.App/AcDream.App.csproj
index 77745cf1..d2137f6a 100644
--- a/src/AcDream.App/AcDream.App.csproj
+++ b/src/AcDream.App/AcDream.App.csproj
@@ -146,4 +146,53 @@
Overwrite="true"
Lines="{ "id": "acdream.smoke", "displayName": "Smoke Plugin", "version": "0.1.0", "entryDll": "AcDream.Plugins.Smoke.dll", "apiVersion": 1 }" />
+
+
+
+
+ false
+ true
+
+
+
+
+ <_MossTankSourceDir>$(MSBuildProjectDirectory)/../AcDream.Plugins.MossTank/bin/$(Configuration)/$(TargetFramework)
+ <_MossTankSourceDir Condition="'$(RuntimeIdentifier)' != ''">$(_MossTankSourceDir)/$(RuntimeIdentifier)
+ <_MossTankDestDir>$(OutputPath)plugins/AcDream.Plugins.MossTank
+
+
+
+
+
+
+
+
+ <_MossTankPublishSourceDir>$(MSBuildProjectDirectory)/../AcDream.Plugins.MossTank/bin/$(Configuration)/$(TargetFramework)
+ <_MossTankPublishSourceDir Condition="'$(RuntimeIdentifier)' != ''">$(_MossTankPublishSourceDir)/$(RuntimeIdentifier)
+ <_MossTankPublishDestDir>$(PublishDir)plugins/AcDream.Plugins.MossTank
+
+
+
+
+
diff --git a/src/AcDream.App/Plugins/AppAutomationSurface.cs b/src/AcDream.App/Plugins/AppAutomationSurface.cs
new file mode 100644
index 00000000..c395fb94
--- /dev/null
+++ b/src/AcDream.App/Plugins/AppAutomationSurface.cs
@@ -0,0 +1,275 @@
+using AcDream.Core.Player;
+using AcDream.Core.Spells;
+using AcDream.Plugin.Abstractions;
+using AcDream.Runtime.Gameplay;
+
+namespace AcDream.App.Plugins;
+
+///
+/// Projects the canonical Runtime gameplay owners into the BCL-only plugin
+/// automation surface.
+///
+///
+///
+/// Owns nothing: the character state and cast state are borrowed from
+/// GameRuntime and rebound per session, matching how every other
+/// graphical projection treats Runtime owners. Between sessions the surface
+/// reports false and every command refuses, rather
+/// than throwing at a plugin that ticked one frame late.
+///
+///
+/// The two snapshot lists are rebuilt on Spellbook change notifications rather
+/// than per read, because a plugin ticking each frame would otherwise force a
+/// full spellbook walk 60 times a second for data that changes rarely.
+///
+///
+internal sealed class AppAutomationSurface
+ : IAutomationSurface, ICharacterInfo, ISpellCatalog, IMagicCommands, IDisposable
+{
+ private readonly object _gate = new();
+
+ private RuntimeCharacterState? _character;
+ private RuntimeSpellCastState? _cast;
+ private Spellbook? _spellbook;
+ private bool _disposed;
+
+ private IReadOnlyList _knownSelfBuffs = Array.Empty();
+ private IReadOnlyList _enchantments =
+ Array.Empty();
+
+ public bool IsAvailable
+ {
+ get
+ {
+ lock (_gate)
+ return !_disposed && _character is not null && _cast is not null;
+ }
+ }
+
+ public ICharacterInfo Character => this;
+ public ISpellCatalog Spells => this;
+ public IMagicCommands Magic => this;
+
+ /// Bind the surface to a live session's owners.
+ public void Bind(RuntimeCharacterState character, RuntimeSpellCastState cast)
+ {
+ ArgumentNullException.ThrowIfNull(character);
+ ArgumentNullException.ThrowIfNull(cast);
+
+ Spellbook spellbook = character.Spellbook;
+ lock (_gate)
+ {
+ if (_disposed)
+ return;
+ DetachLocked();
+ _character = character;
+ _cast = cast;
+ _spellbook = spellbook;
+ spellbook.SpellbookChanged += OnSpellbookChanged;
+ spellbook.EnchantmentsChanged += OnEnchantmentsChanged;
+ }
+
+ RebuildSpellbook();
+ RebuildEnchantments();
+ }
+
+ /// Release the session's owners; reads go inert until the next bind.
+ public void Unbind()
+ {
+ lock (_gate)
+ DetachLocked();
+ _knownSelfBuffs = Array.Empty();
+ _enchantments = Array.Empty();
+ }
+
+ private void DetachLocked()
+ {
+ if (_spellbook is not null)
+ {
+ _spellbook.SpellbookChanged -= OnSpellbookChanged;
+ _spellbook.EnchantmentsChanged -= OnEnchantmentsChanged;
+ }
+ _spellbook = null;
+ _character = null;
+ _cast = null;
+ }
+
+ private void OnSpellbookChanged() => RebuildSpellbook();
+
+ private void OnEnchantmentsChanged() => RebuildEnchantments();
+
+ private void RebuildSpellbook()
+ {
+ Spellbook? spellbook;
+ lock (_gate)
+ spellbook = _spellbook;
+ if (spellbook is null)
+ {
+ _knownSelfBuffs = Array.Empty();
+ return;
+ }
+
+ var built = new List();
+ foreach (uint spellId in spellbook.LearnedSpells)
+ {
+ if (!spellbook.TryGetMetadata(spellId, out SpellMetadata meta))
+ continue;
+ if (!meta.IsSelfTargeted || !meta.IsBeneficial || meta.IsDebuff)
+ continue;
+ built.Add(Project(meta));
+ }
+
+ // Stable order so a plugin's buff sequence does not reshuffle between
+ // passes: family first, then strongest tier within it.
+ built.Sort(static (a, b) =>
+ a.Family != b.Family
+ ? a.Family.CompareTo(b.Family)
+ : b.Tier.CompareTo(a.Tier));
+ _knownSelfBuffs = built;
+ }
+
+ private void RebuildEnchantments()
+ {
+ Spellbook? spellbook;
+ lock (_gate)
+ spellbook = _spellbook;
+ if (spellbook is null)
+ {
+ _enchantments = Array.Empty();
+ return;
+ }
+
+ // EnchantmentsInEffectSnapshot, not the raw active set: retail lets a
+ // weaker same-family enchantment sit in the registry while a stronger
+ // one is in force. A plugin asking "am I buffed?" means in force.
+ IReadOnlyList active =
+ spellbook.EnchantmentsInEffectSnapshot;
+ var built = new List(active.Count);
+ foreach (ActiveEnchantmentRecord record in active)
+ {
+ uint family = 0;
+ int tier = 0;
+ if (spellbook.TryGetMetadata(record.SpellId, out SpellMetadata meta))
+ {
+ family = meta.Family;
+ tier = meta.Generation;
+ }
+ built.Add(new PluginActiveEnchantment(
+ record.SpellId, family, tier, record.Duration));
+ }
+ _enchantments = built;
+ }
+
+ private static PluginSpellInfo Project(SpellMetadata meta) => new(
+ meta.SpellId,
+ meta.Name,
+ meta.Family,
+ meta.Generation,
+ meta.Difficulty,
+ meta.ManaCost,
+ meta.Duration,
+ meta.IsSelfTargeted,
+ meta.IsBeneficial);
+
+ // ── ICharacterInfo ────────────────────────────────────────────────────
+ public bool IsInWorld => IsAvailable;
+
+ public uint CurrentMana => Vital(out uint current, out _) ? current : 0u;
+
+ public uint MaxMana => Vital(out _, out uint maximum) ? maximum : 0u;
+
+ private bool Vital(out uint current, out uint maximum)
+ {
+ current = 0;
+ maximum = 0;
+ RuntimeCharacterState? character;
+ lock (_gate)
+ character = _character;
+ if (character is null)
+ return false;
+ if (!character.View.TryGetVital(
+ (int)LocalPlayerState.VitalKind.Mana, out var vital))
+ {
+ return false;
+ }
+ current = vital.Current;
+ maximum = vital.Maximum;
+ return true;
+ }
+
+ public IReadOnlyList ActiveEnchantments => _enchantments;
+
+ // ── ISpellCatalog ─────────────────────────────────────────────────────
+ public IReadOnlyList KnownSelfBuffs => _knownSelfBuffs;
+
+ public bool TryGet(uint spellId, out PluginSpellInfo info)
+ {
+ Spellbook? spellbook;
+ lock (_gate)
+ spellbook = _spellbook;
+ if (spellbook is not null
+ && spellbook.TryGetMetadata(spellId, out SpellMetadata meta))
+ {
+ info = Project(meta);
+ return true;
+ }
+ info = default;
+ return false;
+ }
+
+ // ── IMagicCommands ────────────────────────────────────────────────────
+ public bool IsCasting
+ {
+ get
+ {
+ RuntimeSpellCastState? cast;
+ lock (_gate)
+ cast = _cast;
+ return cast?.LastRequestedSpellId is not null;
+ }
+ }
+
+ public PluginCastGate EvaluateGate(uint spellId)
+ {
+ RuntimeSpellCastState? cast;
+ Spellbook? spellbook;
+ lock (_gate)
+ {
+ cast = _cast;
+ spellbook = _spellbook;
+ }
+ if (cast is null || spellbook is null)
+ return PluginCastGate.Unavailable;
+ if (!spellbook.Knows(spellId))
+ return PluginCastGate.NotKnown;
+
+ return cast.EvaluateCastGate(spellId) switch
+ {
+ SpellCastGate.Unknown => PluginCastGate.NotKnown,
+ SpellCastGate.NoTargetNeeded => PluginCastGate.Ready,
+ SpellCastGate.TargetCompatible => PluginCastGate.Ready,
+ _ => PluginCastGate.Refused,
+ };
+ }
+
+ public bool Cast(uint spellId)
+ {
+ RuntimeSpellCastState? cast;
+ lock (_gate)
+ cast = _cast;
+ return cast is not null && cast.Cast(spellId) == CastRequestResult.Sent;
+ }
+
+ public void Dispose()
+ {
+ lock (_gate)
+ {
+ if (_disposed)
+ return;
+ _disposed = true;
+ DetachLocked();
+ }
+ _knownSelfBuffs = Array.Empty();
+ _enchantments = Array.Empty();
+ }
+}
diff --git a/src/AcDream.App/Plugins/AppPluginHost.cs b/src/AcDream.App/Plugins/AppPluginHost.cs
index dc81ec44..ad5fe525 100644
--- a/src/AcDream.App/Plugins/AppPluginHost.cs
+++ b/src/AcDream.App/Plugins/AppPluginHost.cs
@@ -9,13 +9,15 @@ public sealed class AppPluginHost : IPluginHost
IGameState state,
IEvents events,
ISelectionService selection,
- IUiRegistry ui)
+ IUiRegistry ui,
+ IAutomationSurface automation)
{
Log = log;
State = state;
Events = events;
Selection = selection;
Ui = ui;
+ Automation = automation;
}
public bool HasUi => true;
@@ -24,4 +26,5 @@ public sealed class AppPluginHost : IPluginHost
public IEvents Events { get; }
public ISelectionService Selection { get; }
public IUiRegistry Ui { get; }
+ public IAutomationSurface Automation { get; }
}
diff --git a/src/AcDream.App/Program.cs b/src/AcDream.App/Program.cs
index 8b0407bb..d62cbb7b 100644
--- a/src/AcDream.App/Program.cs
+++ b/src/AcDream.App/Program.cs
@@ -149,18 +149,23 @@ if (runtimeOptions.DevTools)
var worldGameState = new AcDream.Core.Plugins.WorldGameState();
var worldEvents = new AcDream.Core.Plugins.WorldEvents();
var uiRegistry = new AcDream.App.Plugins.BufferedUiRegistry();
+// Constructed here and handed to both sides: GameWindow binds it to the live
+// session's Runtime owners, the plugin host exposes it to plugins.
+using var automation = new AcDream.App.Plugins.AppAutomationSurface();
using var window = new GameWindow(
runtimeOptions,
worldGameState,
worldEvents,
uiRegistry,
- graphicalPlatform);
+ graphicalPlatform,
+ automation);
var host = new AppPluginHost(
new SerilogAdapter(Log.Logger),
worldGameState,
worldEvents,
window.Selection,
- uiRegistry);
+ uiRegistry,
+ automation);
GraphicalPluginSession pluginSession = GraphicalPluginSession.Create(
applicationPaths,
runtimeOptions.Plugins,
diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs
index b6de89db..ededbe4e 100644
--- a/src/AcDream.App/Rendering/GameWindow.cs
+++ b/src/AcDream.App/Rendering/GameWindow.cs
@@ -363,6 +363,7 @@ public sealed class GameWindow :
// J4/J5.1: Runtime owns communication, selection, combat, and target-mode
// state. App, UI, plugins, and live-session routing borrow exact children.
+ private readonly AcDream.App.Plugins.AppAutomationSurface? _automation;
private readonly GameRuntime _runtime;
private readonly IDisposable _runtimeHostLease;
private RuntimeCommunicationState _runtimeCommunication =>
@@ -634,9 +635,11 @@ public sealed class GameWindow :
WorldGameState worldGameState,
WorldEvents worldEvents,
AcDream.App.Plugins.BufferedUiRegistry? uiRegistry,
- GraphicalHostPlatformServices platformServices)
+ GraphicalHostPlatformServices platformServices,
+ AcDream.App.Plugins.AppAutomationSurface? automation = null)
{
_options = options ?? throw new System.ArgumentNullException(nameof(options));
+ _automation = automation;
_statusWriter = new SessionStatusWriter(options.StatusFilePath);
_platformServices = platformServices
?? throw new ArgumentNullException(nameof(platformServices));
@@ -653,6 +656,11 @@ public sealed class GameWindow :
options.DumpSky ? Console.WriteLine : null));
_runtimeHostLease = _runtime.AcquireHostLease(
"graphical GameWindow");
+ // Bound once, not per session: since Slice J the gameplay owners are
+ // stable for the GameRuntime's lifetime and it is their *contents* that
+ // reset across generations. Re-binding per session would be re-binding
+ // the same two references.
+ _automation?.Bind(_runtime.CharacterOwner, _runtime.ActionOwner.SpellCast);
_localPlayerIdentity = new AcDream.App.Input.LocalPlayerIdentityState(
_runtime.PlayerIdentity);
_updateFrameClock = new AcDream.App.Update.UpdateFrameClock(
@@ -1626,6 +1634,11 @@ public sealed class GameWindow :
using var _updStage = _frameProfiler.BeginStage(
AcDream.App.Diagnostics.FrameStage.Update);
_frameGraphs.Tick(new AcDream.App.Update.UpdateFrameInput(dt));
+ // After the frame graph, so a plugin observes state the host has
+ // already advanced this frame rather than a half-updated world.
+ // WorldEvents.FireTick swallows per-subscriber faults, so a throwing
+ // plugin cannot break the render loop.
+ _worldEvents.FireTick(dt);
_renderLoopArmed = false;
}
diff --git a/src/AcDream.App/UI/MarkupDocument.cs b/src/AcDream.App/UI/MarkupDocument.cs
index 1132479b..410aece4 100644
--- a/src/AcDream.App/UI/MarkupDocument.cs
+++ b/src/AcDream.App/UI/MarkupDocument.cs
@@ -75,12 +75,100 @@ public static class MarkupDocument
FrontRight = Hex((string?)el.Attribute("frontright")),
});
break;
- // future element kinds (label, button, image) added here
+
+ 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),
+ };
+ if (el.Attribute("color") is not null)
+ label.TextColor = Color((string?)el.Attribute("color"));
+ panel.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(
+ $"