diff --git a/AcDream.slnx b/AcDream.slnx
index 0771d163..50f27be6 100644
--- a/AcDream.slnx
+++ b/AcDream.slnx
@@ -30,6 +30,7 @@
+
diff --git a/docs/architecture/acdream-architecture.md b/docs/architecture/acdream-architecture.md
index 37753218..31ec860b 100644
--- a/docs/architecture/acdream-architecture.md
+++ b/docs/architecture/acdream-architecture.md
@@ -120,7 +120,16 @@ handlers and controllers translate those intents to `WorldSession`; panels
never inspect or construct wire messages.
Plugins register retained gameplay markup through the BCL-only
`AcDream.Plugin.Abstractions.IUiRegistry`; they do not import App or
-presentation assemblies. Core `SelectionState` is the sole selected-object owner for world,
+presentation assemblies. `IPluginHost.HasUi` is the explicit capability edge:
+the graphical host supplies its retained registry, while no-window hosts
+return `false` and the BCL-only `NoOpUiRegistry`, which retains no plugin
+binding. Both hosts use Core's session-scoped discovery/lifetime orchestrator
+and the same config allow-list semantics (absent loads all; explicit empty
+loads none). The headless adapter projects entity snapshots on demand from the
+canonical Runtime view, subscribes to Runtime's ordered events, and borrows the
+exact Runtime selection owner; it does not mirror gameplay state.
+
+Core `SelectionState` is the sole selected-object owner for world,
radar, inventory, paperdoll, toolbar, use/examine consumers, and plugins;
`IPluginHost.Selection` exposes that same state and retail-style old/new callback.
Temporary pointer modes are separate App orchestration in `InteractionState` and
@@ -174,6 +183,9 @@ parallel window-lifecycle map.
```
src/
AcDream.Core/ Layer 2-4: no Vulkan, no Silk.NET, pure logic
+ Plugins/
+ PluginSession.cs -> shared per-host allow-list, failure isolation,
+ status outcome, and collectible ALC lifetime
Physics/
PhysicsBody.cs -> body state / integration foundation (done)
CollisionPrimitives.cs -> retail primitive helpers (partial, active)
@@ -304,6 +316,8 @@ src/
Configuration/ -> strict versioned process/session config
Credentials/ -> redacted env/stdin/owner-only-file providers
Hosting/ -> one GameRuntime/session/lease/policy lifetime
+ Plugins/ -> no-window IPluginHost borrowing Runtime/Core;
+ BCL no-op UI and per-session plugin lifetime
Policies/ -> typed Runtime-view/command consumers
-> references Runtime only; no presentation/backend package
-> Slice K complete: portable single/multi-session production host,
@@ -314,6 +328,7 @@ src/
AcDream.Plugin.Abstractions/ Layer 5: plugin interfaces
IAcDreamPlugin.cs -> done
IPluginHost.cs -> done
+ IUiRegistry.cs -> capability-aware retained/no-op UI contract
IGameState.cs -> done
IEvents.cs -> done
ISelectionService.cs -> done
@@ -368,6 +383,7 @@ src/
PlayerMovementController.cs -> active movement driver
Plugins/
AppPluginHost.cs -> done
+ GraphicalPluginSession.cs -> thin shared-session/root/status adapter
```
The 4B2 production SetPosition routes and shared local-controller body remain
diff --git a/docs/plans/2026-08-14-launcher-campaign.md b/docs/plans/2026-08-14-launcher-campaign.md
index 00913fb2..0de911ff 100644
--- a/docs/plans/2026-08-14-launcher-campaign.md
+++ b/docs/plans/2026-08-14-launcher-campaign.md
@@ -155,7 +155,11 @@ Field rules:
for gui/guiSelect/probe.
- `credential`: always `{ "provider": "standardInput", "reference":
"session" }` for launcher-composed configs.
-- `plugins`/`loginCommands`/`loginCommandDelayMs`/`statusFile`: optional,
+- `plugins`: absent/null means load all discovered plugins (preserving the
+ developer flow); explicit `[]` means load none. Launcher-composed
+ normal-empty and probe sessions emit `[]` so they cannot load arbitrary
+ machine-local plugins.
+- `loginCommands`/`loginCommandDelayMs`/`statusFile`: optional,
omitted-when-unset (never null, never `[]` for empty). Absent
`loginCommandDelayMs` means 500.
@@ -297,7 +301,9 @@ are backed by Core-owned types already; only `Ui` (`BufferedUiRegistry`) is
genuinely App-only. Headless has zero plugin hosting today (confirmed).
1. Session-config `Plugins` allow-list filters the discovery result on BOTH
- hosts (absent list = load all, preserving today's dev behavior).
+ hosts (absent/null list = load all, preserving today's dev behavior;
+ explicit `[]` = load none). Launcher-composed normal-empty and probe
+ sessions emit `[]`.
2. `HeadlessPluginHost : IPluginHost` in Headless over the same Core-owned
`State`/`Events`/`Selection`; `Ui` is an explicit no-op behind a new
capability flag on `IPluginHost` (e.g. `HasUi`) so plugins can detect
diff --git a/docs/superpowers/specs/2026-08-14-launcher-campaign-design.md b/docs/superpowers/specs/2026-08-14-launcher-campaign-design.md
index 42360f1d..fb4a37d6 100644
--- a/docs/superpowers/specs/2026-08-14-launcher-campaign-design.md
+++ b/docs/superpowers/specs/2026-08-14-launcher-campaign-design.md
@@ -168,7 +168,9 @@ Hand-editability is a property of the format, not a required workflow.
`HeadlessConfiguration` shape extended with:
- `Plugins: string[]` — plugin names to load from the standard
- `PluginsDirectory`; hosts load exactly this set.
+ `PluginsDirectory`; absent/null loads all discovered plugins, while an
+ explicit empty array loads none. Launcher-composed normal-empty and probe
+ sessions emit the empty array.
- `LoginCommands: string[]` — ordered chat-typed strings.
- Graphical host: `Character` selector may be ABSENT → character-select
screen instead of auto-enter.
diff --git a/src/AcDream.App/Composition/SessionStartComposition.cs b/src/AcDream.App/Composition/SessionStartComposition.cs
index 35b48db9..64e423dc 100644
--- a/src/AcDream.App/Composition/SessionStartComposition.cs
+++ b/src/AcDream.App/Composition/SessionStartComposition.cs
@@ -4,11 +4,7 @@ using AcDream.Runtime.Session;
namespace AcDream.App.Composition;
internal sealed record SessionStartDependencies(
- Action Log,
- /// Campaign LA slice LA1: no-op when no statusFile was
- /// configured.
- SessionStatusWriter StatusWriter,
- string SessionId);
+ Action Log);
///
/// Terminal startup phase. Every callback, command target, and frame root is
@@ -26,9 +22,6 @@ internal sealed class SessionStartCompositionPhase
public void Start(FrameRootResult frame)
{
ArgumentNullException.ThrowIfNull(frame);
- // Campaign LA slice LA1: "started" = session host start — the
- // earliest point the graphical host actually attempts to connect.
- _dependencies.StatusWriter.Started(_dependencies.SessionId);
RuntimeSessionStartResult result =
frame.GameRuntime.Session.Start(frame.GameRuntime.Generation);
Report(result, _dependencies.Log);
diff --git a/src/AcDream.App/Plugins/AppPluginHost.cs b/src/AcDream.App/Plugins/AppPluginHost.cs
index bfabab86..dc81ec44 100644
--- a/src/AcDream.App/Plugins/AppPluginHost.cs
+++ b/src/AcDream.App/Plugins/AppPluginHost.cs
@@ -18,6 +18,7 @@ public sealed class AppPluginHost : IPluginHost
Ui = ui;
}
+ public bool HasUi => true;
public IPluginLogger Log { get; }
public IGameState State { get; }
public IEvents Events { get; }
diff --git a/src/AcDream.App/Plugins/BufferedUiRegistry.cs b/src/AcDream.App/Plugins/BufferedUiRegistry.cs
index bcab04fb..dc3c565e 100644
--- a/src/AcDream.App/Plugins/BufferedUiRegistry.cs
+++ b/src/AcDream.App/Plugins/BufferedUiRegistry.cs
@@ -1,4 +1,5 @@
using System.Collections.Generic;
+using AcDream.App.UI;
using AcDream.Plugin.Abstractions;
namespace AcDream.App.Plugins;
@@ -8,20 +9,119 @@ namespace AcDream.App.Plugins;
/// Program.cs before the GL window opens) until GameWindow drains them into the
/// UiHost tree after construction.
///
-public sealed class BufferedUiRegistry : IUiRegistry
+public sealed class BufferedUiRegistry : IScopedUiRegistry
{
- public readonly record struct Pending(string MarkupPath, object Binding);
+ public readonly record struct Pending(string MarkupPath, object Binding)
+ {
+ internal long RegistrationId { get; init; }
+ }
- private readonly List _pending = new();
+ private sealed class Registration(string markupPath, object binding)
+ {
+ internal string MarkupPath { get; } = markupPath;
+ internal object Binding { get; } = binding;
+ internal bool Drained { get; set; }
+ internal UiRoot? Root { get; set; }
+ internal UiElement? Element { get; set; }
+ }
+
+ private readonly object _gate = new();
+ private readonly Dictionary _registrations = [];
+ private long _nextRegistrationId;
public void AddMarkupPanel(string markupPath, object binding)
- => _pending.Add(new Pending(markupPath, binding));
+ => _ = RegisterMarkupPanel(markupPath, binding);
- /// Return + clear all buffered registrations.
+ public IDisposable RegisterMarkupPanel(string markupPath, object binding)
+ {
+ ArgumentException.ThrowIfNullOrWhiteSpace(markupPath);
+ ArgumentNullException.ThrowIfNull(binding);
+ long id;
+ lock (_gate)
+ {
+ id = checked(++_nextRegistrationId);
+ _registrations.Add(id, new Registration(markupPath, binding));
+ }
+ return new RegistrationToken(this, id);
+ }
+
+ /// Returns each not-yet-drained active registration once.
public IReadOnlyList Drain()
{
- var copy = _pending.ToArray();
- _pending.Clear();
- return copy;
+ lock (_gate)
+ {
+ var pending = new List(_registrations.Count);
+ foreach ((long id, Registration registration) in _registrations)
+ {
+ if (registration.Drained)
+ continue;
+ registration.Drained = true;
+ pending.Add(new Pending(
+ registration.MarkupPath,
+ registration.Binding)
+ {
+ RegistrationId = id,
+ });
+ }
+ return pending;
+ }
+ }
+
+ internal void CompleteMount(Pending pending, UiRoot root, UiElement element)
+ {
+ bool stillRegistered;
+ lock (_gate)
+ {
+ stillRegistered = _registrations.TryGetValue(
+ pending.RegistrationId,
+ out Registration? registration);
+ if (stillRegistered)
+ {
+ registration!.Root = root;
+ registration.Element = element;
+ }
+ }
+
+ // A plugin can fail/disable while markup is being built. Never leave
+ // the just-built child mounted if its host-owned token was rolled back.
+ if (!stillRegistered)
+ root.RemoveChild(element);
+ }
+
+ internal void FailMount(Pending pending) => Remove(pending.RegistrationId);
+
+ internal int RegistrationCount
+ {
+ get
+ {
+ lock (_gate)
+ return _registrations.Count;
+ }
+ }
+
+ private void Remove(long id)
+ {
+ UiRoot? root;
+ UiElement? element;
+ lock (_gate)
+ {
+ if (!_registrations.Remove(id, out Registration? registration))
+ return;
+ root = registration.Root;
+ element = registration.Element;
+ }
+
+ if (root is not null && element is not null)
+ root.RemoveChild(element);
+ }
+
+ private sealed class RegistrationToken(
+ BufferedUiRegistry owner,
+ long registrationId) : IDisposable
+ {
+ private BufferedUiRegistry? _owner = owner;
+
+ public void Dispose() =>
+ Interlocked.Exchange(ref _owner, null)?.Remove(registrationId);
}
}
diff --git a/src/AcDream.App/Plugins/GraphicalPluginSession.cs b/src/AcDream.App/Plugins/GraphicalPluginSession.cs
new file mode 100644
index 00000000..a968028c
--- /dev/null
+++ b/src/AcDream.App/Plugins/GraphicalPluginSession.cs
@@ -0,0 +1,99 @@
+using AcDream.Core.Plugins;
+using AcDream.Platform;
+using AcDream.Plugin.Abstractions;
+using AcDream.Runtime.Session;
+
+namespace AcDream.App.Plugins;
+
+///
+/// Graphical-host composition for one plugin set. The shared
+/// owns discovery and collectible lifetimes; this
+/// adapter supplies the graphical roots and translates outcomes into the
+/// launcher status stream.
+///
+internal sealed class GraphicalPluginSession : IDisposable
+{
+ private readonly PluginSession _plugins;
+ private readonly string[] _roots;
+ private readonly IReadOnlyList? _allowList;
+ private readonly string _sessionId;
+ private readonly SessionStatusWriter _statusWriter;
+ private bool _started;
+
+ private GraphicalPluginSession(
+ PluginSession plugins,
+ string[] roots,
+ IReadOnlyList? allowList,
+ string sessionId,
+ SessionStatusWriter statusWriter)
+ {
+ _plugins = plugins;
+ _roots = roots;
+ _allowList = allowList;
+ _sessionId = sessionId;
+ _statusWriter = statusWriter;
+ }
+
+ internal int LoadedCount => _plugins.LoadedCount;
+
+ internal IReadOnlyList CaptureLoadContextWeakReferences() =>
+ _plugins.CaptureLoadContextWeakReferences();
+
+ internal static GraphicalPluginSession Create(
+ ApplicationPathSet paths,
+ IReadOnlyList? allowList,
+ string sessionId,
+ IPluginHost host,
+ SessionStatusWriter statusWriter)
+ {
+ ArgumentNullException.ThrowIfNull(paths);
+ ArgumentException.ThrowIfNullOrWhiteSpace(sessionId);
+ ArgumentNullException.ThrowIfNull(host);
+ ArgumentNullException.ThrowIfNull(statusWriter);
+
+ var plugins = new PluginSession(
+ host,
+ status => Report(statusWriter, sessionId, status));
+ return new GraphicalPluginSession(
+ plugins,
+ [
+ Path.Combine(AppContext.BaseDirectory, "plugins"),
+ paths.PluginsDirectory,
+ ],
+ allowList,
+ sessionId,
+ statusWriter);
+ }
+
+ internal void Start()
+ {
+ if (_started)
+ throw new InvalidOperationException(
+ "The graphical plugin session has already started.");
+ _started = true;
+
+ // Both real hosts publish the same startup prefix: started first,
+ // then one outcome for each configured plugin, then connection work.
+ _statusWriter.Started(_sessionId);
+ _plugins.Start(_roots, _allowList);
+ }
+
+ public void Dispose() => _plugins.Dispose();
+
+ private static void Report(
+ SessionStatusWriter writer,
+ string sessionId,
+ PluginSessionStatus status)
+ {
+ if (status.Kind == PluginSessionStatusKind.Loaded)
+ {
+ writer.PluginLoaded(sessionId, status.Plugin);
+ return;
+ }
+
+ writer.PluginFailed(
+ sessionId,
+ status.Plugin,
+ status.Error ?? "plugin failed");
+ }
+}
diff --git a/src/AcDream.App/Program.cs b/src/AcDream.App/Program.cs
index 52e647be..8b0407bb 100644
--- a/src/AcDream.App/Program.cs
+++ b/src/AcDream.App/Program.cs
@@ -4,7 +4,6 @@ using AcDream.App.Credentials;
using AcDream.App.Plugins;
using AcDream.App.Platform;
using AcDream.App.Rendering;
-using AcDream.Core.Plugins;
using AcDream.Platform;
using Serilog;
@@ -162,76 +161,16 @@ var host = new AppPluginHost(
worldEvents,
window.Selection,
uiRegistry);
-
-var loaded = new List();
-var loadedPluginIds = new HashSet(StringComparer.OrdinalIgnoreCase);
-StringComparer pathComparer =
- graphicalPlatform.OperatingSystem
- == GraphicalHostOperatingSystem.Windows
- ? StringComparer.OrdinalIgnoreCase
- : StringComparer.Ordinal;
-string[] pluginRoots =
-[
- .. new[]
- {
- Path.Combine(AppContext.BaseDirectory, "plugins"),
- applicationPaths.PluginsDirectory,
- }.Distinct(pathComparer),
-];
-
-foreach (string pluginsDir in pluginRoots)
-{
- Log.Information("scanning plugins in {PluginsDir}", pluginsDir);
- foreach (var result in PluginDiscovery.Scan(pluginsDir))
- {
- if (!result.Success)
- {
- Log.Warning(
- "plugin discovery failed for {Dir}: {Error}",
- result.PluginDirectory,
- result.Error);
- continue;
- }
-
- if (loadedPluginIds.Contains(result.Manifest!.Id))
- {
- Log.Warning(
- "skipping duplicate plugin id {Id} from {Dir}",
- result.Manifest.Id,
- result.PluginDirectory);
- continue;
- }
-
- var loadResult = PluginLoader.Load(
- result.PluginDirectory,
- result.Manifest,
- host);
- if (!loadResult.Success)
- {
- Log.Warning(
- "plugin load failed for {Id}: {Error}",
- result.Manifest.Id,
- loadResult.Error);
- continue;
- }
-
- loadedPluginIds.Add(result.Manifest.Id);
- loaded.Add(loadResult);
- Log.Information(
- "loaded plugin {Id} ({DisplayName})",
- result.Manifest.Id,
- result.Manifest.DisplayName);
- }
-}
+GraphicalPluginSession pluginSession = GraphicalPluginSession.Create(
+ applicationPaths,
+ runtimeOptions.Plugins,
+ runtimeOptions.SessionId ?? "app",
+ host,
+ window.StatusWriter);
+window.StartPluginHosting(pluginSession);
try
{
- foreach (var plugin in loaded)
- {
- try { plugin.Plugin!.Enable(); }
- catch (Exception ex) { Log.Error(ex, "plugin enable failed: {Id}", plugin.Manifest.Id); }
- }
-
try
{
window.Run();
@@ -244,11 +183,6 @@ try
}
finally
{
- foreach (var plugin in loaded)
- {
- try { plugin.Plugin!.Disable(); }
- catch (Exception ex) { Log.Error(ex, "plugin disable failed: {Id}", plugin.Manifest.Id); }
- }
Log.CloseAndFlush();
}
diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs
index f3be7ef2..ac349c78 100644
--- a/src/AcDream.App/Rendering/GameWindow.cs
+++ b/src/AcDream.App/Rendering/GameWindow.cs
@@ -362,6 +362,7 @@ public sealed class GameWindow :
private RuntimeActionState _runtimeActions => _runtime.ActionOwner;
public AcDream.Core.Selection.SelectionState Selection =>
_runtimeActions.Selection;
+ internal SessionStatusWriter StatusWriter => _statusWriter;
public AcDream.Core.Chat.ChatLog Chat => _runtimeCommunication.Chat;
public AcDream.Core.Chat.TurbineChatState TurbineChat =>
_runtimeCommunication.TurbineChat;
@@ -430,6 +431,7 @@ public sealed class GameWindow :
_creatureAppraisalFramePresenter;
// Phase D.2b Task 9 — plugin UI registrations buffered before OnLoad; drained in OnLoad.
private readonly AcDream.App.Plugins.BufferedUiRegistry? _uiRegistry;
+ private AcDream.App.Plugins.GraphicalPluginSession? _pluginSession;
// Campaign V slice V11 deleted the ImGui developer-tools frontend along
// with the OpenGL backend it required, so no host ever composes a
// developer UI regardless of ACDREAM_DEVTOOLS. The flag still reaches
@@ -722,6 +724,24 @@ public sealed class GameWindow :
_movementTruthDiagnostics);
}
+ ///
+ /// Transfers the graphical plugin lifetime into the window shutdown graph
+ /// and starts it before retained UI construction drains registrations.
+ ///
+ internal void StartPluginHosting(
+ AcDream.App.Plugins.GraphicalPluginSession pluginSession)
+ {
+ ArgumentNullException.ThrowIfNull(pluginSession);
+ if (_pluginSession is not null)
+ {
+ throw new InvalidOperationException(
+ "The graphical plugin session is already attached.");
+ }
+
+ _pluginSession = pluginSession;
+ pluginSession.Start();
+ }
+
public void Run()
{
_platformServices.ConfigureWindowBackend();
@@ -1554,9 +1574,7 @@ public sealed class GameWindow :
sessionPlayer),
frameRoots => new SessionStartCompositionPhase(
new SessionStartDependencies(
- Console.WriteLine,
- _statusWriter,
- _options.SessionId ?? "app"))
+ Console.WriteLine))
.Start(frameRoots));
}
@@ -1703,6 +1721,7 @@ public sealed class GameWindow :
_kbSource,
_retailUiLease,
_uiHost,
+ _pluginSession,
_runtime,
_movementInput,
_cameraInput,
diff --git a/src/AcDream.App/Rendering/GameWindowLifetime.cs b/src/AcDream.App/Rendering/GameWindowLifetime.cs
index 72605e26..92001eae 100644
--- a/src/AcDream.App/Rendering/GameWindowLifetime.cs
+++ b/src/AcDream.App/Rendering/GameWindowLifetime.cs
@@ -71,6 +71,7 @@ internal sealed record IngressShutdownRoots(
RetailUiRuntimeLease RetailUi,
// Keeps failed physical UI bindings alive through native-window release.
UiHost? RetainedUiHost,
+ IDisposable? Plugins,
GameRuntime Runtime,
DispatcherMovementInputSource MovementInput,
DispatcherCameraInputSource CameraInput,
@@ -422,6 +423,10 @@ internal static class GameWindowShutdownManifest
Soft("keyboard source", () => DisposeKeyboardSource(ingress.KeyboardSource)),
Soft("native window callbacks", () => DisposeWindowCallbacks(ingress.WindowCallbacks)),
]),
+ new ResourceShutdownStage("plugin host",
+ [
+ Hard("plugins", () => ingress.Plugins?.Dispose()),
+ ]),
new ResourceShutdownStage("frame borrowers",
[
Hard("world frame composition", () => frame.FrameGraphPublication?.Dispose()),
diff --git a/src/AcDream.App/UI/RetailUiRuntime.cs b/src/AcDream.App/UI/RetailUiRuntime.cs
index 8c21f9c0..b4164367 100644
--- a/src/AcDream.App/UI/RetailUiRuntime.cs
+++ b/src/AcDream.App/UI/RetailUiRuntime.cs
@@ -3273,10 +3273,12 @@ public sealed class RetailUiRuntime : IDisposable
_bindings.Assets.ResolveSprite,
_bindings.Assets.Controls);
Host.Root.AddChild(element);
+ _bindings.Plugins.CompleteMount(panel, Host.Root, element);
Console.WriteLine($"[D.2b] plugin UI panel loaded: {panel.MarkupPath}");
}
catch (Exception ex)
{
+ _bindings.Plugins.FailMount(panel);
Console.WriteLine($"[D.2b] plugin UI panel '{panel.MarkupPath}' failed to load: {ex.Message}");
}
}
diff --git a/src/AcDream.Core/Plugins/LoadedPlugin.cs b/src/AcDream.Core/Plugins/LoadedPlugin.cs
index a3f6d249..9f1f534a 100644
--- a/src/AcDream.Core/Plugins/LoadedPlugin.cs
+++ b/src/AcDream.Core/Plugins/LoadedPlugin.cs
@@ -7,8 +7,10 @@ namespace AcDream.Core.Plugins;
/// Outcome of a plugin load attempt.
/// On success, is the instantiated plugin,
/// owns its assembly, and is null.
-/// On failure, and are null and
-/// describes what went wrong.
+/// On failure, describes what went wrong. A partial
+/// and/or may still be present;
+/// the caller owns their cleanup. The loader never requests collectible unload
+/// itself because the session must first roll back host registrations.
///
public sealed record LoadedPlugin(
PluginManifest Manifest,
@@ -16,5 +18,6 @@ public sealed record LoadedPlugin(
AssemblyLoadContext? LoadContext,
Exception? Error)
{
- public bool Success => Plugin is not null && Error is null;
+ public bool Success =>
+ Plugin is not null && LoadContext is not null && Error is null;
}
diff --git a/src/AcDream.Core/Plugins/PluginLoader.cs b/src/AcDream.Core/Plugins/PluginLoader.cs
index ba2ba07d..1d729f2a 100644
--- a/src/AcDream.Core/Plugins/PluginLoader.cs
+++ b/src/AcDream.Core/Plugins/PluginLoader.cs
@@ -11,9 +11,15 @@ public static class PluginLoader
/// implementing , instantiate it, and call its
/// with the supplied host. Any failure
/// is returned as a failed rather than thrown.
+ /// A returned partial plugin/context remains caller-owned; this method never
+ /// requests unload because the caller must close host registrations first.
///
public static LoadedPlugin Load(string pluginDirectory, PluginManifest manifest, IPluginHost host)
{
+ ArgumentException.ThrowIfNullOrWhiteSpace(pluginDirectory);
+ ArgumentNullException.ThrowIfNull(manifest);
+ ArgumentNullException.ThrowIfNull(host);
+
var dllPath = Path.Combine(pluginDirectory, manifest.EntryDll);
if (!File.Exists(dllPath))
return new LoadedPlugin(
@@ -22,9 +28,11 @@ public static class PluginLoader
LoadContext: null,
Error: new FileNotFoundException($"entry dll not found: {dllPath}", dllPath));
+ PluginAssemblyLoadContext? alc = null;
+ IAcDreamPlugin? instance = null;
try
{
- var alc = new PluginAssemblyLoadContext(pluginDirectory, dllPath);
+ alc = new PluginAssemblyLoadContext(pluginDirectory, dllPath);
var asm = alc.LoadFromAssemblyPath(dllPath);
IEnumerable types;
@@ -41,20 +49,30 @@ public static class PluginLoader
.FirstOrDefault(t => !t.IsAbstract && typeof(IAcDreamPlugin).IsAssignableFrom(t));
if (pluginType is null)
+ {
return new LoadedPlugin(
manifest,
Plugin: null,
- LoadContext: null,
+ LoadContext: alc,
Error: new InvalidOperationException(
$"no IAcDreamPlugin implementation found in {manifest.EntryDll}"));
+ }
- var instance = (IAcDreamPlugin)Activator.CreateInstance(pluginType)!;
+ instance = (IAcDreamPlugin)Activator.CreateInstance(pluginType)!;
instance.Initialize(host);
return new LoadedPlugin(manifest, instance, alc, Error: null);
}
catch (Exception ex)
{
- return new LoadedPlugin(manifest, Plugin: null, LoadContext: null, Error: ex);
+ // The caller owns rollback for a partial instance/context. In
+ // particular, Initialize may already have attached host callbacks;
+ // the per-plugin host scope must remove those registrations before
+ // Disable or any collectible unload request can run.
+ return new LoadedPlugin(
+ manifest,
+ Plugin: instance,
+ LoadContext: alc,
+ Error: ex);
}
}
}
diff --git a/src/AcDream.Core/Plugins/PluginSession.cs b/src/AcDream.Core/Plugins/PluginSession.cs
new file mode 100644
index 00000000..1436fb6b
--- /dev/null
+++ b/src/AcDream.Core/Plugins/PluginSession.cs
@@ -0,0 +1,421 @@
+using AcDream.Plugin.Abstractions;
+
+namespace AcDream.Core.Plugins;
+
+public enum PluginSessionStatusKind
+{
+ Loaded,
+ Failed,
+}
+
+///
+/// Final startup outcome for one configured plugin id. Hosts translate these
+/// outcomes into their own diagnostics and the Campaign LA status stream.
+///
+public readonly record struct PluginSessionStatus(
+ string Plugin,
+ PluginSessionStatusKind Kind,
+ string? Error = null);
+
+///
+/// One host/session-scoped plugin lifetime. Discovery, allow-listing,
+/// initialize/enable, failure isolation, reverse-order disable, and collectible
+/// load-context release are shared by graphical and no-window hosts so their
+/// configured plugin-set semantics cannot drift.
+///
+public sealed class PluginSession : IDisposable
+{
+ private readonly IPluginHost _host;
+ private readonly Action? _report;
+ private readonly List _loaded = [];
+ private readonly List _releasedContexts = [];
+ private bool _started;
+ private bool _disposed;
+
+ public PluginSession(
+ IPluginHost host,
+ Action? report = null)
+ {
+ _host = host ?? throw new ArgumentNullException(nameof(host));
+ _report = report;
+ }
+
+ public int LoadedCount => _loaded.Count;
+
+ public IReadOnlyList LoadedPluginIds =>
+ _loaded.Select(static active => active.Loaded.Manifest.Id).ToArray();
+
+ ///
+ /// Discovers and starts the configured set exactly once. A
+ /// allow-list loads every discovered id; an explicit
+ /// empty list loads none. Matching and duplicate-id handling are
+ /// ordinal-ignore-case on every operating system because plugin ids are
+ /// logical identifiers, not paths.
+ ///
+ public void Start(
+ IEnumerable pluginRoots,
+ IReadOnlyList? allowList)
+ {
+ ArgumentNullException.ThrowIfNull(pluginRoots);
+ ObjectDisposedException.ThrowIf(_disposed, this);
+ if (_started)
+ throw new InvalidOperationException("The plugin session has already started.");
+ _started = true;
+
+ string[] roots = DistinctRoots(pluginRoots);
+ string[]? requested = allowList is null
+ ? null
+ : allowList
+ .Distinct(StringComparer.OrdinalIgnoreCase)
+ .ToArray();
+ if (requested is { Length: 0 })
+ return;
+
+ var candidates = new Dictionary>(
+ StringComparer.OrdinalIgnoreCase);
+ var errors = new Dictionary>(
+ StringComparer.OrdinalIgnoreCase);
+ var discoveredOrder = new List();
+ HashSet? requestedSet = requested is null
+ ? null
+ : new HashSet(requested, StringComparer.OrdinalIgnoreCase);
+
+ foreach (string root in roots)
+ {
+ IReadOnlyList results;
+ try
+ {
+ results = PluginDiscovery.Scan(root);
+ }
+ catch (Exception error) when (IsDiscoveryFailure(error))
+ {
+ SafeLog(
+ static (log, message, exception) =>
+ log.Error(message, exception),
+ $"plugin discovery failed for root '{root}'",
+ error);
+ continue;
+ }
+
+ foreach (PluginDiscoveryResult result in results)
+ {
+ if (!result.Success)
+ {
+ string directoryId = Path.GetFileName(
+ Path.TrimEndingDirectorySeparator(result.PluginDirectory));
+ if (string.IsNullOrWhiteSpace(directoryId)
+ || (requestedSet is not null
+ && !requestedSet.Contains(directoryId)))
+ {
+ continue;
+ }
+
+ AddOrdered(discoveredOrder, directoryId);
+ AddError(
+ errors,
+ directoryId,
+ result.Error ?? new InvalidOperationException(
+ "plugin discovery failed"));
+ continue;
+ }
+
+ string id = result.Manifest!.Id;
+ if (requestedSet is not null && !requestedSet.Contains(id))
+ continue;
+ AddOrdered(discoveredOrder, id);
+ if (!candidates.TryGetValue(id, out List? list))
+ {
+ list = [];
+ candidates.Add(id, list);
+ }
+ list.Add(result);
+ }
+ }
+
+ IEnumerable loadOrder = requested is null
+ ? discoveredOrder
+ : requested;
+ foreach (string id in loadOrder)
+ LoadOne(id, candidates, errors);
+ }
+
+ ///
+ /// Test/diagnostic observation of the exact collectible contexts currently
+ /// owned by this session. The returned weak references do not delay unload.
+ ///
+ public IReadOnlyList CaptureLoadContextWeakReferences() =>
+ [
+ .. _releasedContexts,
+ .. _loaded.Select(static active =>
+ new WeakReference(active.Loaded.LoadContext!)),
+ ];
+
+ public void Dispose()
+ {
+ if (_disposed)
+ return;
+ _disposed = true;
+
+ for (int index = _loaded.Count - 1; index >= 0; index--)
+ {
+ ActivePlugin active = _loaded[index];
+ LoadedPlugin loaded = active.Loaded;
+ try
+ {
+ loaded.Plugin!.Disable();
+ }
+ catch (Exception error)
+ {
+ SafeLog(
+ static (log, message, exception) =>
+ log.Error(message, exception),
+ $"plugin disable failed: {loaded.Manifest.Id}",
+ error);
+ }
+
+ // Host-owned registrations are released even when Disable throws.
+ // This must precede ALC unload so no UI binding or event delegate
+ // can keep the plugin assembly reachable.
+ active.Scope.Dispose();
+
+ try
+ {
+ loaded.LoadContext!.Unload();
+ }
+ catch (Exception error)
+ {
+ SafeLog(
+ static (log, message, exception) =>
+ log.Error(message, exception),
+ $"plugin unload failed: {loaded.Manifest.Id}",
+ error);
+ }
+ }
+
+ // Drop both plugin instances and AssemblyLoadContext references. The
+ // CLR completes collectible unload after no plugin-owned object remains
+ // reachable and a normal GC cycle observes the contexts.
+ _loaded.Clear();
+ }
+
+ private void LoadOne(
+ string id,
+ IReadOnlyDictionary> candidates,
+ Dictionary> errors)
+ {
+ if (candidates.TryGetValue(id, out List? available))
+ {
+ foreach (PluginDiscoveryResult candidate in available)
+ {
+ var scope = new ScopedPluginHost(_host);
+ LoadedPlugin loaded = PluginLoader.Load(
+ candidate.PluginDirectory,
+ candidate.Manifest!,
+ scope);
+ if (!loaded.Success)
+ {
+ // Initialize can register callbacks before it fails. The
+ // registration transaction closes before plugin cleanup
+ // and, critically, before any ALC Unloading notification.
+ scope.Dispose();
+ ReleaseFailedLoad(loaded);
+ AddError(
+ errors,
+ id,
+ loaded.Error ?? new InvalidOperationException(
+ "plugin load failed"));
+ continue;
+ }
+
+ try
+ {
+ loaded.Plugin!.Enable();
+ _loaded.Add(new ActivePlugin(loaded, scope));
+ SafeLog(
+ static (log, message, _) => log.Info(message),
+ $"plugin loaded: {loaded.Manifest.Id} "
+ + $"({loaded.Manifest.DisplayName})",
+ null);
+ Report(new PluginSessionStatus(
+ loaded.Manifest.Id,
+ PluginSessionStatusKind.Loaded));
+ return;
+ }
+ catch (Exception error)
+ {
+ AddError(errors, id, error);
+ ReleaseFailedEnable(loaded, scope);
+ }
+ }
+ }
+
+ if (!errors.TryGetValue(id, out List? failures)
+ || failures.Count == 0)
+ {
+ failures =
+ [
+ new FileNotFoundException(
+ $"plugin '{id}' was not found in the configured plugin roots."),
+ ];
+ }
+
+ string errorText = string.Join(
+ " | ",
+ failures.Select(Describe));
+ Report(new PluginSessionStatus(
+ id,
+ PluginSessionStatusKind.Failed,
+ errorText));
+ SafeLog(
+ static (log, message, _) => log.Warn(message),
+ $"plugin failed: {id}: {errorText}",
+ null);
+ }
+
+ private void ReleaseFailedEnable(
+ LoadedPlugin loaded,
+ ScopedPluginHost scope)
+ {
+ try
+ {
+ loaded.Plugin!.Disable();
+ }
+ catch (Exception error)
+ {
+ SafeLog(
+ static (log, message, exception) =>
+ log.Error(message, exception),
+ $"plugin cleanup after enable failure failed: {loaded.Manifest.Id}",
+ error);
+ }
+
+ scope.Dispose();
+
+ _releasedContexts.Add(new WeakReference(loaded.LoadContext!));
+ try
+ {
+ loaded.LoadContext!.Unload();
+ }
+ catch (Exception error)
+ {
+ SafeLog(
+ static (log, message, exception) =>
+ log.Error(message, exception),
+ $"plugin unload after enable failure failed: {loaded.Manifest.Id}",
+ error);
+ }
+ }
+
+ private void ReleaseFailedLoad(LoadedPlugin loaded)
+ {
+ if (loaded.Plugin is not null)
+ {
+ try
+ {
+ loaded.Plugin.Disable();
+ }
+ catch (Exception error)
+ {
+ SafeLog(
+ static (log, message, exception) =>
+ log.Error(message, exception),
+ $"plugin cleanup after initialize failure failed: {loaded.Manifest.Id}",
+ error);
+ }
+ }
+
+ if (loaded.LoadContext is null)
+ return;
+
+ _releasedContexts.Add(new WeakReference(loaded.LoadContext));
+ try
+ {
+ loaded.LoadContext.Unload();
+ }
+ catch (Exception error)
+ {
+ SafeLog(
+ static (log, message, exception) =>
+ log.Error(message, exception),
+ $"plugin unload after load failure failed: {loaded.Manifest.Id}",
+ error);
+ }
+ }
+
+ private void Report(PluginSessionStatus status)
+ {
+ if (_report is null)
+ return;
+ try
+ {
+ _report(status);
+ }
+ catch (Exception error)
+ {
+ SafeLog(
+ static (log, message, exception) =>
+ log.Error(message, exception),
+ $"plugin status observer failed for {status.Plugin}",
+ error);
+ }
+ }
+
+ private void SafeLog(
+ Action write,
+ string message,
+ Exception? error)
+ {
+ try { write(_host.Log, message, error); }
+ catch { }
+ }
+
+ private static string[] DistinctRoots(IEnumerable roots)
+ {
+ StringComparer comparer = OperatingSystem.IsWindows()
+ ? StringComparer.OrdinalIgnoreCase
+ : StringComparer.Ordinal;
+ return roots
+ .Where(static root => !string.IsNullOrWhiteSpace(root))
+ .Select(Path.GetFullPath)
+ .Distinct(comparer)
+ .ToArray();
+ }
+
+ private static void AddOrdered(List ordered, string id)
+ {
+ if (!ordered.Contains(id, StringComparer.OrdinalIgnoreCase))
+ ordered.Add(id);
+ }
+
+ private static void AddError(
+ Dictionary> errors,
+ string id,
+ Exception error)
+ {
+ if (!errors.TryGetValue(id, out List? list))
+ {
+ list = [];
+ errors.Add(id, list);
+ }
+ list.Add(error);
+ }
+
+ private static string Describe(Exception error)
+ {
+ Exception root = error.GetBaseException();
+ return string.IsNullOrWhiteSpace(root.Message)
+ ? root.GetType().Name
+ : root.Message;
+ }
+
+ private static bool IsDiscoveryFailure(Exception error) =>
+ error is IOException
+ or UnauthorizedAccessException
+ or ArgumentException
+ or NotSupportedException
+ or System.Security.SecurityException;
+
+ private sealed record ActivePlugin(
+ LoadedPlugin Loaded,
+ ScopedPluginHost Scope);
+}
diff --git a/src/AcDream.Core/Plugins/ScopedPluginHost.cs b/src/AcDream.Core/Plugins/ScopedPluginHost.cs
new file mode 100644
index 00000000..ee1667f1
--- /dev/null
+++ b/src/AcDream.Core/Plugins/ScopedPluginHost.cs
@@ -0,0 +1,272 @@
+using AcDream.Plugin.Abstractions;
+
+namespace AcDream.Core.Plugins;
+
+///
+/// Per-plugin host view that owns every registration made through the public
+/// event/selection/UI surfaces. Disposal is the host's rollback boundary: it removes
+/// registrations even when plugin Initialize/Enable/Disable code throws.
+///
+internal sealed class ScopedPluginHost : IPluginHost, IDisposable
+{
+ private readonly IPluginHost _inner;
+ private readonly ScopedEvents _events;
+ private readonly ScopedSelectionService _selection;
+ private readonly ScopedUiRegistry _ui;
+ private bool _disposed;
+
+ internal ScopedPluginHost(IPluginHost inner)
+ {
+ _inner = inner ?? throw new ArgumentNullException(nameof(inner));
+ _events = new ScopedEvents(inner.Events);
+ _selection = new ScopedSelectionService(inner.Selection);
+ _ui = new ScopedUiRegistry(inner.Ui);
+ }
+
+ public bool HasUi => _inner.HasUi;
+ public IPluginLogger Log => _inner.Log;
+ public IGameState State => _inner.State;
+ public IEvents Events => _events;
+ public ISelectionService Selection => _selection;
+ public IUiRegistry Ui => _ui;
+
+ public void Dispose()
+ {
+ if (_disposed)
+ return;
+ _disposed = true;
+ _events.Dispose();
+ _selection.Dispose();
+ _ui.Dispose();
+ }
+
+ private sealed class ScopedSelectionService(ISelectionService inner)
+ : ISelectionService,
+ IDisposable
+ {
+ private readonly object _gate = new();
+ private readonly List> _registrations = [];
+ private bool _disposed;
+
+ public uint? SelectedObjectId => inner.SelectedObjectId;
+ public uint? PreviousObjectId => inner.PreviousObjectId;
+
+ public event Action Changed
+ {
+ add
+ {
+ ArgumentNullException.ThrowIfNull(value);
+ try
+ {
+ inner.Changed += value;
+ }
+ catch
+ {
+ try { inner.Changed -= value; }
+ catch { }
+ throw;
+ }
+ lock (_gate)
+ {
+ if (!_disposed)
+ {
+ _registrations.Add(value);
+ return;
+ }
+ }
+
+ try { inner.Changed -= value; }
+ catch { }
+ throw new ObjectDisposedException(nameof(ScopedSelectionService));
+ }
+ remove
+ {
+ if (value is null)
+ return;
+ inner.Changed -= value;
+ lock (_gate)
+ RemoveLast(value);
+ }
+ }
+
+ public bool Select(uint objectId)
+ {
+ lock (_gate)
+ {
+ ObjectDisposedException.ThrowIf(_disposed, this);
+ return inner.Select(objectId);
+ }
+ }
+
+ public bool Clear()
+ {
+ lock (_gate)
+ {
+ ObjectDisposedException.ThrowIf(_disposed, this);
+ return inner.Clear();
+ }
+ }
+
+ public void Dispose()
+ {
+ Action[] registrations;
+ lock (_gate)
+ {
+ if (_disposed)
+ return;
+ _disposed = true;
+ registrations = _registrations.ToArray();
+ _registrations.Clear();
+ }
+
+ for (int index = registrations.Length - 1; index >= 0; index--)
+ {
+ try { inner.Changed -= registrations[index]; }
+ catch { }
+ }
+ }
+
+ private void RemoveLast(Action handler)
+ {
+ for (int index = _registrations.Count - 1; index >= 0; index--)
+ {
+ if (_registrations[index] != handler)
+ continue;
+ _registrations.RemoveAt(index);
+ return;
+ }
+ }
+ }
+
+ private sealed class ScopedEvents(IEvents inner) : IEvents, IDisposable
+ {
+ private readonly object _gate = new();
+ private readonly List> _registrations = [];
+ private bool _disposed;
+
+ public event Action EntitySpawned
+ {
+ add
+ {
+ ArgumentNullException.ThrowIfNull(value);
+ try
+ {
+ inner.EntitySpawned += value;
+ }
+ catch
+ {
+ // A custom event source may mutate before its add accessor
+ // faults. Best-effort removal keeps the scope transactional.
+ try { inner.EntitySpawned -= value; }
+ catch { }
+ throw;
+ }
+ lock (_gate)
+ {
+ if (!_disposed)
+ {
+ _registrations.Add(value);
+ return;
+ }
+ }
+
+ // Disposal may race the host subscription call. In that case
+ // the disposal snapshot could not see this registration, so
+ // the attaching thread must roll it back before returning.
+ try { inner.EntitySpawned -= value; }
+ catch { }
+ throw new ObjectDisposedException(nameof(ScopedEvents));
+ }
+ remove
+ {
+ if (value is null)
+ return;
+ inner.EntitySpawned -= value;
+ lock (_gate)
+ RemoveLast(value);
+ }
+ }
+
+ public void Dispose()
+ {
+ Action[] registrations;
+ lock (_gate)
+ {
+ if (_disposed)
+ return;
+ _disposed = true;
+ registrations = _registrations.ToArray();
+ _registrations.Clear();
+ }
+
+ for (int index = registrations.Length - 1; index >= 0; index--)
+ {
+ try { inner.EntitySpawned -= registrations[index]; }
+ catch { }
+ }
+ }
+
+ private void RemoveLast(Action handler)
+ {
+ for (int index = _registrations.Count - 1; index >= 0; index--)
+ {
+ if (_registrations[index] != handler)
+ continue;
+ _registrations.RemoveAt(index);
+ return;
+ }
+ }
+ }
+
+ private sealed class ScopedUiRegistry : IUiRegistry, IDisposable
+ {
+ private readonly IScopedUiRegistry _inner;
+ private readonly object _gate = new();
+ private readonly List _registrations = [];
+ private bool _disposed;
+
+ internal ScopedUiRegistry(IUiRegistry inner)
+ {
+ _inner = inner as IScopedUiRegistry
+ ?? throw new InvalidOperationException(
+ "Plugin hosts must expose an IScopedUiRegistry so UI registrations can be rolled back.");
+ }
+
+ public void AddMarkupPanel(string markupPath, object binding)
+ {
+ IDisposable registration = _inner.RegisterMarkupPanel(
+ markupPath,
+ binding);
+ lock (_gate)
+ {
+ if (!_disposed)
+ {
+ _registrations.Add(registration);
+ return;
+ }
+ }
+
+ registration.Dispose();
+ throw new ObjectDisposedException(nameof(ScopedUiRegistry));
+ }
+
+ public void Dispose()
+ {
+ IDisposable[] registrations;
+ lock (_gate)
+ {
+ if (_disposed)
+ return;
+ _disposed = true;
+ registrations = _registrations.ToArray();
+ _registrations.Clear();
+ }
+
+ for (int index = registrations.Length - 1; index >= 0; index--)
+ {
+ try { registrations[index].Dispose(); }
+ catch { }
+ }
+ }
+ }
+}
diff --git a/src/AcDream.Headless/Hosting/HeadlessProcessHost.cs b/src/AcDream.Headless/Hosting/HeadlessProcessHost.cs
index 5d38edb0..c620c52a 100644
--- a/src/AcDream.Headless/Hosting/HeadlessProcessHost.cs
+++ b/src/AcDream.Headless/Hosting/HeadlessProcessHost.cs
@@ -56,6 +56,11 @@ internal sealed class HeadlessProcessHost : IDisposable
paths.ConfigDirectory);
var sessions = new List(
configuration.Sessions.Count);
+ string[] pluginRoots =
+ [
+ Path.Combine(AppContext.BaseDirectory, "plugins"),
+ paths.PluginsDirectory,
+ ];
HeadlessProcessContentOwner? content = null;
HeadlessProcessResourceSampler? resources = null;
// FA6: constructed unconditionally — cheap, and every non-gate
@@ -104,7 +109,8 @@ internal sealed class HeadlessProcessHost : IDisposable
sessionOperations,
timeProvider,
contentLease: contentLease,
- gateCoordinator: gateCoordinator));
+ gateCoordinator: gateCoordinator,
+ pluginRoots: pluginRoots));
}
catch
{
diff --git a/src/AcDream.Headless/Hosting/HeadlessSessionHost.cs b/src/AcDream.Headless/Hosting/HeadlessSessionHost.cs
index 0f6340a1..9e025f61 100644
--- a/src/AcDream.Headless/Hosting/HeadlessSessionHost.cs
+++ b/src/AcDream.Headless/Hosting/HeadlessSessionHost.cs
@@ -1,6 +1,7 @@
using AcDream.Headless.Configuration;
using AcDream.Headless.Credentials;
using AcDream.Headless.Diagnostics;
+using AcDream.Headless.Plugins;
using AcDream.Headless.Policies;
using AcDream.Core.Net.Messages;
using AcDream.Core.Physics;
@@ -155,6 +156,7 @@ internal sealed class HeadlessSessionHost : IDisposable
private readonly IDisposable _hostLease;
private readonly IHeadlessBotPolicy _policy;
private readonly IDisposable _policySubscription;
+ private readonly HeadlessPluginSession _pluginSession;
private readonly LiveSessionHost _liveSession;
private readonly RuntimeLocalPlayerFrameController _localPlayerFrame;
private readonly HeadlessProcessContentOwner.HeadlessProcessContentLease?
@@ -238,7 +240,8 @@ internal sealed class HeadlessSessionHost : IDisposable
contentLease = null,
IHeadlessBotPolicy? policyOverride = null,
IRuntimePlacementProjectionSink? placementSinkOverride = null,
- FellowshipAllegianceGateCoordinator? gateCoordinator = null)
+ FellowshipAllegianceGateCoordinator? gateCoordinator = null,
+ IEnumerable? pluginRoots = null)
{
_descriptor = descriptor
?? throw new ArgumentNullException(nameof(descriptor));
@@ -263,6 +266,7 @@ internal sealed class HeadlessSessionHost : IDisposable
IDisposable? hostLease = null;
IHeadlessBotPolicy? policy = null;
IDisposable? policySubscription = null;
+ HeadlessPluginSession? pluginSession = null;
try
{
var gameplay = new HeadlessGameplayOperations();
@@ -297,6 +301,13 @@ internal sealed class HeadlessSessionHost : IDisposable
// descriptor.StatusFile is unset — every call site below stays
// unconditional.
var statusWriter = new SessionStatusWriter(descriptor.StatusFile);
+ pluginSession = HeadlessPluginSession.Create(
+ runtime,
+ diagnostics,
+ statusWriter,
+ descriptor.Id,
+ pluginRoots ?? [],
+ descriptor.Plugins);
var liveSession = new LiveSessionHost(
runtime.Session,
new LiveSessionHostBindings(
@@ -402,9 +413,11 @@ internal sealed class HeadlessSessionHost : IDisposable
_hostLease = hostLease;
_policy = policy;
_policySubscription = policySubscription;
+ _pluginSession = pluginSession;
}
catch
{
+ pluginSession?.Dispose();
policySubscription?.Dispose();
policy?.Dispose();
hostLease?.Dispose();
@@ -427,6 +440,7 @@ internal sealed class HeadlessSessionHost : IDisposable
/// production code uses to reach the same state.
///
internal HeadlessCharacterOptionsSeeder? OptionsSeeder => _optionsSeeder;
+ internal HeadlessPluginSession Plugins => _pluginSession;
internal string SessionId => _descriptor.Id;
internal string ActiveCharacterName { get; private set; } =
string.Empty;
@@ -474,6 +488,7 @@ internal sealed class HeadlessSessionHost : IDisposable
// Campaign LA slice LA1: "started" = session host start — the
// earliest point this session actually attempts to connect.
_statusWriter.Started(_descriptor.Id);
+ _pluginSession.Start();
RuntimeSessionStartResult result =
Commands.Session.Start(Runtime.Generation);
_startOutcome = result.Status;
@@ -638,22 +653,26 @@ internal sealed class HeadlessSessionHost : IDisposable
_disposeStage++;
break;
case 4:
- _hostLease.Dispose();
+ _pluginSession.Dispose();
_disposeStage++;
break;
case 5:
- _credential.Dispose();
+ _hostLease.Dispose();
_disposeStage++;
break;
case 6:
- Runtime.Dispose();
+ _credential.Dispose();
_disposeStage++;
break;
case 7:
- _contentLease?.Dispose();
+ Runtime.Dispose();
_disposeStage++;
break;
case 8:
+ _contentLease?.Dispose();
+ _disposeStage++;
+ break;
+ case 9:
_diagnostics.Message(
_descriptor.Id,
"disposed",
diff --git a/src/AcDream.Headless/Platform/HeadlessPathSet.cs b/src/AcDream.Headless/Platform/HeadlessPathSet.cs
index bdbe1866..e8e110e2 100644
--- a/src/AcDream.Headless/Platform/HeadlessPathSet.cs
+++ b/src/AcDream.Headless/Platform/HeadlessPathSet.cs
@@ -8,6 +8,9 @@ internal sealed record HeadlessPathSet(
string DataDirectory,
string CacheDirectory)
{
+ internal string PluginsDirectory =>
+ Path.Combine(DataDirectory, "plugins");
+
internal static HeadlessPathSet Resolve(
HeadlessPathOverrides overrides,
IHeadlessPlatformEnvironment? platform = null)
diff --git a/src/AcDream.Headless/Plugins/HeadlessPluginHost.cs b/src/AcDream.Headless/Plugins/HeadlessPluginHost.cs
new file mode 100644
index 00000000..ca5b6d5a
--- /dev/null
+++ b/src/AcDream.Headless/Plugins/HeadlessPluginHost.cs
@@ -0,0 +1,265 @@
+using AcDream.Plugin.Abstractions;
+using AcDream.Runtime;
+
+namespace AcDream.Headless.Plugins;
+
+///
+/// No-window plugin surface over one exact . State is
+/// projected on demand from Runtime's canonical entity view, events come from
+/// Runtime's ordered event source, and selection is the exact J5 action owner;
+/// this adapter owns no gameplay mirror.
+///
+internal sealed class HeadlessPluginHost
+ : IPluginHost,
+ IGameState,
+ IEvents,
+ IRuntimeEventObserver,
+ IDisposable
+{
+ private readonly GameRuntime _runtime;
+ private readonly IDisposable _eventSubscription;
+ private readonly object _eventGate = new();
+ private readonly List _subscriptions = [];
+ private Subscription[] _liveSnapshot = [];
+ private bool _disposed;
+
+ private readonly record struct ReplayEntity(
+ RuntimeEntityIdentity Identity,
+ WorldEntitySnapshot Snapshot);
+
+ private sealed class Subscription(Action handler)
+ {
+ internal Action Handler { get; } = handler;
+ internal Queue Pending { get; } = new();
+ internal HashSet Delivered { get; } = [];
+ internal bool Replaying { get; set; } = true;
+ internal bool Active { get; set; } = true;
+ }
+
+ internal HeadlessPluginHost(
+ GameRuntime runtime,
+ IPluginLogger logger)
+ {
+ _runtime = runtime ?? throw new ArgumentNullException(nameof(runtime));
+ Log = logger ?? throw new ArgumentNullException(nameof(logger));
+ _eventSubscription = runtime.Subscribe(this);
+ }
+
+ public bool HasUi => false;
+ public IPluginLogger Log { get; }
+ public IGameState State => this;
+ public IEvents Events => this;
+ public ISelectionService Selection => _runtime.ActionOwner.Selection;
+ public IUiRegistry Ui => NoOpUiRegistry.Instance;
+
+ /// Test-only barrier invoked after the first replay item is
+ /// captured while Runtime's exact active-membership read lease is still
+ /// held.
+ internal Action? ReplayCapturedForTest { get; set; }
+
+ ///
+ /// Immutable point-in-time values produced directly from Runtime on each
+ /// read. The caller owns the returned snapshot list; this host retains no
+ /// entity collection and therefore cannot become a second gameplay owner.
+ ///
+ public IReadOnlyList Entities
+ {
+ get
+ {
+ ObjectDisposedException.ThrowIf(_disposed, this);
+ var visitor = new SnapshotVisitor(_runtime);
+ _runtime.Entities.Visit(visitor);
+ return visitor.Items.Select(static item => item.Snapshot).ToArray();
+ }
+ }
+
+ public event Action EntitySpawned
+ {
+ add
+ {
+ ArgumentNullException.ThrowIfNull(value);
+ ObjectDisposedException.ThrowIf(_disposed, this);
+ var subscription = new Subscription(value);
+ lock (_eventGate)
+ {
+ ObjectDisposedException.ThrowIf(_disposed, this);
+ _subscriptions.Add(subscription);
+ }
+
+ // Arm the pending queue before borrowing Runtime's snapshot. This
+ // avoids a host-lock/Runtime-lock inversion while the identity
+ // dedup below collapses any registration present in both views.
+ var visitor = new SnapshotVisitor(
+ _runtime,
+ ReplayCapturedForTest);
+ _runtime.Entities.Visit(visitor);
+ ReplayEntity[] replay = visitor.Items.ToArray();
+
+ foreach (ReplayEntity item in replay)
+ {
+ lock (_eventGate)
+ {
+ if (!subscription.Active)
+ return;
+ if (!_runtime.Entities.TryGet(
+ item.Identity.ServerGuid,
+ out RuntimeEntitySnapshot current)
+ || current.Identity != item.Identity
+ || !subscription.Delivered.Add(item.Identity))
+ {
+ continue;
+ }
+ }
+
+ Invoke(subscription.Handler, item.Snapshot);
+ }
+
+ while (true)
+ {
+ ReplayEntity pending;
+ lock (_eventGate)
+ {
+ if (!subscription.Active)
+ return;
+ if (!subscription.Pending.TryDequeue(out pending))
+ {
+ subscription.Replaying = false;
+ subscription.Delivered.Clear();
+ RebuildLiveSnapshotLocked();
+ return;
+ }
+ if (!subscription.Delivered.Add(pending.Identity))
+ continue;
+ }
+
+ Invoke(subscription.Handler, pending.Snapshot);
+ }
+ }
+ remove
+ {
+ if (value is null)
+ return;
+ lock (_eventGate)
+ {
+ for (int index = _subscriptions.Count - 1; index >= 0; index--)
+ {
+ Subscription subscription = _subscriptions[index];
+ if (subscription.Handler != value)
+ continue;
+ subscription.Active = false;
+ subscription.Pending.Clear();
+ subscription.Delivered.Clear();
+ _subscriptions.RemoveAt(index);
+ if (!subscription.Replaying)
+ RebuildLiveSnapshotLocked();
+ break;
+ }
+ }
+ }
+ }
+
+ public void Dispose()
+ {
+ if (_disposed)
+ return;
+ lock (_eventGate)
+ {
+ _disposed = true;
+ foreach (Subscription subscription in _subscriptions)
+ {
+ subscription.Active = false;
+ subscription.Pending.Clear();
+ subscription.Delivered.Clear();
+ }
+ _subscriptions.Clear();
+ _liveSnapshot = [];
+ }
+ _eventSubscription.Dispose();
+ }
+
+ public void OnEntity(in RuntimeEntityDelta delta)
+ {
+ if (delta.Change != RuntimeEntityChange.Registered)
+ return;
+ Subscription[] toNotify;
+ var pending = new ReplayEntity(
+ delta.Entity.Identity,
+ Convert(_runtime, delta.Entity));
+ lock (_eventGate)
+ {
+ if (_disposed)
+ return;
+ foreach (Subscription subscription in _subscriptions)
+ {
+ if (subscription.Active && subscription.Replaying)
+ subscription.Pending.Enqueue(pending);
+ }
+ toNotify = _liveSnapshot;
+ }
+ if (toNotify.Length == 0)
+ return;
+
+ foreach (Subscription subscription in toNotify)
+ Invoke(subscription.Handler, pending.Snapshot);
+ }
+
+ public void OnLifecycle(in RuntimeLifecycleDelta delta) { }
+ public void OnCommand(in RuntimeCommandDelta delta) { }
+ public void OnInventory(in RuntimeInventoryDelta delta) { }
+ public void OnChat(in RuntimeChatDelta delta) { }
+ public void OnMovement(in RuntimeMovementDelta delta) { }
+ public void OnPortal(in RuntimePortalDelta delta) { }
+ public void OnCombat(in RuntimeCombatDelta delta) { }
+
+ private static WorldEntitySnapshot Convert(
+ GameRuntime runtime,
+ in RuntimeEntitySnapshot entity)
+ {
+ uint sourceId = runtime.EntityObjects.Entities.TryGetActive(
+ entity.Identity.ServerGuid,
+ out AcDream.Runtime.Entities.RuntimeEntityRecord record)
+ ? record.Snapshot.SetupTableId ?? 0u
+ : 0u;
+ return new WorldEntitySnapshot(
+ entity.Identity.LocalEntityId,
+ sourceId,
+ entity.Position?.Frame.Origin ?? default,
+ entity.Position?.Frame.Orientation
+ ?? System.Numerics.Quaternion.Identity);
+ }
+
+ private static void Invoke(
+ Action handler,
+ WorldEntitySnapshot snapshot)
+ {
+ try { handler(snapshot); }
+ catch { }
+ }
+
+ private sealed class SnapshotVisitor(
+ GameRuntime runtime,
+ Action? captureBarrier = null)
+ : IRuntimeEntityVisitor
+ {
+ private Action? _captureBarrier = captureBarrier;
+
+ internal List Items { get; } =
+ new(runtime.Entities.Count);
+
+ public void Visit(in RuntimeEntitySnapshot entity)
+ {
+ Items.Add(new ReplayEntity(
+ entity.Identity,
+ Convert(runtime, entity)));
+ Interlocked.Exchange(ref _captureBarrier, null)?.Invoke();
+ }
+ }
+
+ private void RebuildLiveSnapshotLocked()
+ {
+ _liveSnapshot = _subscriptions
+ .Where(static subscription =>
+ subscription.Active && !subscription.Replaying)
+ .ToArray();
+ }
+}
diff --git a/src/AcDream.Headless/Plugins/HeadlessPluginLogger.cs b/src/AcDream.Headless/Plugins/HeadlessPluginLogger.cs
new file mode 100644
index 00000000..8b8494c2
--- /dev/null
+++ b/src/AcDream.Headless/Plugins/HeadlessPluginLogger.cs
@@ -0,0 +1,43 @@
+using AcDream.Headless.Diagnostics;
+using AcDream.Plugin.Abstractions;
+
+namespace AcDream.Headless.Plugins;
+
+internal sealed class HeadlessPluginLogger : IPluginLogger
+{
+ private readonly HeadlessDiagnosticWriter _diagnostics;
+ private readonly string _sessionId;
+ private readonly Func _generation;
+
+ internal HeadlessPluginLogger(
+ HeadlessDiagnosticWriter diagnostics,
+ string sessionId,
+ Func generation)
+ {
+ _diagnostics = diagnostics
+ ?? throw new ArgumentNullException(nameof(diagnostics));
+ _sessionId = sessionId
+ ?? throw new ArgumentNullException(nameof(sessionId));
+ _generation = generation
+ ?? throw new ArgumentNullException(nameof(generation));
+ }
+
+ public void Info(string message) => Write("info", message);
+ public void Warn(string message) => Write("warn", message);
+
+ public void Error(string message, Exception? exception = null)
+ {
+ if (exception is not null)
+ {
+ _diagnostics.Failure(_sessionId, "plugin", exception);
+ return;
+ }
+ Write("error", message);
+ }
+
+ private void Write(string level, string message) =>
+ _diagnostics.Message(
+ _sessionId,
+ $"plugin-{level}:{message}",
+ _generation());
+}
diff --git a/src/AcDream.Headless/Plugins/HeadlessPluginSession.cs b/src/AcDream.Headless/Plugins/HeadlessPluginSession.cs
new file mode 100644
index 00000000..d199752b
--- /dev/null
+++ b/src/AcDream.Headless/Plugins/HeadlessPluginSession.cs
@@ -0,0 +1,120 @@
+using AcDream.Core.Plugins;
+using AcDream.Headless.Diagnostics;
+using AcDream.Plugin.Abstractions;
+using AcDream.Runtime;
+using AcDream.Runtime.Session;
+
+namespace AcDream.Headless.Plugins;
+
+///
+/// Headless composition wrapper that keeps plugin disable/unsubscribe/unload
+/// ahead of canonical Runtime disposal.
+///
+internal sealed class HeadlessPluginSession : IDisposable
+{
+ private readonly HeadlessPluginHost _host;
+ private readonly PluginSession _plugins;
+ private readonly string[] _roots;
+ private readonly IReadOnlyList? _allowList;
+ private int _disposeStage;
+ private bool _started;
+ private bool _disposed;
+
+ private HeadlessPluginSession(
+ HeadlessPluginHost host,
+ PluginSession plugins,
+ string[] roots,
+ IReadOnlyList? allowList)
+ {
+ _host = host;
+ _plugins = plugins;
+ _roots = roots;
+ _allowList = allowList;
+ }
+
+ internal int LoadedCount => _plugins.LoadedCount;
+ internal HeadlessPluginHost Host => _host;
+
+ internal IReadOnlyList CaptureLoadContextWeakReferences() =>
+ _plugins.CaptureLoadContextWeakReferences();
+
+ internal static HeadlessPluginSession Create(
+ GameRuntime runtime,
+ HeadlessDiagnosticWriter diagnostics,
+ SessionStatusWriter statusWriter,
+ string sessionId,
+ IEnumerable roots,
+ IReadOnlyList? allowList)
+ {
+ ArgumentNullException.ThrowIfNull(runtime);
+ ArgumentNullException.ThrowIfNull(diagnostics);
+ ArgumentNullException.ThrowIfNull(statusWriter);
+ ArgumentException.ThrowIfNullOrWhiteSpace(sessionId);
+ ArgumentNullException.ThrowIfNull(roots);
+
+ var host = new HeadlessPluginHost(
+ runtime,
+ new HeadlessPluginLogger(
+ diagnostics,
+ sessionId,
+ () => runtime.Generation.Value));
+ var plugins = new PluginSession(
+ host,
+ status => Report(statusWriter, sessionId, status));
+ return new HeadlessPluginSession(
+ host,
+ plugins,
+ roots.ToArray(),
+ allowList);
+ }
+
+ internal void Start()
+ {
+ ObjectDisposedException.ThrowIf(_disposed, this);
+ if (_started)
+ throw new InvalidOperationException(
+ "The headless plugin session has already started.");
+ _started = true;
+ _plugins.Start(_roots, _allowList);
+ }
+
+ public void Dispose()
+ {
+ if (_disposed)
+ return;
+ while (!_disposed)
+ {
+ switch (_disposeStage)
+ {
+ case 0:
+ _plugins.Dispose();
+ _disposeStage++;
+ break;
+ case 1:
+ _host.Dispose();
+ _disposeStage++;
+ _disposed = true;
+ break;
+ default:
+ throw new InvalidOperationException(
+ "Unknown headless plugin teardown stage.");
+ }
+ }
+ }
+
+ private static void Report(
+ SessionStatusWriter writer,
+ string sessionId,
+ PluginSessionStatus status)
+ {
+ if (status.Kind == PluginSessionStatusKind.Loaded)
+ {
+ writer.PluginLoaded(sessionId, status.Plugin);
+ return;
+ }
+ writer.PluginFailed(
+ sessionId,
+ status.Plugin,
+ status.Error ?? "plugin failed");
+ }
+}
diff --git a/src/AcDream.Launcher.Core/Launching/SessionConfigComposer.cs b/src/AcDream.Launcher.Core/Launching/SessionConfigComposer.cs
index 419e2e2d..1fceee5c 100644
--- a/src/AcDream.Launcher.Core/Launching/SessionConfigComposer.cs
+++ b/src/AcDream.Launcher.Core/Launching/SessionConfigComposer.cs
@@ -132,7 +132,9 @@ public static class SessionConfigComposer
Character = selector,
Policy = policy,
Credential = new SessionCredentialDescriptor(),
- Plugins = character.Plugins.Count > 0 ? [.. character.Plugins] : null,
+ // LA5 distinguishes an omitted allow-list (load all, preserving
+ // the developer flow) from an explicit empty list (load none).
+ Plugins = [.. character.Plugins],
LoginCommands = character.LoginCommands.Count > 0
? [.. character.LoginCommands]
: null,
@@ -165,9 +167,9 @@ public static class SessionConfigComposer
/// §LA3 review finding F2): the session carries mode: "probe",
/// no character selector, and no policy — the host
/// reports the account's character roster over the status stream and
- /// exits without entering the world. plugins/loginCommands
- /// don't apply to a probe and are always omitted, exactly like an
- /// empty configured set on a normal session.
+ /// exits without entering the world. Probes carry an explicit empty
+ /// plugins allow-list so a plugin installed on the machine cannot
+ /// run merely because the probe has no character-level plugin settings.
///
public static ComposedSessionConfig ComposeProbe(
ServerProfile server,
@@ -197,7 +199,7 @@ public static class SessionConfigComposer
Character = null,
Policy = null,
Credential = new SessionCredentialDescriptor(),
- Plugins = null,
+ Plugins = [],
LoginCommands = null,
LoginCommandDelayMs = null,
StatusFile = statusFilePath,
diff --git a/src/AcDream.Launcher.Core/Launching/SessionConfigDocument.cs b/src/AcDream.Launcher.Core/Launching/SessionConfigDocument.cs
index 0f013f53..1fc31f4d 100644
--- a/src/AcDream.Launcher.Core/Launching/SessionConfigDocument.cs
+++ b/src/AcDream.Launcher.Core/Launching/SessionConfigDocument.cs
@@ -98,8 +98,10 @@ public sealed class SessionDescriptor
public SessionCredentialDescriptor Credential { get; init; } = new();
- /// Omitted (never an empty array) when the character has no
- /// configured plugin set.
+ /// Plugin allow-list. Omitted or JSON null means load all
+ /// discovered plugins (the developer flow); an explicit empty array means
+ /// load none. Launcher-composed normal-empty and probe sessions therefore
+ /// emit [].
public List? Plugins { get; init; }
/// Omitted (never an empty array) when the character has no
diff --git a/src/AcDream.Plugin.Abstractions/IPluginHost.cs b/src/AcDream.Plugin.Abstractions/IPluginHost.cs
index f3690107..4ece2480 100644
--- a/src/AcDream.Plugin.Abstractions/IPluginHost.cs
+++ b/src/AcDream.Plugin.Abstractions/IPluginHost.cs
@@ -7,6 +7,14 @@ namespace AcDream.Plugin.Abstractions;
///
public interface IPluginHost
{
+ ///
+ /// when registrations can be
+ /// projected by this host. No-window hosts return
+ /// and expose so a plugin may keep
+ /// one code path while deliberately omitting presentation work.
+ ///
+ bool HasUi { get; }
+
IPluginLogger Log { get; }
IGameState State { get; }
IEvents Events { get; }
diff --git a/src/AcDream.Plugin.Abstractions/IUiRegistry.cs b/src/AcDream.Plugin.Abstractions/IUiRegistry.cs
index 1b724f1a..ca587dcf 100644
--- a/src/AcDream.Plugin.Abstractions/IUiRegistry.cs
+++ b/src/AcDream.Plugin.Abstractions/IUiRegistry.cs
@@ -3,8 +3,10 @@ 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.
+/// registers it from Enable(). Graphical hosts buffer registrations until
+/// their retained UI exists. A host whose is
+/// exposes and
+/// intentionally discards registrations.
///
public interface IUiRegistry
{
@@ -12,3 +14,45 @@ public interface IUiRegistry
/// Object whose properties the markup's {Bindings} resolve against.
void AddMarkupPanel(string markupPath, object binding);
}
+
+///
+/// Host-infrastructure extension used to give each plugin a removable UI
+/// registration lifetime. Plugins continue to call
+/// ; the shared plugin host wraps that
+/// call and owns the returned token so failed initialization, failed enable,
+/// and shutdown can roll the registration back without trusting plugin code.
+///
+public interface IScopedUiRegistry : IUiRegistry
+{
+ IDisposable RegisterMarkupPanel(string markupPath, object binding);
+}
+
+///
+/// BCL-only UI sink for no-window plugin hosts. It intentionally retains
+/// neither markup paths nor binding objects, so a UI registration cannot keep
+/// a plugin assembly alive after its collectible load context is unloaded.
+///
+public sealed class NoOpUiRegistry : IScopedUiRegistry
+{
+ public static NoOpUiRegistry Instance { get; } = new();
+
+ private NoOpUiRegistry()
+ {
+ }
+
+ public void AddMarkupPanel(string markupPath, object binding)
+ {
+ }
+
+ public IDisposable RegisterMarkupPanel(string markupPath, object binding) =>
+ NoOpRegistration.Instance;
+
+ private sealed class NoOpRegistration : IDisposable
+ {
+ internal static NoOpRegistration Instance { get; } = new();
+
+ public void Dispose()
+ {
+ }
+ }
+}
diff --git a/src/AcDream.Runtime/Entities/RuntimeEntityDirectory.cs b/src/AcDream.Runtime/Entities/RuntimeEntityDirectory.cs
index fe733332..98dbd9cb 100644
--- a/src/AcDream.Runtime/Entities/RuntimeEntityDirectory.cs
+++ b/src/AcDream.Runtime/Entities/RuntimeEntityDirectory.cs
@@ -16,6 +16,7 @@ public sealed class RuntimeEntityDirectory
public const uint LastLocalEntityId = 0x3FFF_FFFFu;
private readonly InboundPhysicsStateController _inbound = new();
+ private readonly object _activeGate = new();
private readonly Dictionary _activeByGuid = new();
private readonly Dictionary<(uint Guid, ushort Incarnation), RuntimeEntityRecord>
_teardownByIncarnation = new();
@@ -35,10 +36,27 @@ public sealed class RuntimeEntityDirectory
_nextLocalEntityId = firstLocalEntityId;
}
- public int Count => _activeByGuid.Count;
+ public int Count
+ {
+ get
+ {
+ lock (_activeGate)
+ return _activeByGuid.Count;
+ }
+ }
public int PendingTeardownCount => _teardownByIncarnation.Count;
- public int ClaimedLocalIdCount => _byLocalId.Count;
+ public int ClaimedLocalIdCount
+ {
+ get
+ {
+ lock (_activeGate)
+ return _byLocalId.Count;
+ }
+ }
public ulong SessionLifetimeVersion { get; private set; }
+ /// Update-thread-only borrowed collection. Cross-thread hosts use
+ /// through IRuntimeEntityView.Visit
+ /// so membership cannot change during enumeration.
public IReadOnlyCollection ActiveRecords => _activeByGuid.Values;
public IReadOnlyCollection TeardownRecords =>
_teardownByIncarnation.Values;
@@ -87,34 +105,48 @@ public sealed class RuntimeEntityDirectory
public RuntimeEntityRecord AddActive(WorldSession.EntitySpawn snapshot)
{
- if (_activeByGuid.ContainsKey(snapshot.Guid))
+ lock (_activeGate)
{
- throw new InvalidOperationException(
- $"Live entity 0x{snapshot.Guid:X8} already has an active incarnation.");
- }
+ if (_activeByGuid.ContainsKey(snapshot.Guid))
+ {
+ throw new InvalidOperationException(
+ $"Live entity 0x{snapshot.Guid:X8} already has an active incarnation.");
+ }
- var record = new RuntimeEntityRecord(snapshot);
- _activeByGuid.Add(snapshot.Guid, record);
- try
- {
- ClaimLocalId(record);
- return record;
- }
- catch
- {
- _activeByGuid.Remove(snapshot.Guid);
- throw;
+ var record = new RuntimeEntityRecord(snapshot);
+ _activeByGuid.Add(snapshot.Guid, record);
+ try
+ {
+ ClaimLocalId(record);
+ return record;
+ }
+ catch
+ {
+ _activeByGuid.Remove(snapshot.Guid);
+ throw;
+ }
}
}
- public bool RemoveActive(uint guid, out RuntimeEntityRecord? record) =>
- _activeByGuid.Remove(guid, out record);
+ public bool RemoveActive(uint guid, out RuntimeEntityRecord? record)
+ {
+ lock (_activeGate)
+ return _activeByGuid.Remove(guid, out record);
+ }
public bool RemoveActive(RuntimeEntityRecord expected)
{
- if (!IsCurrent(expected))
- return false;
- return _activeByGuid.Remove(expected.ServerGuid);
+ lock (_activeGate)
+ {
+ if (!_activeByGuid.TryGetValue(
+ expected.ServerGuid,
+ out RuntimeEntityRecord? current)
+ || !ReferenceEquals(current, expected))
+ {
+ return false;
+ }
+ return _activeByGuid.Remove(expected.ServerGuid);
+ }
}
public void RetainTeardown(RuntimeEntityRecord record)
@@ -157,46 +189,52 @@ public sealed class RuntimeEntityDirectory
public uint ClaimLocalId(RuntimeEntityRecord record)
{
- if (!IsKnown(record))
+ lock (_activeGate)
{
- throw new InvalidOperationException(
- "A local id can only be claimed for an active or retained incarnation.");
+ if (!IsKnown(record))
+ {
+ throw new InvalidOperationException(
+ "A local id can only be claimed for an active or retained incarnation.");
+ }
+
+ if (record.LocalEntityId is { } existing)
+ return existing;
+
+ uint start = _nextLocalEntityId;
+ do
+ {
+ uint candidate = _nextLocalEntityId;
+ _nextLocalEntityId = candidate == LastLocalEntityId
+ ? FirstLocalEntityId
+ : candidate + 1u;
+ if (_byLocalId.ContainsKey(candidate))
+ continue;
+
+ _byLocalId.Add(candidate, record);
+ record.LocalEntityId = candidate;
+ return candidate;
+ }
+ while (_nextLocalEntityId != start);
+
+ throw new InvalidOperationException("The live entity id namespace is exhausted.");
}
-
- if (record.LocalEntityId is { } existing)
- return existing;
-
- uint start = _nextLocalEntityId;
- do
- {
- uint candidate = _nextLocalEntityId;
- _nextLocalEntityId = candidate == LastLocalEntityId
- ? FirstLocalEntityId
- : candidate + 1u;
- if (_byLocalId.ContainsKey(candidate))
- continue;
-
- _byLocalId.Add(candidate, record);
- record.LocalEntityId = candidate;
- return candidate;
- }
- while (_nextLocalEntityId != start);
-
- throw new InvalidOperationException("The live entity id namespace is exhausted.");
}
public bool ReleaseLocalId(RuntimeEntityRecord record)
{
- if (record.LocalEntityId is not { } localId)
- return false;
- if (_byLocalId.TryGetValue(localId, out RuntimeEntityRecord? retained)
- && ReferenceEquals(retained, record))
+ lock (_activeGate)
{
- _byLocalId.Remove(localId);
- }
+ if (record.LocalEntityId is not { } localId)
+ return false;
+ if (_byLocalId.TryGetValue(localId, out RuntimeEntityRecord? retained)
+ && ReferenceEquals(retained, record))
+ {
+ _byLocalId.Remove(localId);
+ }
- record.LocalEntityId = null;
- return true;
+ record.LocalEntityId = null;
+ return true;
+ }
}
public ulong AdvanceLifetimeMutation(uint serverGuid)
@@ -219,15 +257,39 @@ public sealed class RuntimeEntityDirectory
public bool CompleteSessionClearIfConverged()
{
- if (_activeByGuid.Count != 0 || _teardownByIncarnation.Count != 0)
- return false;
+ lock (_activeGate)
+ {
+ if (_activeByGuid.Count != 0 || _teardownByIncarnation.Count != 0)
+ return false;
- _byLocalId.Clear();
+ _byLocalId.Clear();
+ }
ParentAttachments.Clear();
_inbound.Clear();
return true;
}
+ ///
+ /// Enters the exact active-membership read boundary. Add/remove and local-id
+ /// membership commits serialize behind this allocation-free lease; record
+ /// ownership remains canonical here and no copied gameplay collection is
+ /// introduced.
+ ///
+ internal ActiveReadLease AcquireActiveRead() => new(_activeGate);
+
+ internal readonly struct ActiveReadLease : IDisposable
+ {
+ private readonly object _gate;
+
+ internal ActiveReadLease(object gate)
+ {
+ _gate = gate;
+ Monitor.Enter(gate);
+ }
+
+ public void Dispose() => Monitor.Exit(_gate);
+ }
+
public void RefreshSnapshot(
RuntimeEntityRecord record,
WorldSession.EntitySpawn accepted,
diff --git a/src/AcDream.Runtime/Entities/RuntimeEntityObjectViews.cs b/src/AcDream.Runtime/Entities/RuntimeEntityObjectViews.cs
index 26f27e23..8ac2d909 100644
--- a/src/AcDream.Runtime/Entities/RuntimeEntityObjectViews.cs
+++ b/src/AcDream.Runtime/Entities/RuntimeEntityObjectViews.cs
@@ -91,6 +91,8 @@ internal sealed class RuntimeEntityObjectViews
uint serverGuid,
out RuntimeEntitySnapshot entity)
{
+ using RuntimeEntityDirectory.ActiveReadLease lease =
+ owner.AcquireActiveRead();
if (owner.TryGetActive(
serverGuid,
out RuntimeEntityRecord record))
@@ -106,6 +108,8 @@ internal sealed class RuntimeEntityObjectViews
public void Visit(IRuntimeEntityVisitor visitor)
{
ArgumentNullException.ThrowIfNull(visitor);
+ using RuntimeEntityDirectory.ActiveReadLease lease =
+ owner.AcquireActiveRead();
foreach (RuntimeEntityRecord record in owner.ActiveRecords)
{
RuntimeEntitySnapshot entity = Snapshot(record);
diff --git a/src/AcDream.Runtime/Session/SessionStatusWriter.cs b/src/AcDream.Runtime/Session/SessionStatusWriter.cs
index b98e4761..2b3461fa 100644
--- a/src/AcDream.Runtime/Session/SessionStatusWriter.cs
+++ b/src/AcDream.Runtime/Session/SessionStatusWriter.cs
@@ -13,9 +13,9 @@ namespace AcDream.Runtime.Session;
/// is a single shared-stdout JSONL diagnostics stream with no per-session
/// file; this class writes one file per session, meant to be read by an
/// external process (the launcher) rather than scraped from console output.
-/// Event shapes are versioned ("v":1) so a future event kind
-/// (pluginLoaded/pluginFailed, LA5) can be added without
-/// breaking an existing reader.
+/// Event shapes are versioned ("v":1); LA5's
+/// pluginLoaded/pluginFailed additions use that same envelope
+/// without breaking an existing reader.
///
///
///
@@ -29,9 +29,11 @@ namespace AcDream.Runtime.Session;
///
///
///
-/// Never write credential material into this stream. Every
-/// event method below takes only identifiers, names, and counts — there is no
-/// parameter shape that could carry a password, by construction.
+/// Never write credential material into this stream. LA5's
+/// diagnostic is caller-supplied text, so hosts may
+/// pass only the plugin lifecycle failure and must never append session
+/// credentials or other secrets. Neither plugin host exposes credentials
+/// through IPluginHost.
///
///
///
@@ -205,6 +207,27 @@ public sealed class SessionStatusWriter
characterName,
});
+ public void PluginLoaded(string sessionId, string plugin) =>
+ Write(new
+ {
+ v = VocabularyVersion,
+ e = "pluginLoaded",
+ t = Now(),
+ sessionId,
+ plugin,
+ });
+
+ public void PluginFailed(string sessionId, string plugin, string error) =>
+ Write(new
+ {
+ v = VocabularyVersion,
+ e = "pluginFailed",
+ t = Now(),
+ sessionId,
+ plugin,
+ error,
+ });
+
public void Disconnected(string sessionId, string reason)
{
if (!IsEnabled)
diff --git a/tests/AcDream.App.Tests/AcDream.App.Tests.csproj b/tests/AcDream.App.Tests/AcDream.App.Tests.csproj
index 6760881b..8f82d7ba 100644
--- a/tests/AcDream.App.Tests/AcDream.App.Tests.csproj
+++ b/tests/AcDream.App.Tests/AcDream.App.Tests.csproj
@@ -25,6 +25,15 @@
+
+
+
+ false
+ true
+
+
+
diff --git a/tests/AcDream.App.Tests/Configuration/SessionConfigurationSharedFixtureTests.cs b/tests/AcDream.App.Tests/Configuration/SessionConfigurationSharedFixtureTests.cs
index c37ce9fe..5e83aa49 100644
--- a/tests/AcDream.App.Tests/Configuration/SessionConfigurationSharedFixtureTests.cs
+++ b/tests/AcDream.App.Tests/Configuration/SessionConfigurationSharedFixtureTests.cs
@@ -56,6 +56,18 @@ public sealed class SessionConfigurationSharedFixtureTests
StringComparison.Ordinal);
}
+ [Fact]
+ public void AppReaderPreservesLauncherExplicitEmptyPluginAllowList()
+ {
+ using TemporaryFile file = TemporaryFile.Create(
+ LauncherCoreSessionConfigFixture.ComposeEmptyPlugins());
+
+ (_, SessionDescriptor session) = SessionConfigurationLoader.Load(file.Path);
+
+ Assert.NotNull(session.Plugins);
+ Assert.Empty(session.Plugins);
+ }
+
[Fact]
public void AppReaderAcceptsTheProductionShapedSharedFixture()
{
diff --git a/tests/AcDream.App.Tests/Plugins/BufferedUiRegistryTests.cs b/tests/AcDream.App.Tests/Plugins/BufferedUiRegistryTests.cs
index 6e22e17f..23e84896 100644
--- a/tests/AcDream.App.Tests/Plugins/BufferedUiRegistryTests.cs
+++ b/tests/AcDream.App.Tests/Plugins/BufferedUiRegistryTests.cs
@@ -1,4 +1,5 @@
using AcDream.App.Plugins;
+using AcDream.App.UI;
namespace AcDream.App.Tests.Plugins;
@@ -18,4 +19,26 @@ public class BufferedUiRegistryTests
Assert.Empty(reg.Drain()); // consumed
}
+
+ [Fact]
+ public void ScopedRegistrationTokenRemovesAnAlreadyMountedElement()
+ {
+ var registry = new BufferedUiRegistry();
+ IDisposable registration = registry.RegisterMarkupPanel(
+ "plugin.xml",
+ new object());
+ BufferedUiRegistry.Pending pending = Assert.Single(registry.Drain());
+ var root = new UiRoot();
+ var element = new UiPanel();
+ root.AddChild(element);
+ registry.CompleteMount(pending, root, element);
+
+ Assert.Contains(element, root.Children);
+ Assert.Equal(1, registry.RegistrationCount);
+
+ registration.Dispose();
+
+ Assert.DoesNotContain(element, root.Children);
+ Assert.Equal(0, registry.RegistrationCount);
+ }
}
diff --git a/tests/AcDream.App.Tests/Plugins/GraphicalPluginSessionTests.cs b/tests/AcDream.App.Tests/Plugins/GraphicalPluginSessionTests.cs
new file mode 100644
index 00000000..97850c53
--- /dev/null
+++ b/tests/AcDream.App.Tests/Plugins/GraphicalPluginSessionTests.cs
@@ -0,0 +1,347 @@
+using System.Text.Json;
+using System.Runtime.CompilerServices;
+using AcDream.App.Configuration;
+using AcDream.App.Plugins;
+using AcDream.Core.Plugins;
+using AcDream.Core.Selection;
+using AcDream.Platform;
+using AcDream.Plugin.Abstractions;
+using AcDream.Runtime.Session;
+using AcDream.Tests.Fixtures.CampaignLa;
+
+namespace AcDream.App.Tests.Plugins;
+
+public sealed class GraphicalPluginSessionTests
+{
+ private const string FixtureId = "acdream.test.host-fixture";
+ private const string ThrowingId = "acdream.test.throwing-fixture";
+ private const string InitializeThrowingId =
+ "acdream.test.initialize-throwing-fixture";
+
+ [Fact]
+ public void ConfiguredSetLoadsOnlyAllowedPluginAndReportsBothOutcomes()
+ {
+ using var temporary = new TemporaryDirectory();
+ ApplicationPathSet paths = Paths(temporary.Path);
+ InstallFixture(paths.PluginsDirectory, FixtureId);
+ string statusPath = Path.Combine(temporary.Path, "status.jsonl");
+ var logger = new CapturingLogger();
+ var state = new WorldGameState();
+ var events = new WorldEvents();
+ var selection = new SelectionState();
+ var ui = new BufferedUiRegistry();
+ var host = new AppPluginHost(logger, state, events, selection, ui);
+
+ using GraphicalPluginSession plugins = GraphicalPluginSession.Create(
+ paths,
+ [FixtureId.ToUpperInvariant(), "acdream.test.missing"],
+ "gui-session",
+ host,
+ new SessionStatusWriter(statusPath));
+ plugins.Start();
+
+ Assert.Equal(1, plugins.LoadedCount);
+ Assert.True(host.HasUi);
+ AssertPanelWasRegisteredAndReleaseBinding(ui);
+ Assert.Contains(
+ logger.Messages,
+ message => message.Contains("fixture-enabled:hasUi=True", StringComparison.Ordinal));
+
+ JsonElement[] statuses = ReadStatuses(statusPath);
+ Assert.Equal(["started", "pluginLoaded", "pluginFailed"], EventNames(statuses));
+ Assert.Equal(FixtureId, statuses[1].GetProperty("plugin").GetString());
+ Assert.Equal(
+ "acdream.test.missing",
+ statuses[2].GetProperty("plugin").GetString());
+ Assert.Contains(
+ "not found",
+ statuses[2].GetProperty("error").GetString(),
+ StringComparison.OrdinalIgnoreCase);
+
+ WeakReference context = Assert.Single(
+ plugins.CaptureLoadContextWeakReferences());
+ plugins.Dispose();
+ Assert.Equal(0, ui.RegistrationCount);
+ Collect(context);
+ Assert.False(context.IsAlive);
+ }
+
+ [Fact]
+ public void ExplicitEmptyConfiguredSetLoadsNone()
+ {
+ using var temporary = new TemporaryDirectory();
+ ApplicationPathSet paths = Paths(temporary.Path);
+ InstallFixture(paths.PluginsDirectory, FixtureId);
+ string statusPath = Path.Combine(temporary.Path, "status.jsonl");
+ string configPath = Path.Combine(temporary.Path, "session.json");
+ File.WriteAllText(
+ configPath,
+ LauncherCoreSessionConfigFixture.ComposeEmptyPlugins());
+ (_, SessionDescriptor descriptor) =
+ SessionConfigurationLoader.Load(configPath);
+ var ui = new BufferedUiRegistry();
+ var host = new AppPluginHost(
+ new CapturingLogger(),
+ new WorldGameState(),
+ new WorldEvents(),
+ new SelectionState(),
+ ui);
+
+ using GraphicalPluginSession plugins = GraphicalPluginSession.Create(
+ paths,
+ descriptor.Plugins,
+ "gui-session",
+ host,
+ new SessionStatusWriter(statusPath));
+ plugins.Start();
+
+ Assert.Equal(0, plugins.LoadedCount);
+ Assert.NotNull(descriptor.Plugins);
+ Assert.Empty(descriptor.Plugins);
+ Assert.Empty(ui.Drain());
+ Assert.Equal(["started"], EventNames(ReadStatuses(statusPath)));
+ }
+
+ [Fact]
+ public void ThrowAfterRegistrationRollsBackUiAndEventsAndCollectsContext()
+ {
+ using var temporary = new TemporaryDirectory();
+ ApplicationPathSet paths = Paths(temporary.Path);
+ string pluginDirectory = InstallFixture(
+ paths.PluginsDirectory,
+ ThrowingId,
+ "throwing-fixture");
+ File.WriteAllText(
+ Path.Combine(pluginDirectory, "throw-after-register"),
+ string.Empty);
+ string statusPath = Path.Combine(temporary.Path, "status.jsonl");
+ var events = new WorldEvents();
+ var selection = new SelectionState();
+ var ui = new BufferedUiRegistry();
+ var host = new AppPluginHost(
+ new CapturingLogger(),
+ new WorldGameState(),
+ events,
+ selection,
+ ui);
+
+ using GraphicalPluginSession plugins = GraphicalPluginSession.Create(
+ paths,
+ [ThrowingId],
+ "gui-session",
+ host,
+ new SessionStatusWriter(statusPath));
+ plugins.Start();
+
+ Assert.Equal(0, plugins.LoadedCount);
+ Assert.Empty(ui.Drain());
+ Assert.Equal(0, ui.RegistrationCount);
+ events.FireEntitySpawned(new WorldEntitySnapshot(
+ 1u,
+ 2u,
+ default,
+ System.Numerics.Quaternion.Identity));
+ Assert.True(((ISelectionService)selection).Select(7u));
+ Assert.False(File.Exists(
+ Path.Combine(pluginDirectory, "unexpected-callback")));
+ Assert.Equal(
+ ["started", "pluginFailed"],
+ EventNames(ReadStatuses(statusPath)));
+
+ WeakReference context = Assert.Single(
+ plugins.CaptureLoadContextWeakReferences());
+ plugins.Dispose();
+ Collect(context);
+ Assert.False(context.IsAlive);
+ }
+
+ [Fact]
+ public void InitializeFailureRollsBackEveryRegistrationBeforeUnload()
+ {
+ using var temporary = new TemporaryDirectory();
+ ApplicationPathSet paths = Paths(temporary.Path);
+ string pluginDirectory = InstallFixture(
+ paths.PluginsDirectory,
+ InitializeThrowingId,
+ "initialize-throwing-fixture");
+ File.WriteAllText(
+ Path.Combine(pluginDirectory, "throw-during-initialize"),
+ string.Empty);
+ string statusPath = Path.Combine(temporary.Path, "status.jsonl");
+ var events = new WorldEvents();
+ var selection = new SelectionState();
+ var ui = new BufferedUiRegistry();
+ var host = new AppPluginHost(
+ new CapturingLogger(),
+ new WorldGameState(),
+ events,
+ selection,
+ ui);
+
+ using GraphicalPluginSession plugins = GraphicalPluginSession.Create(
+ paths,
+ [InitializeThrowingId],
+ "gui-session",
+ host,
+ new SessionStatusWriter(statusPath));
+ plugins.Start();
+
+ Assert.Equal(0, plugins.LoadedCount);
+ Assert.Empty(ui.Drain());
+ Assert.Equal(0, ui.RegistrationCount);
+ Assert.Equal(
+ "ui=True;events=True;selection=True",
+ File.ReadAllText(Path.Combine(
+ pluginDirectory,
+ "unload-observation")));
+ events.FireEntitySpawned(new WorldEntitySnapshot(
+ 1u,
+ 2u,
+ default,
+ System.Numerics.Quaternion.Identity));
+ Assert.True(((ISelectionService)selection).Select(9u));
+ Assert.False(File.Exists(
+ Path.Combine(pluginDirectory, "unexpected-callback")));
+ Assert.Equal(
+ ["started", "pluginFailed"],
+ EventNames(ReadStatuses(statusPath)));
+
+ WeakReference context = Assert.Single(
+ plugins.CaptureLoadContextWeakReferences());
+ plugins.Dispose();
+ Collect(context);
+ Assert.False(context.IsAlive);
+ }
+
+ private static ApplicationPathSet Paths(string root) => new(
+ Path.Combine(root, "config"),
+ Path.Combine(root, "data"),
+ Path.Combine(root, "cache"),
+ LegacyConfigDirectory: null);
+
+ [MethodImpl(MethodImplOptions.NoInlining)]
+ private static void AssertPanelWasRegisteredAndReleaseBinding(
+ BufferedUiRegistry ui)
+ {
+ BufferedUiRegistry.Pending panel = Assert.Single(ui.Drain());
+ Assert.EndsWith(
+ "fixture-panel.xml",
+ panel.MarkupPath,
+ StringComparison.Ordinal);
+ Assert.Equal(
+ "AcDream.Plugin.Tests.Fixtures.HostPlugin",
+ panel.Binding.GetType().Assembly.GetName().Name);
+ }
+
+ private static JsonElement[] ReadStatuses(string path) =>
+ File.ReadAllLines(path)
+ .Select(static line => JsonDocument.Parse(line).RootElement.Clone())
+ .ToArray();
+
+ private static string[] EventNames(IEnumerable events) =>
+ events.Select(static item => item.GetProperty("e").GetString()!).ToArray();
+
+ private static string InstallFixture(
+ string root,
+ string id,
+ string directoryName = "host-fixture")
+ {
+ string source = FixtureAssemblyPath();
+ Assert.True(File.Exists(source), $"fixture DLL not found: {source}");
+ string pluginDirectory = Path.Combine(root, directoryName);
+ Directory.CreateDirectory(pluginDirectory);
+ string fileName = Path.GetFileName(source);
+ File.Copy(source, Path.Combine(pluginDirectory, fileName));
+ File.WriteAllText(
+ Path.Combine(pluginDirectory, "plugin.json"),
+ JsonSerializer.Serialize(new
+ {
+ id,
+ displayName = "Host fixture",
+ version = "1.0.0",
+ entryDll = fileName,
+ apiVersion = 1,
+ }));
+ return pluginDirectory;
+ }
+
+ private static string FixtureAssemblyPath()
+ {
+ string configuration = new DirectoryInfo(AppContext.BaseDirectory)
+ .Parent!.Name;
+ string root = FindRepoRoot(AppContext.BaseDirectory);
+ return Path.Combine(
+ root,
+ "tests",
+ "AcDream.Plugin.Tests.Fixtures.HostPlugin",
+ "bin",
+ configuration,
+ "net10.0",
+ "AcDream.Plugin.Tests.Fixtures.HostPlugin.dll");
+ }
+
+ private static string FindRepoRoot(string start)
+ {
+ DirectoryInfo? directory = new(start);
+ while (directory is not null
+ && !File.Exists(Path.Combine(directory.FullName, "AcDream.slnx")))
+ {
+ directory = directory.Parent;
+ }
+ return directory?.FullName
+ ?? throw new InvalidOperationException("Repository root not found.");
+ }
+
+ private static void Collect(WeakReference reference)
+ {
+ for (int attempt = 0; attempt < 10 && reference.IsAlive; attempt++)
+ {
+ GC.Collect();
+ GC.WaitForPendingFinalizers();
+ GC.Collect();
+ }
+ }
+
+ private sealed class CapturingLogger : IPluginLogger
+ {
+ internal List Messages { get; } = [];
+
+ public void Info(string message) => Messages.Add(message);
+ public void Warn(string message) => Messages.Add(message);
+ public void Error(string message, Exception? exception = null) =>
+ Messages.Add(exception is null ? message : $"{message}: {exception.Message}");
+ }
+
+ private sealed class TemporaryDirectory : IDisposable
+ {
+ internal TemporaryDirectory()
+ {
+ Path = System.IO.Path.Combine(
+ System.IO.Path.GetTempPath(),
+ $"acdream-graphical-plugins-{Guid.NewGuid():N}");
+ Directory.CreateDirectory(Path);
+ }
+
+ internal string Path { get; }
+
+ public void Dispose()
+ {
+ for (int attempt = 0; Directory.Exists(Path); attempt++)
+ {
+ try
+ {
+ Directory.Delete(Path, recursive: true);
+ return;
+ }
+ catch (Exception error)
+ when (error is IOException or UnauthorizedAccessException
+ && attempt < 9)
+ {
+ GC.Collect();
+ GC.WaitForPendingFinalizers();
+ Thread.Sleep(10);
+ }
+ }
+ }
+ }
+}
diff --git a/tests/AcDream.App.Tests/Rendering/GameWindowSlice8BoundaryTests.cs b/tests/AcDream.App.Tests/Rendering/GameWindowSlice8BoundaryTests.cs
index 017762cd..f6ce4c9c 100644
--- a/tests/AcDream.App.Tests/Rendering/GameWindowSlice8BoundaryTests.cs
+++ b/tests/AcDream.App.Tests/Rendering/GameWindowSlice8BoundaryTests.cs
@@ -447,6 +447,11 @@ public sealed class GameWindowSlice8BoundaryTests
public void Shutdown_PreservesDependencyStagesAndNativeWindowLast()
{
string source = GameWindowSource();
+ string program = File.ReadAllText(Path.Combine(
+ FindRepoRoot(),
+ "src",
+ "AcDream.App",
+ "Program.cs"));
string lifetime = GameWindowLifetimeSource();
string manifest = Slice(
lifetime,
@@ -464,6 +469,7 @@ public sealed class GameWindowSlice8BoundaryTests
[
"new ResourceShutdownStage(\"host and session barriers\"",
"new ResourceShutdownStage(\"physical ingress cleanup\"",
+ "new ResourceShutdownStage(\"plugin host\"",
"new ResourceShutdownStage(\"frame borrowers\"",
"new ResourceShutdownStage(\"session dependents\"",
"new ResourceShutdownStage(\"live entities\"",
@@ -499,6 +505,8 @@ public sealed class GameWindowSlice8BoundaryTests
"Soft(\"gameplay actions\", () => DisposeGameplayActions(ingress.GameplayActions))",
"Soft(\"camera pointer\", () => DisposeCameraPointer(ingress.CameraPointer))",
"Soft(\"native window callbacks\", () => DisposeWindowCallbacks(ingress.WindowCallbacks))",
+ "new ResourceShutdownStage(\"plugin host\"",
+ "Hard(\"plugins\", () => ingress.Plugins?.Dispose())",
"new ResourceShutdownStage(\"session dependents\"",
"Hard(\"mouse capture\", () => live.CameraPointer?.ReleaseMouseLookAfterSessionRetirement())");
Assert.Contains(
@@ -509,7 +517,27 @@ public sealed class GameWindowSlice8BoundaryTests
"UiHost? RetainedUiHost,",
lifetime,
StringComparison.Ordinal);
+ Assert.Contains(
+ "IDisposable? Plugins,",
+ lifetime,
+ StringComparison.Ordinal);
Assert.Contains("_uiHost,", source, StringComparison.Ordinal);
+ Assert.Contains("_pluginSession,", source, StringComparison.Ordinal);
+ AssertAppearsInOrder(
+ program,
+ "GraphicalPluginSession pluginSession = GraphicalPluginSession.Create(",
+ "window.StartPluginHosting(pluginSession);",
+ "window.Run();");
+ AssertAppearsInOrder(
+ source,
+ "_pluginSession = pluginSession;",
+ "pluginSession.Start();",
+ "public void Run()");
+ AssertAppearsInOrder(
+ manifest,
+ "Hard(\"plugins\", () => ingress.Plugins?.Dispose())",
+ "Hard(\"retail UI\", () => DisposeRetailUi(live.RetailUi))",
+ "Hard(\"game runtime\", () => DisposeGameRuntime(live.Runtime))");
AssertAppearsInOrder(
nativeRelease,
"TryComplete();",
diff --git a/tests/AcDream.Core.Tests/Plugins/PluginLoaderTests.cs b/tests/AcDream.Core.Tests/Plugins/PluginLoaderTests.cs
index a55fb398..e35755a7 100644
--- a/tests/AcDream.Core.Tests/Plugins/PluginLoaderTests.cs
+++ b/tests/AcDream.Core.Tests/Plugins/PluginLoaderTests.cs
@@ -28,6 +28,7 @@ public class PluginLoaderTests
private sealed class StubHost : IPluginHost
{
+ public bool HasUi => true;
public IPluginLogger Log { get; } = new StubLogger();
public IGameState State { get; } = new StubState();
public IEvents Events { get; } = new StubEvents();
@@ -84,6 +85,8 @@ public class PluginLoaderTests
Assert.True(loaded.Success);
Assert.NotNull(loaded.Plugin);
Assert.Equal("HelloPlugin", loaded.Plugin!.GetType().Name);
+ loaded.Plugin.Disable();
+ loaded.LoadContext!.Unload();
}
[Fact]
@@ -122,5 +125,7 @@ public class PluginLoaderTests
Assert.False(loaded.Success);
Assert.Contains("IAcDreamPlugin", loaded.Error!.Message);
+ Assert.NotNull(loaded.LoadContext);
+ loaded.LoadContext!.Unload();
}
}
diff --git a/tests/AcDream.Core.Tests/Plugins/PluginSessionTests.cs b/tests/AcDream.Core.Tests/Plugins/PluginSessionTests.cs
new file mode 100644
index 00000000..5a3ef89c
--- /dev/null
+++ b/tests/AcDream.Core.Tests/Plugins/PluginSessionTests.cs
@@ -0,0 +1,201 @@
+using System.Text.Json;
+using AcDream.Core.Plugins;
+using AcDream.Core.Selection;
+using AcDream.Plugin.Abstractions;
+
+namespace AcDream.Core.Tests.Plugins;
+
+public sealed class PluginSessionTests
+{
+ [Fact]
+ public void AbsentAllowListLoadsEveryDiscoveredPlugin()
+ {
+ using var temporary = new TemporaryDirectory();
+ InstallFixture(temporary.Path, "alpha", "acdream.test.alpha");
+ InstallFixture(temporary.Path, "beta", "acdream.test.beta");
+ var statuses = new List();
+ var plugins = new PluginSession(new StubHost(), statuses.Add);
+
+ plugins.Start([temporary.Path], allowList: null);
+
+ Assert.Equal(2, plugins.LoadedCount);
+ Assert.Equal(
+ ["acdream.test.alpha", "acdream.test.beta"],
+ plugins.LoadedPluginIds);
+ Assert.All(
+ statuses,
+ status => Assert.Equal(PluginSessionStatusKind.Loaded, status.Kind));
+ ReleaseAndCollect(plugins);
+ }
+
+ [Fact]
+ public void AllowListIsCaseInsensitiveAndOneFailureDoesNotBlockAnotherPlugin()
+ {
+ using var temporary = new TemporaryDirectory();
+ InstallFixture(temporary.Path, "good", "acdream.test.good");
+ InstallBroken(temporary.Path, "broken", "acdream.test.broken");
+ var statuses = new List();
+ var plugins = new PluginSession(new StubHost(), statuses.Add);
+
+ plugins.Start(
+ [temporary.Path],
+ [
+ "ACDREAM.TEST.BROKEN",
+ "ACDREAM.TEST.GOOD",
+ "acdream.test.missing",
+ ]);
+
+ Assert.Equal(["acdream.test.good"], plugins.LoadedPluginIds);
+ Assert.Equal(
+ [
+ ("ACDREAM.TEST.BROKEN", PluginSessionStatusKind.Failed),
+ ("acdream.test.good", PluginSessionStatusKind.Loaded),
+ ("acdream.test.missing", PluginSessionStatusKind.Failed),
+ ],
+ statuses.Select(static status => (status.Plugin, status.Kind)));
+ Assert.All(
+ statuses.Where(static status => status.Kind == PluginSessionStatusKind.Failed),
+ status => Assert.False(string.IsNullOrWhiteSpace(status.Error)));
+ ReleaseAndCollect(plugins);
+ }
+
+ [Fact]
+ public void ExplicitEmptyAllowListLoadsNothing()
+ {
+ using var temporary = new TemporaryDirectory();
+ InstallFixture(temporary.Path, "fixture", "acdream.test.fixture");
+ var statuses = new List();
+ using var plugins = new PluginSession(new StubHost(), statuses.Add);
+
+ plugins.Start([temporary.Path], []);
+
+ Assert.Equal(0, plugins.LoadedCount);
+ Assert.Empty(statuses);
+ }
+
+ private static void ReleaseAndCollect(PluginSession plugins)
+ {
+ IReadOnlyList contexts =
+ plugins.CaptureLoadContextWeakReferences();
+ plugins.Dispose();
+ for (int attempt = 0;
+ attempt < 10 && contexts.Any(static context => context.IsAlive);
+ attempt++)
+ {
+ GC.Collect();
+ GC.WaitForPendingFinalizers();
+ GC.Collect();
+ }
+ Assert.All(contexts, static context => Assert.False(context.IsAlive));
+ }
+
+ private static void InstallFixture(string root, string folder, string id)
+ {
+ string source = FixturePluginPath();
+ Assert.True(File.Exists(source), $"fixture DLL not found: {source}");
+ string pluginDirectory = Path.Combine(root, folder);
+ Directory.CreateDirectory(pluginDirectory);
+ string fileName = Path.GetFileName(source);
+ File.Copy(source, Path.Combine(pluginDirectory, fileName));
+ WriteManifest(pluginDirectory, id, fileName);
+ }
+
+ private static void InstallBroken(string root, string folder, string id)
+ {
+ string pluginDirectory = Path.Combine(root, folder);
+ Directory.CreateDirectory(pluginDirectory);
+ WriteManifest(pluginDirectory, id, "missing.dll");
+ }
+
+ private static void WriteManifest(
+ string directory,
+ string id,
+ string entryDll) =>
+ File.WriteAllText(
+ Path.Combine(directory, "plugin.json"),
+ JsonSerializer.Serialize(new
+ {
+ id,
+ displayName = id,
+ version = "1.0.0",
+ entryDll,
+ apiVersion = 1,
+ }));
+
+ private static string FixturePluginPath()
+ {
+ string configuration = new DirectoryInfo(AppContext.BaseDirectory)
+ .Parent!.Name;
+ string root = FindRepoRoot(AppContext.BaseDirectory);
+ return Path.Combine(
+ root,
+ "tests",
+ "AcDream.Core.Tests.Fixtures.HelloPlugin",
+ "bin",
+ configuration,
+ "net10.0",
+ "AcDream.Core.Tests.Fixtures.HelloPlugin.dll");
+ }
+
+ private static string FindRepoRoot(string start)
+ {
+ DirectoryInfo? directory = new(start);
+ while (directory is not null
+ && !File.Exists(Path.Combine(directory.FullName, "AcDream.slnx")))
+ {
+ directory = directory.Parent;
+ }
+ return directory?.FullName
+ ?? throw new InvalidOperationException("Repository root not found.");
+ }
+
+ private sealed class StubHost : IPluginHost
+ {
+ public bool HasUi => false;
+ public IPluginLogger Log { get; } = new StubLogger();
+ public IGameState State { get; } = new StubState();
+ public IEvents Events { get; } = new StubEvents();
+ public ISelectionService Selection { get; } = new SelectionState();
+ public IUiRegistry Ui => NoOpUiRegistry.Instance;
+ }
+
+ private sealed class StubLogger : IPluginLogger
+ {
+ public void Info(string message) { }
+ public void Warn(string message) { }
+ public void Error(string message, Exception? exception = null) { }
+ }
+
+ private sealed class StubState : IGameState
+ {
+ public IReadOnlyList Entities => [];
+ }
+
+ private sealed class StubEvents : IEvents
+ {
+ public event Action EntitySpawned
+ {
+ add { }
+ remove { }
+ }
+ }
+
+ private sealed class TemporaryDirectory : IDisposable
+ {
+ internal TemporaryDirectory()
+ {
+ Path = System.IO.Path.Combine(
+ System.IO.Path.GetTempPath(),
+ $"acdream-plugin-session-{Guid.NewGuid():N}");
+ Directory.CreateDirectory(Path);
+ }
+
+ internal string Path { get; }
+
+ public void Dispose()
+ {
+ if (Directory.Exists(Path))
+ Directory.Delete(Path, recursive: true);
+ }
+ }
+}
diff --git a/tests/AcDream.Headless.Tests/AcDream.Headless.Tests.csproj b/tests/AcDream.Headless.Tests/AcDream.Headless.Tests.csproj
index 9591a359..fa871545 100644
--- a/tests/AcDream.Headless.Tests/AcDream.Headless.Tests.csproj
+++ b/tests/AcDream.Headless.Tests/AcDream.Headless.Tests.csproj
@@ -24,6 +24,15 @@
+
+
+
+ false
+ true
+
+
+
diff --git a/tests/AcDream.Headless.Tests/HeadlessPluginSessionTests.cs b/tests/AcDream.Headless.Tests/HeadlessPluginSessionTests.cs
new file mode 100644
index 00000000..c2f332ef
--- /dev/null
+++ b/tests/AcDream.Headless.Tests/HeadlessPluginSessionTests.cs
@@ -0,0 +1,429 @@
+using System.Text.Json;
+using System.Net;
+using AcDream.Core.Net;
+using AcDream.Core.Net.Messages;
+using AcDream.Headless.Configuration;
+using AcDream.Headless.Credentials;
+using AcDream.Headless.Diagnostics;
+using AcDream.Headless.Hosting;
+using AcDream.Headless.Plugins;
+using AcDream.Plugin.Abstractions;
+using AcDream.Runtime;
+using AcDream.Runtime.Session;
+using AcDream.Tests.Fixtures.CampaignLa;
+
+namespace AcDream.Headless.Tests;
+
+public sealed class HeadlessPluginSessionTests
+{
+ private const string FixtureId = "acdream.test.host-fixture";
+ private const string BrokenId = "acdream.test.broken";
+ private const string ThrowingId = "acdream.test.throwing-fixture";
+
+ [Fact]
+ public void ConfiguredSetBorrowsRuntimeUsesNoOpUiIsolatesFailureAndUnloads()
+ {
+ using var temporary = new TemporaryDirectory();
+ InstallFixture(temporary.Path, FixtureId);
+ InstallBrokenPlugin(temporary.Path, BrokenId);
+ var output = new StringWriter();
+ var diagnostics = new HeadlessDiagnosticWriter(output);
+ string statusPath = Path.Combine(temporary.Path, "status.jsonl");
+ var credential = new HeadlessCredentialSecret("fixture", "password");
+ using var session = new HeadlessSessionHost(
+ Descriptor([FixtureId.ToUpperInvariant(), BrokenId], statusPath),
+ credential,
+ diagnostics,
+ new FixtureSessionOperations(),
+ pluginRoots: [temporary.Path]);
+ HeadlessPluginSession plugins = session.Plugins;
+ _ = session.Start();
+ _ = session.Runtime.EntityObjects.RegisterEntity(Spawn(0x50000001u, 1f));
+
+ Assert.Equal(1, plugins.LoadedCount);
+ Assert.False(plugins.Host.HasUi);
+ Assert.Same(NoOpUiRegistry.Instance, plugins.Host.Ui);
+ Assert.Same(
+ session.Runtime.ActionOwner.Selection,
+ plugins.Host.Selection);
+ WorldEntitySnapshot first = Assert.Single(plugins.Host.State.Entities);
+ Assert.Equal(1_000_000u, first.Id);
+ Assert.Equal(0x02000001u, first.SourceId);
+
+ _ = session.Runtime.EntityObjects.RegisterEntity(Spawn(0x50000002u, 2f));
+ Assert.Equal(2, plugins.Host.State.Entities.Count);
+
+ JsonElement[] statuses = ReadStatuses(statusPath);
+ Assert.Equal(
+ [
+ "started", "pluginLoaded", "pluginFailed", "connected",
+ "characterList", "enteredWorld",
+ ],
+ EventNames(statuses));
+ Assert.Equal(FixtureId, statuses[1].GetProperty("plugin").GetString());
+ Assert.Equal(BrokenId, statuses[2].GetProperty("plugin").GetString());
+ Assert.Contains(
+ "entry dll not found",
+ statuses[2].GetProperty("error").GetString()!,
+ StringComparison.OrdinalIgnoreCase);
+ Assert.Contains("fixture-enabled:hasUi=False:entities=0", output.ToString());
+
+ WeakReference context = Assert.Single(
+ plugins.CaptureLoadContextWeakReferences());
+ session.Dispose();
+ Assert.Contains("fixture-disabled:entitiesSeen=2", output.ToString());
+ Assert.True(session.Runtime.CaptureOwnership().IsConverged);
+ Assert.True(credential.IsDisposed);
+ Collect(context);
+ Assert.False(context.IsAlive);
+ }
+
+ [Fact]
+ public void ExplicitEmptyConfiguredSetLoadsNone()
+ {
+ using var temporary = new TemporaryDirectory();
+ InstallFixture(temporary.Path, FixtureId);
+ var output = new StringWriter();
+ string statusPath = Path.Combine(temporary.Path, "status.jsonl");
+ var credential = new HeadlessCredentialSecret("fixture", "password");
+
+ using var session = new HeadlessSessionHost(
+ Descriptor([], statusPath),
+ credential,
+ new HeadlessDiagnosticWriter(output),
+ new FixtureSessionOperations(),
+ pluginRoots: [temporary.Path]);
+
+ _ = session.Start();
+ Assert.Equal(0, session.Plugins.LoadedCount);
+ Assert.Equal(
+ ["started", "connected", "characterList", "enteredWorld"],
+ EventNames(ReadStatuses(statusPath)));
+ Assert.DoesNotContain("fixture-", output.ToString());
+ }
+
+ [Fact]
+ public void ThrowAfterRegistrationRollsBackEventsAndCollectsContext()
+ {
+ using var temporary = new TemporaryDirectory();
+ string pluginDirectory = InstallFixture(
+ temporary.Path,
+ ThrowingId,
+ "throwing-fixture");
+ File.WriteAllText(
+ Path.Combine(pluginDirectory, "throw-after-register"),
+ string.Empty);
+ string statusPath = Path.Combine(temporary.Path, "status.jsonl");
+ var credential = new HeadlessCredentialSecret("fixture", "password");
+ using var session = new HeadlessSessionHost(
+ Descriptor([ThrowingId], statusPath),
+ credential,
+ new HeadlessDiagnosticWriter(new StringWriter()),
+ new FixtureSessionOperations(),
+ pluginRoots: [temporary.Path]);
+
+ _ = session.Start();
+ Assert.Equal(0, session.Plugins.LoadedCount);
+ _ = session.Runtime.EntityObjects.RegisterEntity(Spawn(0x50000001u, 1f));
+ Assert.True(((ISelectionService)session.Runtime.ActionOwner.Selection)
+ .Select(7u));
+ Assert.False(File.Exists(
+ Path.Combine(pluginDirectory, "unexpected-callback")));
+ Assert.Equal(
+ [
+ "started", "pluginFailed", "connected", "characterList",
+ "enteredWorld",
+ ],
+ EventNames(ReadStatuses(statusPath)));
+
+ WeakReference context = Assert.Single(
+ session.Plugins.CaptureLoadContextWeakReferences());
+ session.Dispose();
+ Collect(context);
+ Assert.False(context.IsAlive);
+ }
+
+ [Fact]
+ public void LauncherProbeRoundTripKeepsPluginsDisabledInTheRealHost()
+ {
+ using var temporary = new TemporaryDirectory();
+ InstallFixture(temporary.Path, FixtureId);
+ string configPath = Path.Combine(temporary.Path, "probe.json");
+ File.WriteAllText(
+ configPath,
+ LauncherCoreSessionConfigFixture.ComposeProbe());
+ HeadlessSessionDescriptor descriptor = Assert.Single(
+ HeadlessConfigurationLoader.Load(configPath).Sessions)! with
+ {
+ StatusFile = Path.Combine(temporary.Path, "probe-status.jsonl"),
+ };
+ var credential = new HeadlessCredentialSecret("fixture", "password");
+ using var session = new HeadlessSessionHost(
+ descriptor,
+ credential,
+ new HeadlessDiagnosticWriter(new StringWriter()),
+ new FixtureSessionOperations(),
+ pluginRoots: [temporary.Path]);
+
+ RuntimeSessionStartResult result = session.Start();
+
+ Assert.Equal(RuntimeSessionStartStatus.ProbeComplete, result.Status);
+ Assert.NotNull(descriptor.Plugins);
+ Assert.Empty(descriptor.Plugins);
+ Assert.Equal(0, session.Plugins.LoadedCount);
+ Assert.Equal(
+ ["started", "connected", "characterList"],
+ EventNames(ReadStatuses(descriptor.StatusFile!)));
+ }
+
+ [Fact]
+ public async Task LateSubscriberReplayQueuesConcurrentRegistrationExactlyOnceInOrder()
+ {
+ using var temporary = new TemporaryDirectory();
+ var credential = new HeadlessCredentialSecret("fixture", "password");
+ using var session = new HeadlessSessionHost(
+ Descriptor([], Path.Combine(temporary.Path, "status.jsonl")),
+ credential,
+ new HeadlessDiagnosticWriter(new StringWriter()),
+ new FixtureSessionOperations(),
+ pluginRoots: [temporary.Path]);
+ _ = session.Runtime.EntityObjects.RegisterEntity(Spawn(0x50000001u, 1f));
+ HeadlessPluginHost host = session.Plugins.Host;
+ using var replayCaptured = new ManualResetEventSlim();
+ using var releaseReplay = new ManualResetEventSlim();
+ host.ReplayCapturedForTest = () =>
+ {
+ replayCaptured.Set();
+ Assert.True(releaseReplay.Wait(TimeSpan.FromSeconds(10)));
+ };
+ var observed = new List();
+ Action handler = snapshot =>
+ {
+ lock (observed)
+ observed.Add(snapshot.Id);
+ };
+
+ Task subscribe = Task.Run(() => host.Events.EntitySpawned += handler);
+ Assert.True(replayCaptured.Wait(TimeSpan.FromSeconds(10)));
+ using var registrationStarted = new ManualResetEventSlim();
+ Task registration = Task.Run(() =>
+ {
+ registrationStarted.Set();
+ _ = session.Runtime.EntityObjects.RegisterEntity(
+ Spawn(0x50000002u, 2f));
+ });
+ Assert.True(registrationStarted.Wait(TimeSpan.FromSeconds(10)));
+ try
+ {
+ await Task.Delay(TimeSpan.FromMilliseconds(100));
+ Assert.False(registration.IsCompleted);
+ }
+ finally
+ {
+ releaseReplay.Set();
+ }
+ await Task.WhenAll(subscribe, registration)
+ .WaitAsync(TimeSpan.FromSeconds(10));
+ host.Events.EntitySpawned -= handler;
+
+ Assert.Equal([1_000_000u, 1_000_001u], observed);
+ }
+
+ private static HeadlessSessionDescriptor Descriptor(
+ List plugins,
+ string statusPath) => new()
+ {
+ Id = "headless-session",
+ Endpoint = new HeadlessEndpointDescriptor
+ {
+ Host = "127.0.0.1",
+ Port = 9000,
+ },
+ Account = "account",
+ Character = new HeadlessCharacterSelector
+ {
+ Name = "Fixture",
+ },
+ Policy = new HeadlessBotPolicyDescriptor
+ {
+ Id = "idle",
+ },
+ Credential = new HeadlessCredentialReference
+ {
+ Provider = HeadlessCredentialProviderKind.Environment,
+ Reference = "FIXTURE_PASSWORD",
+ },
+ Plugins = plugins,
+ StatusFile = statusPath,
+ };
+
+ private static WorldSession.EntitySpawn Spawn(uint guid, float x) => new(
+ guid,
+ new CreateObject.ServerPosition(
+ 0x01010001u,
+ x,
+ 10f,
+ 5f,
+ 1f,
+ 0f,
+ 0f,
+ 0f),
+ 0x02000001u,
+ [],
+ [],
+ [],
+ null,
+ null,
+ "Fixture",
+ null,
+ null,
+ null);
+
+ private static JsonElement[] ReadStatuses(string path) =>
+ File.ReadAllLines(path)
+ .Select(static line => JsonDocument.Parse(line).RootElement.Clone())
+ .ToArray();
+
+ private static string[] EventNames(IEnumerable events) =>
+ events.Select(static item => item.GetProperty("e").GetString()!).ToArray();
+
+ private static string InstallFixture(
+ string root,
+ string id,
+ string directoryName = "host-fixture")
+ {
+ string source = FixtureAssemblyPath();
+ Assert.True(File.Exists(source), $"fixture DLL not found: {source}");
+ string pluginDirectory = Path.Combine(root, directoryName);
+ Directory.CreateDirectory(pluginDirectory);
+ string fileName = Path.GetFileName(source);
+ File.Copy(source, Path.Combine(pluginDirectory, fileName));
+ WriteManifest(pluginDirectory, id, fileName);
+ return pluginDirectory;
+ }
+
+ private static void InstallBrokenPlugin(string root, string id)
+ {
+ string pluginDirectory = Path.Combine(root, "broken");
+ Directory.CreateDirectory(pluginDirectory);
+ WriteManifest(pluginDirectory, id, "missing.dll");
+ }
+
+ private static void WriteManifest(
+ string directory,
+ string id,
+ string entryDll) =>
+ File.WriteAllText(
+ Path.Combine(directory, "plugin.json"),
+ JsonSerializer.Serialize(new
+ {
+ id,
+ displayName = "Host fixture",
+ version = "1.0.0",
+ entryDll,
+ apiVersion = 1,
+ }));
+
+ private static string FixtureAssemblyPath()
+ {
+ string configuration = new DirectoryInfo(AppContext.BaseDirectory)
+ .Parent!.Name;
+ string root = FindRepoRoot(AppContext.BaseDirectory);
+ return Path.Combine(
+ root,
+ "tests",
+ "AcDream.Plugin.Tests.Fixtures.HostPlugin",
+ "bin",
+ configuration,
+ "net10.0",
+ "AcDream.Plugin.Tests.Fixtures.HostPlugin.dll");
+ }
+
+ private static string FindRepoRoot(string start)
+ {
+ DirectoryInfo? directory = new(start);
+ while (directory is not null
+ && !File.Exists(Path.Combine(directory.FullName, "AcDream.slnx")))
+ {
+ directory = directory.Parent;
+ }
+ return directory?.FullName
+ ?? throw new InvalidOperationException("Repository root not found.");
+ }
+
+ private static void Collect(WeakReference reference)
+ {
+ for (int attempt = 0; attempt < 10 && reference.IsAlive; attempt++)
+ {
+ GC.Collect();
+ GC.WaitForPendingFinalizers();
+ GC.Collect();
+ }
+ }
+
+ private sealed class FixtureSessionOperations : ILiveSessionOperations
+ {
+ private static readonly CharacterList.Parsed Characters = new(
+ 0u,
+ [new CharacterList.Character(0x50000001u, "Fixture", 0u)],
+ [],
+ 1,
+ "account",
+ true,
+ true);
+
+ public IPEndPoint ResolveEndpoint(string host, int port) =>
+ new(IPAddress.Loopback, port);
+
+ public WorldSession CreateSession(IPEndPoint endpoint) => new(endpoint);
+
+ public void Connect(WorldSession session, string user, string password)
+ {
+ }
+
+ public CharacterList.Parsed? GetCharacters(WorldSession session) => Characters;
+
+ public void EnterWorld(WorldSession session, int activeCharacterIndex)
+ {
+ }
+
+ public void Tick(WorldSession session)
+ {
+ }
+
+ public void DisposeSession(WorldSession session) => session.Dispose();
+ }
+
+ private sealed class TemporaryDirectory : IDisposable
+ {
+ internal TemporaryDirectory()
+ {
+ Path = System.IO.Path.Combine(
+ System.IO.Path.GetTempPath(),
+ $"acdream-headless-plugins-{Guid.NewGuid():N}");
+ Directory.CreateDirectory(Path);
+ }
+
+ internal string Path { get; }
+
+ public void Dispose()
+ {
+ for (int attempt = 0; Directory.Exists(Path); attempt++)
+ {
+ try
+ {
+ Directory.Delete(Path, recursive: true);
+ return;
+ }
+ catch (Exception error)
+ when (error is IOException or UnauthorizedAccessException
+ && attempt < 9)
+ {
+ GC.Collect();
+ GC.WaitForPendingFinalizers();
+ Thread.Sleep(10);
+ }
+ }
+ }
+ }
+}
diff --git a/tests/AcDream.Headless.Tests/SessionConfigurationSharedFixtureTests.cs b/tests/AcDream.Headless.Tests/SessionConfigurationSharedFixtureTests.cs
index 44e32af9..bc410558 100644
--- a/tests/AcDream.Headless.Tests/SessionConfigurationSharedFixtureTests.cs
+++ b/tests/AcDream.Headless.Tests/SessionConfigurationSharedFixtureTests.cs
@@ -60,6 +60,33 @@ public sealed class SessionConfigurationSharedFixtureTests
StringComparison.Ordinal);
}
+ [Fact]
+ public void HeadlessReaderPreservesLauncherExplicitEmptyPluginAllowList()
+ {
+ using TemporaryFile file = TemporaryFile.Create(
+ LauncherCoreSessionConfigFixture.ComposeEmptyPlugins());
+
+ HeadlessSessionDescriptor session = Assert.Single(
+ HeadlessConfigurationLoader.Load(file.Path).Sessions)!;
+
+ Assert.NotNull(session.Plugins);
+ Assert.Empty(session.Plugins);
+ }
+
+ [Fact]
+ public void HeadlessReaderPreservesProbeLoadNoneAllowList()
+ {
+ using TemporaryFile file = TemporaryFile.Create(
+ LauncherCoreSessionConfigFixture.ComposeProbe());
+
+ HeadlessSessionDescriptor session = Assert.Single(
+ HeadlessConfigurationLoader.Load(file.Path).Sessions)!;
+
+ Assert.Equal(HeadlessSessionMode.Probe, session.Mode);
+ Assert.NotNull(session.Plugins);
+ Assert.Empty(session.Plugins);
+ }
+
[Fact]
public void HeadlessReaderAcceptsTheProductionShapedSharedFixture()
{
diff --git a/tests/AcDream.Launcher.Core.Tests/Launching/SessionConfigComposerTests.cs b/tests/AcDream.Launcher.Core.Tests/Launching/SessionConfigComposerTests.cs
index eda854b9..dae51b3f 100644
--- a/tests/AcDream.Launcher.Core.Tests/Launching/SessionConfigComposerTests.cs
+++ b/tests/AcDream.Launcher.Core.Tests/Launching/SessionConfigComposerTests.cs
@@ -175,7 +175,7 @@ public sealed class SessionConfigComposerTests
}
[Fact]
- public void PluginsAndLoginCommandsAreOmittedWhenEmptyRatherThanEmptyArrays()
+ public void EmptyPluginsRemainAnExplicitLoadNoneAllowListWhileLoginCommandsAreOmitted()
{
CharacterProfile character = Character(LaunchMode.Gui);
character.Plugins = [];
@@ -190,7 +190,8 @@ public sealed class SessionConfigComposerTests
sessionId: "session-empty-lists");
JsonObject session = SingleSession(composed);
- Assert.False(session.ContainsKey("plugins"));
+ Assert.True(session.ContainsKey("plugins"));
+ Assert.Empty(session["plugins"]!.AsArray());
Assert.False(session.ContainsKey("loginCommands"));
}
@@ -243,7 +244,7 @@ public sealed class SessionConfigComposerTests
}
[Fact]
- public void ProbeModeSetsModeAndOmitsCharacterPolicyPluginsAndLoginCommands()
+ public void ProbeModeSetsModeAndCarriesExplicitLoadNonePluginAllowList()
{
ComposedSessionConfig composed = SessionConfigComposer.ComposeProbe(
Server(),
@@ -256,7 +257,7 @@ public sealed class SessionConfigComposerTests
AssertKeys(
session,
- "id", "mode", "endpoint", "account", "credential", "statusFile");
+ "id", "mode", "endpoint", "account", "credential", "plugins", "statusFile");
Assert.Equal("session-probe", (string?)session["id"]);
Assert.Equal("probe", (string?)session["mode"]);
@@ -266,7 +267,7 @@ public sealed class SessionConfigComposerTests
Assert.Equal("standardInput", (string?)session["credential"]!["provider"]);
Assert.False(session.ContainsKey("character"));
Assert.False(session.ContainsKey("policy"));
- Assert.False(session.ContainsKey("plugins"));
+ Assert.Empty(session["plugins"]!.AsArray());
Assert.False(session.ContainsKey("loginCommands"));
Assert.False(session.ContainsKey("loginCommandDelayMs"));
Assert.Equal(
diff --git a/tests/AcDream.Plugin.Tests.Fixtures.HostPlugin/AcDream.Plugin.Tests.Fixtures.HostPlugin.csproj b/tests/AcDream.Plugin.Tests.Fixtures.HostPlugin/AcDream.Plugin.Tests.Fixtures.HostPlugin.csproj
new file mode 100644
index 00000000..7b200e49
--- /dev/null
+++ b/tests/AcDream.Plugin.Tests.Fixtures.HostPlugin/AcDream.Plugin.Tests.Fixtures.HostPlugin.csproj
@@ -0,0 +1,19 @@
+
+
+ net10.0
+ enable
+ enable
+ latest
+ false
+ true
+
+
+
+
+ false
+ runtime
+
+
+
diff --git a/tests/AcDream.Plugin.Tests.Fixtures.HostPlugin/HostPlugin.cs b/tests/AcDream.Plugin.Tests.Fixtures.HostPlugin/HostPlugin.cs
new file mode 100644
index 00000000..b294f697
--- /dev/null
+++ b/tests/AcDream.Plugin.Tests.Fixtures.HostPlugin/HostPlugin.cs
@@ -0,0 +1,130 @@
+using AcDream.Plugin.Abstractions;
+using System.Runtime.Loader;
+
+namespace AcDream.Plugin.Tests.Fixtures.HostPlugin;
+
+///
+/// Cross-host LA5 fixture. It deliberately takes the same path on graphical
+/// and no-window hosts: observe the capability, register UI, and subscribe to
+/// gameplay events. A headless registry must make the UI call harmless without
+/// retaining this instance in the default load context.
+///
+public sealed class HostPlugin : IAcDreamPlugin
+{
+ private IPluginHost? _host;
+ private string? _assemblyDirectory;
+ private bool _throwAfterRegistration;
+ private bool _throwDuringInitialize;
+ private int _entitiesSeen;
+
+ public void Initialize(IPluginHost host)
+ {
+ _host = host ?? throw new ArgumentNullException(nameof(host));
+ _assemblyDirectory = Path.GetDirectoryName(
+ typeof(HostPlugin).Assembly.Location);
+ _throwAfterRegistration = File.Exists(
+ Path.Combine(_assemblyDirectory!, "throw-after-register"));
+ _throwDuringInitialize = File.Exists(
+ Path.Combine(_assemblyDirectory!, "throw-during-initialize"));
+ host.Log.Info($"fixture-initialized:hasUi={host.HasUi}");
+ if (_throwDuringInitialize)
+ {
+ RegisterHostCallbacks(host);
+ AssemblyLoadContext.GetLoadContext(typeof(HostPlugin).Assembly)!
+ .Unloading += OnUnloading;
+ throw new InvalidOperationException(
+ "fixture initialize failed after registering UI, entity, and selection callbacks");
+ }
+ }
+
+ public void Enable()
+ {
+ IPluginHost host = _host
+ ?? throw new InvalidOperationException("The fixture was not initialized.");
+ RegisterHostCallbacks(host);
+ if (_throwAfterRegistration)
+ {
+ throw new InvalidOperationException(
+ "fixture enable failed after registering UI and events");
+ }
+ host.Log.Info(
+ $"fixture-enabled:hasUi={host.HasUi}:entities={host.State.Entities.Count}");
+ }
+
+ public void Disable()
+ {
+ IPluginHost? host = _host;
+ if (host is null)
+ return;
+ if (_throwAfterRegistration || _throwDuringInitialize)
+ {
+ throw new InvalidOperationException(
+ "fixture disable intentionally refuses cleanup");
+ }
+ host.Events.EntitySpawned -= OnEntitySpawned;
+ host.Selection.Changed -= OnSelectionChanged;
+ host.Log.Info($"fixture-disabled:entitiesSeen={_entitiesSeen}");
+ _host = null;
+ }
+
+ private void RegisterHostCallbacks(IPluginHost host)
+ {
+ host.Ui.AddMarkupPanel(
+ Path.Combine(AppContext.BaseDirectory, "fixture-panel.xml"),
+ this);
+ host.Events.EntitySpawned += OnEntitySpawned;
+ host.Selection.Changed += OnSelectionChanged;
+ }
+
+ private void OnEntitySpawned(WorldEntitySnapshot snapshot)
+ {
+ _entitiesSeen++;
+ RecordUnexpectedCallback(snapshot.Id);
+ }
+
+ private void OnSelectionChanged(SelectionChangedEvent change) =>
+ RecordUnexpectedCallback(change.SelectedObjectId ?? 0u);
+
+ private void RecordUnexpectedCallback(uint objectId)
+ {
+ if ((_throwAfterRegistration || _throwDuringInitialize)
+ && _assemblyDirectory is not null)
+ {
+ File.AppendAllText(
+ Path.Combine(_assemblyDirectory, "unexpected-callback"),
+ $"{objectId}{Environment.NewLine}");
+ }
+ }
+
+ private void OnUnloading(AssemblyLoadContext context)
+ {
+ IPluginHost host = _host!;
+ bool uiClosed = Rejects(() => host.Ui.AddMarkupPanel(
+ Path.Combine(AppContext.BaseDirectory, "unloading-panel.xml"),
+ this));
+ bool eventsClosed = Rejects(() =>
+ {
+ host.Events.EntitySpawned += OnEntitySpawned;
+ });
+ bool selectionClosed = Rejects(() =>
+ {
+ host.Selection.Changed += OnSelectionChanged;
+ });
+ File.WriteAllText(
+ Path.Combine(_assemblyDirectory!, "unload-observation"),
+ $"ui={uiClosed};events={eventsClosed};selection={selectionClosed}");
+ }
+
+ private static bool Rejects(Action action)
+ {
+ try
+ {
+ action();
+ return false;
+ }
+ catch (ObjectDisposedException)
+ {
+ return true;
+ }
+ }
+}
diff --git a/tests/AcDream.Runtime.Tests/Session/SessionStatusWriterTests.cs b/tests/AcDream.Runtime.Tests/Session/SessionStatusWriterTests.cs
index 899c0e0f..f9208079 100644
--- a/tests/AcDream.Runtime.Tests/Session/SessionStatusWriterTests.cs
+++ b/tests/AcDream.Runtime.Tests/Session/SessionStatusWriterTests.cs
@@ -30,11 +30,13 @@ public sealed class SessionStatusWriterTests
new LiveSessionRosterEntry(0x50000002u, "Grey", 10u),
]));
writer.EnteredWorld("s1", 0x50000001u, "Ready");
+ writer.PluginLoaded("s1", "acdream.good");
+ writer.PluginFailed("s1", "acdream.bad", "enable failed");
writer.Disconnected("s1", "stopped");
writer.Exited("s1", 0, "disposed");
string[] lines = File.ReadAllLines(file.Path);
- Assert.Equal(6, lines.Length);
+ Assert.Equal(8, lines.Length);
JsonElement started = Parse(lines[0]);
Assert.Equal(1, started.GetProperty("v").GetInt32());
@@ -62,11 +64,20 @@ public sealed class SessionStatusWriterTests
Assert.Equal(0x50000001u, enteredWorld.GetProperty("characterId").GetUInt32());
Assert.Equal("Ready", enteredWorld.GetProperty("characterName").GetString());
- JsonElement disconnected = Parse(lines[4]);
+ JsonElement pluginLoaded = Parse(lines[4]);
+ Assert.Equal("pluginLoaded", pluginLoaded.GetProperty("e").GetString());
+ Assert.Equal("acdream.good", pluginLoaded.GetProperty("plugin").GetString());
+
+ JsonElement pluginFailed = Parse(lines[5]);
+ Assert.Equal("pluginFailed", pluginFailed.GetProperty("e").GetString());
+ Assert.Equal("acdream.bad", pluginFailed.GetProperty("plugin").GetString());
+ Assert.Equal("enable failed", pluginFailed.GetProperty("error").GetString());
+
+ JsonElement disconnected = Parse(lines[6]);
Assert.Equal("disconnected", disconnected.GetProperty("e").GetString());
Assert.Equal("stopped", disconnected.GetProperty("reason").GetString());
- JsonElement exited = Parse(lines[5]);
+ JsonElement exited = Parse(lines[7]);
Assert.Equal("exited", exited.GetProperty("e").GetString());
Assert.Equal(0, exited.GetProperty("code").GetInt32());
Assert.Equal("disposed", exited.GetProperty("reason").GetString());
@@ -80,6 +91,8 @@ public sealed class SessionStatusWriterTests
writer.Started("s1");
writer.Connected("s1");
+ writer.PluginLoaded("s1", "acdream.good");
+ writer.PluginFailed("s1", "acdream.bad", "failed");
writer.Disconnected("s1", "stopped");
writer.Exited("s1", 0, "disposed");
@@ -164,9 +177,10 @@ public sealed class SessionStatusWriterTests
/// credential material into this stream" contract: each event kind
/// serializes EXACTLY its pinned property set — the shared envelope
/// (v/e/t/sessionId) plus that event's own
- /// named fields, nothing else. An extra property (a smuggled password,
- /// or any other accidental field) fails this test by construction,
- /// regardless of what value it carries.
+ /// named fields, nothing else. An extra credential-shaped or otherwise
+ /// accidental property fails this test by construction. LA5's documented
+ /// pluginFailed.error diagnostic is the one free-text value and its
+ /// caller remains responsible for never appending session secrets.
///
[Fact]
public void EachEventSerializesExactlyItsPinnedPropertySetAndNothingElse()
@@ -183,11 +197,13 @@ public sealed class SessionStatusWriterTests
11,
[new LiveSessionRosterEntry(0x50000001u, "Ready", 0u)]));
writer.EnteredWorld("bot", 0x50000001u, "Ready");
+ writer.PluginLoaded("bot", "acdream.good");
+ writer.PluginFailed("bot", "acdream.bad", "enable failed");
writer.Disconnected("bot", "stopped");
writer.Exited("bot", 0, "disposed");
string[] lines = File.ReadAllLines(file.Path);
- Assert.Equal(6, lines.Length);
+ Assert.Equal(8, lines.Length);
AssertExactProperties(lines[0], "v", "e", "t", "sessionId");
AssertExactProperties(lines[1], "v", "e", "t", "sessionId");
@@ -196,8 +212,11 @@ public sealed class SessionStatusWriterTests
"v", "e", "t", "sessionId", "accountName", "slotCount", "characters");
AssertExactProperties(
lines[3], "v", "e", "t", "sessionId", "characterId", "characterName");
- AssertExactProperties(lines[4], "v", "e", "t", "sessionId", "reason");
- AssertExactProperties(lines[5], "v", "e", "t", "sessionId", "code", "reason");
+ AssertExactProperties(lines[4], "v", "e", "t", "sessionId", "plugin");
+ AssertExactProperties(
+ lines[5], "v", "e", "t", "sessionId", "plugin", "error");
+ AssertExactProperties(lines[6], "v", "e", "t", "sessionId", "reason");
+ AssertExactProperties(lines[7], "v", "e", "t", "sessionId", "code", "reason");
// The nested characters[] entries are exact too — the exact shape a
// password could otherwise be smuggled through.
diff --git a/tests/Fixtures/campaign-la/LauncherCoreSessionConfigFixture.cs b/tests/Fixtures/campaign-la/LauncherCoreSessionConfigFixture.cs
index 71b6581e..cc841868 100644
--- a/tests/Fixtures/campaign-la/LauncherCoreSessionConfigFixture.cs
+++ b/tests/Fixtures/campaign-la/LauncherCoreSessionConfigFixture.cs
@@ -55,4 +55,66 @@ internal static class LauncherCoreSessionConfigFixture
return SessionConfigComposer.Serialize(composed.Document);
}
+
+ internal static string ComposeEmptyPlugins()
+ {
+ (ServerProfile server, AccountProfile account,
+ LauncherInstallRecord install, ApplicationPathSet paths) = Inputs();
+ var character = new CharacterProfile
+ {
+ Name = "Composer Character",
+ Id = "0x50000001",
+ LaunchMode = LaunchMode.Headless,
+ Plugins = [],
+ LoginCommands = [],
+ };
+
+ ComposedSessionConfig composed = SessionConfigComposer.Compose(
+ server,
+ account,
+ character,
+ install,
+ paths,
+ "composer-empty-plugins");
+ return SessionConfigComposer.Serialize(composed.Document);
+ }
+
+ internal static string ComposeProbe()
+ {
+ (ServerProfile server, AccountProfile account,
+ LauncherInstallRecord install, ApplicationPathSet paths) = Inputs();
+ ComposedSessionConfig composed = SessionConfigComposer.ComposeProbe(
+ server,
+ account,
+ install,
+ paths,
+ "composer-probe");
+ return SessionConfigComposer.Serialize(composed.Document);
+ }
+
+ private static (
+ ServerProfile Server,
+ AccountProfile Account,
+ LauncherInstallRecord Install,
+ ApplicationPathSet Paths) Inputs() =>
+ (
+ new ServerProfile
+ {
+ Name = "Composer Server",
+ Host = "composer.example",
+ Port = 9010,
+ },
+ new AccountProfile
+ {
+ Account = "composer-account",
+ Password = Password,
+ },
+ new LauncherInstallRecord(
+ "composer-dats",
+ "composer-dats/acdream.pak"),
+ new ApplicationPathSet(
+ Path.Combine(Path.GetTempPath(), "composer-config"),
+ Path.Combine(Path.GetTempPath(), "composer-data"),
+ Path.Combine(Path.GetTempPath(), "composer-cache"),
+ LegacyConfigDirectory: null));
}