diff --git a/src/AcDream.App/Plugins/AppPluginHost.cs b/src/AcDream.App/Plugins/AppPluginHost.cs
index 2916724e..5b06e67e 100644
--- a/src/AcDream.App/Plugins/AppPluginHost.cs
+++ b/src/AcDream.App/Plugins/AppPluginHost.cs
@@ -4,14 +4,16 @@ namespace AcDream.App.Plugins;
public sealed class AppPluginHost : IPluginHost
{
- public AppPluginHost(IPluginLogger log, IGameState state, IEvents events)
+ public AppPluginHost(IPluginLogger log, IGameState state, IEvents events, IUiRegistry ui)
{
Log = log;
State = state;
Events = events;
+ Ui = ui;
}
public IPluginLogger Log { get; }
public IGameState State { get; }
public IEvents Events { get; }
+ public IUiRegistry Ui { get; }
}
diff --git a/src/AcDream.App/Plugins/BufferedUiRegistry.cs b/src/AcDream.App/Plugins/BufferedUiRegistry.cs
new file mode 100644
index 00000000..bcab04fb
--- /dev/null
+++ b/src/AcDream.App/Plugins/BufferedUiRegistry.cs
@@ -0,0 +1,27 @@
+using System.Collections.Generic;
+using AcDream.Plugin.Abstractions;
+
+namespace AcDream.App.Plugins;
+
+///
+/// Buffers plugin calls (which run in
+/// Program.cs before the GL window opens) until GameWindow drains them into the
+/// UiHost tree after construction.
+///
+public sealed class BufferedUiRegistry : IUiRegistry
+{
+ public readonly record struct Pending(string MarkupPath, object Binding);
+
+ private readonly List _pending = new();
+
+ public void AddMarkupPanel(string markupPath, object binding)
+ => _pending.Add(new Pending(markupPath, binding));
+
+ /// Return + clear all buffered registrations.
+ public IReadOnlyList Drain()
+ {
+ var copy = _pending.ToArray();
+ _pending.Clear();
+ return copy;
+ }
+}
diff --git a/src/AcDream.App/Program.cs b/src/AcDream.App/Program.cs
index bc43997b..b3aebd5a 100644
--- a/src/AcDream.App/Program.cs
+++ b/src/AcDream.App/Program.cs
@@ -23,7 +23,8 @@ var runtimeOptions = RuntimeOptions.FromEnvironment(datDir);
var worldGameState = new AcDream.Core.Plugins.WorldGameState();
var worldEvents = new AcDream.Core.Plugins.WorldEvents();
-var host = new AppPluginHost(new SerilogAdapter(Log.Logger), worldGameState, worldEvents);
+var uiRegistry = new AcDream.App.Plugins.BufferedUiRegistry();
+var host = new AppPluginHost(new SerilogAdapter(Log.Logger), worldGameState, worldEvents, uiRegistry);
var pluginsDir = Path.Combine(AppContext.BaseDirectory, "plugins");
Log.Information("scanning plugins in {PluginsDir}", pluginsDir);
@@ -56,7 +57,7 @@ try
catch (Exception ex) { Log.Error(ex, "plugin enable failed: {Id}", plugin.Manifest.Id); }
}
- using var window = new GameWindow(runtimeOptions, worldGameState, worldEvents);
+ using var window = new GameWindow(runtimeOptions, worldGameState, worldEvents, uiRegistry);
window.Run();
}
finally
diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs
index 74b8a5d5..bea54e36 100644
--- a/src/AcDream.App/Rendering/GameWindow.cs
+++ b/src/AcDream.App/Rendering/GameWindow.cs
@@ -614,6 +614,8 @@ public sealed class GameWindow : IDisposable
private AcDream.UI.Abstractions.Panels.Vitals.VitalsVM? _vitalsVm;
// Phase D.2b — retail-look UI tree (dormant UiHost wired here). Null unless ACDREAM_RETAIL_UI=1.
private AcDream.App.UI.UiHost? _uiHost;
+ // Phase D.2b Task 9 — plugin UI registrations buffered before OnLoad; drained in OnLoad.
+ private readonly AcDream.App.Plugins.BufferedUiRegistry? _uiRegistry;
// Phase I.2: ImGui debug panel ViewModel. Lives for as long as
// _panelHost does. Self-subscribes to CombatState in its ctor, so
// disposing isn't required (panel host holds the only ref).
@@ -864,12 +866,14 @@ public sealed class GameWindow : IDisposable
private int _liveAnimRejectSingleFrame;
private int _liveAnimRejectPartFrames;
- public GameWindow(AcDream.App.RuntimeOptions options, WorldGameState worldGameState, WorldEvents worldEvents)
+ public GameWindow(AcDream.App.RuntimeOptions options, WorldGameState worldGameState, WorldEvents worldEvents,
+ AcDream.App.Plugins.BufferedUiRegistry? uiRegistry = null)
{
_options = options ?? throw new System.ArgumentNullException(nameof(options));
_datDir = options.DatDir;
_worldGameState = worldGameState;
_worldEvents = worldEvents;
+ _uiRegistry = uiRegistry;
SpellBook = new AcDream.Core.Spells.Spellbook(SpellTable);
LocalPlayer = new AcDream.Core.Player.LocalPlayerState(SpellBook);
}
@@ -1758,6 +1762,28 @@ public sealed class GameWindow : IDisposable
var panel = AcDream.App.UI.MarkupDocument.Build(vitalsXml, _vitalsVm!, ResolveChrome, controls);
_uiHost.Root.AddChild(panel);
Console.WriteLine("[D.2b] retail UI active — vitals panel from vitals.xml markup.");
+
+ // Drain plugin-registered markup panels (buffered before the GL
+ // window opened) into the same UiRoot tree. A faulty plugin markup
+ // file is isolated — logged + skipped, never crashes the client.
+ if (_uiRegistry is not null)
+ {
+ foreach (var p in _uiRegistry.Drain())
+ {
+ try
+ {
+ string pluginXml = System.IO.File.ReadAllText(p.MarkupPath);
+ var pluginPanel = AcDream.App.UI.MarkupDocument.Build(
+ pluginXml, p.Binding, ResolveChrome, controls);
+ _uiHost.Root.AddChild(pluginPanel);
+ Console.WriteLine($"[D.2b] plugin UI panel loaded: {p.MarkupPath}");
+ }
+ catch (Exception ex)
+ {
+ Console.WriteLine($"[D.2b] plugin UI panel '{p.MarkupPath}' failed to load: {ex.Message}");
+ }
+ }
+ }
}
// Phase N.4+N.5 — WB rendering pipeline foundation. The modern path is
diff --git a/src/AcDream.Plugin.Abstractions/IPluginHost.cs b/src/AcDream.Plugin.Abstractions/IPluginHost.cs
index 7374ea91..dca64d7b 100644
--- a/src/AcDream.Plugin.Abstractions/IPluginHost.cs
+++ b/src/AcDream.Plugin.Abstractions/IPluginHost.cs
@@ -10,4 +10,5 @@ public interface IPluginHost
IPluginLogger Log { get; }
IGameState State { get; }
IEvents Events { get; }
+ IUiRegistry Ui { get; }
}
diff --git a/src/AcDream.Plugin.Abstractions/IUiRegistry.cs b/src/AcDream.Plugin.Abstractions/IUiRegistry.cs
new file mode 100644
index 00000000..1b724f1a
--- /dev/null
+++ b/src/AcDream.Plugin.Abstractions/IUiRegistry.cs
@@ -0,0 +1,14 @@
+namespace AcDream.Plugin.Abstractions;
+
+///
+/// Plugin-facing UI registration. A plugin ships a markup file (KSML-style) +
+/// a binding object exposing the data properties the markup binds to, and
+/// registers it from Enable(). Calls made before the GL window opens are
+/// buffered and drained once the UI host exists.
+///
+public interface IUiRegistry
+{
+ /// Absolute path to the plugin's panel markup file.
+ /// Object whose properties the markup's {Bindings} resolve against.
+ void AddMarkupPanel(string markupPath, object binding);
+}
diff --git a/tests/AcDream.App.Tests/Plugins/BufferedUiRegistryTests.cs b/tests/AcDream.App.Tests/Plugins/BufferedUiRegistryTests.cs
new file mode 100644
index 00000000..6e22e17f
--- /dev/null
+++ b/tests/AcDream.App.Tests/Plugins/BufferedUiRegistryTests.cs
@@ -0,0 +1,21 @@
+using AcDream.App.Plugins;
+
+namespace AcDream.App.Tests.Plugins;
+
+public class BufferedUiRegistryTests
+{
+ [Fact]
+ public void Drain_YieldsBufferedRegistrationsOnceThenEmpty()
+ {
+ var reg = new BufferedUiRegistry();
+ reg.AddMarkupPanel("a.xml", new object());
+ reg.AddMarkupPanel("b.xml", new object());
+
+ var drained = reg.Drain();
+ Assert.Equal(2, drained.Count);
+ Assert.Equal("a.xml", drained[0].MarkupPath);
+ Assert.Equal("b.xml", drained[1].MarkupPath);
+
+ Assert.Empty(reg.Drain()); // consumed
+ }
+}
diff --git a/tests/AcDream.Core.Tests/Plugins/PluginLoaderTests.cs b/tests/AcDream.Core.Tests/Plugins/PluginLoaderTests.cs
index 2fdafc97..da508aab 100644
--- a/tests/AcDream.Core.Tests/Plugins/PluginLoaderTests.cs
+++ b/tests/AcDream.Core.Tests/Plugins/PluginLoaderTests.cs
@@ -30,6 +30,12 @@ public class PluginLoaderTests
public IPluginLogger Log { get; } = new StubLogger();
public IGameState State { get; } = new StubState();
public IEvents Events { get; } = new StubEvents();
+ public IUiRegistry Ui { get; } = new StubUiRegistry();
+ }
+
+ private sealed class StubUiRegistry : IUiRegistry
+ {
+ public void AddMarkupPanel(string markupPath, object binding) { }
}
private sealed class StubLogger : IPluginLogger