From 019350fa3132669769f11071e183f3dbdaa55704 Mon Sep 17 00:00:00 2001 From: Erik Date: Sun, 14 Jun 2026 17:46:37 +0200 Subject: [PATCH] feat(D.2b): IUiRegistry plugin UI surface + buffered drain into UiHost Adds the plugin-facing UI registration surface (Task 9, final D.2b task). Plugins call host.Ui.AddMarkupPanel(path, binding) from Enable(); calls are buffered in BufferedUiRegistry before the GL window opens, then drained into UiHost.Root in GameWindow.OnLoad inside the RetailUi block after the first- party vitals panel. Faulty plugin markup is isolated (try/catch per panel, logged + skipped). IPluginHost.Ui added; AppPluginHost wired; StubHost in Core.Tests updated; BufferedUiRegistryTests confirms drain-once semantics. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/AcDream.App/Plugins/AppPluginHost.cs | 4 ++- src/AcDream.App/Plugins/BufferedUiRegistry.cs | 27 ++++++++++++++++++ src/AcDream.App/Program.cs | 5 ++-- src/AcDream.App/Rendering/GameWindow.cs | 28 ++++++++++++++++++- .../IPluginHost.cs | 1 + .../IUiRegistry.cs | 14 ++++++++++ .../Plugins/BufferedUiRegistryTests.cs | 21 ++++++++++++++ .../Plugins/PluginLoaderTests.cs | 6 ++++ 8 files changed, 102 insertions(+), 4 deletions(-) create mode 100644 src/AcDream.App/Plugins/BufferedUiRegistry.cs create mode 100644 src/AcDream.Plugin.Abstractions/IUiRegistry.cs create mode 100644 tests/AcDream.App.Tests/Plugins/BufferedUiRegistryTests.cs 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