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) <noreply@anthropic.com>
This commit is contained in:
Erik 2026-06-14 17:46:37 +02:00
parent 07bf6cbf60
commit 019350fa31
8 changed files with 102 additions and 4 deletions

View file

@ -4,14 +4,16 @@ namespace AcDream.App.Plugins;
public sealed class AppPluginHost : IPluginHost 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; Log = log;
State = state; State = state;
Events = events; Events = events;
Ui = ui;
} }
public IPluginLogger Log { get; } public IPluginLogger Log { get; }
public IGameState State { get; } public IGameState State { get; }
public IEvents Events { get; } public IEvents Events { get; }
public IUiRegistry Ui { get; }
} }

View file

@ -0,0 +1,27 @@
using System.Collections.Generic;
using AcDream.Plugin.Abstractions;
namespace AcDream.App.Plugins;
/// <summary>
/// Buffers plugin <see cref="IUiRegistry.AddMarkupPanel"/> calls (which run in
/// Program.cs before the GL window opens) until GameWindow drains them into the
/// UiHost tree after construction.
/// </summary>
public sealed class BufferedUiRegistry : IUiRegistry
{
public readonly record struct Pending(string MarkupPath, object Binding);
private readonly List<Pending> _pending = new();
public void AddMarkupPanel(string markupPath, object binding)
=> _pending.Add(new Pending(markupPath, binding));
/// <summary>Return + clear all buffered registrations.</summary>
public IReadOnlyList<Pending> Drain()
{
var copy = _pending.ToArray();
_pending.Clear();
return copy;
}
}

View file

@ -23,7 +23,8 @@ var runtimeOptions = RuntimeOptions.FromEnvironment(datDir);
var worldGameState = new AcDream.Core.Plugins.WorldGameState(); var worldGameState = new AcDream.Core.Plugins.WorldGameState();
var worldEvents = new AcDream.Core.Plugins.WorldEvents(); 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"); var pluginsDir = Path.Combine(AppContext.BaseDirectory, "plugins");
Log.Information("scanning plugins in {PluginsDir}", pluginsDir); 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); } 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(); window.Run();
} }
finally finally

View file

@ -614,6 +614,8 @@ public sealed class GameWindow : IDisposable
private AcDream.UI.Abstractions.Panels.Vitals.VitalsVM? _vitalsVm; 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. // Phase D.2b — retail-look UI tree (dormant UiHost wired here). Null unless ACDREAM_RETAIL_UI=1.
private AcDream.App.UI.UiHost? _uiHost; 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 // Phase I.2: ImGui debug panel ViewModel. Lives for as long as
// _panelHost does. Self-subscribes to CombatState in its ctor, so // _panelHost does. Self-subscribes to CombatState in its ctor, so
// disposing isn't required (panel host holds the only ref). // disposing isn't required (panel host holds the only ref).
@ -864,12 +866,14 @@ public sealed class GameWindow : IDisposable
private int _liveAnimRejectSingleFrame; private int _liveAnimRejectSingleFrame;
private int _liveAnimRejectPartFrames; 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)); _options = options ?? throw new System.ArgumentNullException(nameof(options));
_datDir = options.DatDir; _datDir = options.DatDir;
_worldGameState = worldGameState; _worldGameState = worldGameState;
_worldEvents = worldEvents; _worldEvents = worldEvents;
_uiRegistry = uiRegistry;
SpellBook = new AcDream.Core.Spells.Spellbook(SpellTable); SpellBook = new AcDream.Core.Spells.Spellbook(SpellTable);
LocalPlayer = new AcDream.Core.Player.LocalPlayerState(SpellBook); 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); var panel = AcDream.App.UI.MarkupDocument.Build(vitalsXml, _vitalsVm!, ResolveChrome, controls);
_uiHost.Root.AddChild(panel); _uiHost.Root.AddChild(panel);
Console.WriteLine("[D.2b] retail UI active — vitals panel from vitals.xml markup."); 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 // Phase N.4+N.5 — WB rendering pipeline foundation. The modern path is

View file

@ -10,4 +10,5 @@ public interface IPluginHost
IPluginLogger Log { get; } IPluginLogger Log { get; }
IGameState State { get; } IGameState State { get; }
IEvents Events { get; } IEvents Events { get; }
IUiRegistry Ui { get; }
} }

View file

@ -0,0 +1,14 @@
namespace AcDream.Plugin.Abstractions;
/// <summary>
/// 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 <c>Enable()</c>. Calls made before the GL window opens are
/// buffered and drained once the UI host exists.
/// </summary>
public interface IUiRegistry
{
/// <param name="markupPath">Absolute path to the plugin's panel markup file.</param>
/// <param name="binding">Object whose properties the markup's {Bindings} resolve against.</param>
void AddMarkupPanel(string markupPath, object binding);
}

View file

@ -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
}
}

View file

@ -30,6 +30,12 @@ public class PluginLoaderTests
public IPluginLogger Log { get; } = new StubLogger(); public IPluginLogger Log { get; } = new StubLogger();
public IGameState State { get; } = new StubState(); public IGameState State { get; } = new StubState();
public IEvents Events { get; } = new StubEvents(); 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 private sealed class StubLogger : IPluginLogger