diff --git a/AcDream.slnx b/AcDream.slnx
index 20f17f98..2dc2d0fd 100644
--- a/AcDream.slnx
+++ b/AcDream.slnx
@@ -28,6 +28,7 @@
+
diff --git a/docs/architecture/acdream-architecture.md b/docs/architecture/acdream-architecture.md
index 4696577b..855aa04d 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)
@@ -288,6 +300,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,
@@ -298,6 +312,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
@@ -352,6 +367,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/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/GraphicalPluginSession.cs b/src/AcDream.App/Plugins/GraphicalPluginSession.cs
new file mode 100644
index 00000000..a41ceaab
--- /dev/null
+++ b/src/AcDream.App/Plugins/GraphicalPluginSession.cs
@@ -0,0 +1,78 @@
+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 GraphicalPluginSession(PluginSession plugins)
+ {
+ _plugins = plugins;
+ }
+
+ internal int LoadedCount => _plugins.LoadedCount;
+
+ internal IReadOnlyList CaptureLoadContextWeakReferences() =>
+ _plugins.CaptureLoadContextWeakReferences();
+
+ internal static GraphicalPluginSession Start(
+ 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));
+ try
+ {
+ plugins.Start(
+ [
+ Path.Combine(AppContext.BaseDirectory, "plugins"),
+ paths.PluginsDirectory,
+ ],
+ allowList);
+ return new GraphicalPluginSession(plugins);
+ }
+ catch
+ {
+ plugins.Dispose();
+ throw;
+ }
+ }
+
+ 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..7b2c6265 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,15 @@ 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);
- }
-}
+using var pluginSession = GraphicalPluginSession.Start(
+ applicationPaths,
+ runtimeOptions.Plugins,
+ runtimeOptions.SessionId ?? "app",
+ host,
+ window.StatusWriter);
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 +182,7 @@ try
}
finally
{
- foreach (var plugin in loaded)
- {
- try { plugin.Plugin!.Disable(); }
- catch (Exception ex) { Log.Error(ex, "plugin disable failed: {Id}", plugin.Manifest.Id); }
- }
+ pluginSession.Dispose();
Log.CloseAndFlush();
}
diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs
index f3be7ef2..a3ec07eb 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;
diff --git a/src/AcDream.Core/Plugins/PluginLoader.cs b/src/AcDream.Core/Plugins/PluginLoader.cs
index ba2ba07d..85581642 100644
--- a/src/AcDream.Core/Plugins/PluginLoader.cs
+++ b/src/AcDream.Core/Plugins/PluginLoader.cs
@@ -14,6 +14,10 @@ public static class PluginLoader
///
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 +26,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,19 +47,29 @@ public static class PluginLoader
.FirstOrDefault(t => !t.IsAbstract && typeof(IAcDreamPlugin).IsAssignableFrom(t));
if (pluginType is null)
+ {
+ alc.Unload();
return new LoadedPlugin(
manifest,
Plugin: null,
LoadContext: null,
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)
{
+ // Initialize may have attached host callbacks before it failed.
+ // Give that partial instance the same best-effort cleanup chance
+ // as an Enable failure before releasing the collectible context.
+ try { instance?.Disable(); }
+ catch { }
+ try { alc?.Unload(); }
+ catch { }
return new LoadedPlugin(manifest, Plugin: null, LoadContext: null, Error: ex);
}
}
diff --git a/src/AcDream.Core/Plugins/PluginSession.cs b/src/AcDream.Core/Plugins/PluginSession.cs
new file mode 100644
index 00000000..5c939fd7
--- /dev/null
+++ b/src/AcDream.Core/Plugins/PluginSession.cs
@@ -0,0 +1,361 @@
+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 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 plugin => plugin.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() =>
+ _loaded
+ .Select(static plugin => new WeakReference(plugin.LoadContext!))
+ .ToArray();
+
+ public void Dispose()
+ {
+ if (_disposed)
+ return;
+ _disposed = true;
+
+ for (int index = _loaded.Count - 1; index >= 0; index--)
+ {
+ LoadedPlugin loaded = _loaded[index];
+ try
+ {
+ loaded.Plugin!.Disable();
+ }
+ catch (Exception error)
+ {
+ SafeLog(
+ static (log, message, exception) =>
+ log.Error(message, exception),
+ $"plugin disable failed: {loaded.Manifest.Id}",
+ error);
+ }
+
+ 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)
+ {
+ LoadedPlugin loaded = PluginLoader.Load(
+ candidate.PluginDirectory,
+ candidate.Manifest!,
+ _host);
+ if (!loaded.Success)
+ {
+ AddError(
+ errors,
+ id,
+ loaded.Error ?? new InvalidOperationException(
+ "plugin load failed"));
+ continue;
+ }
+
+ try
+ {
+ loaded.Plugin!.Enable();
+ _loaded.Add(loaded);
+ 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);
+ }
+ }
+ }
+
+ 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)
+ {
+ 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);
+ }
+
+ 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 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;
+}
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..eb0ce2a5 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.Start(
+ 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;
@@ -638,22 +652,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..f68c4bc5
--- /dev/null
+++ b/src/AcDream.Headless/Plugins/HeadlessPluginHost.cs
@@ -0,0 +1,150 @@
+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 Action? _entitySpawned;
+ private bool _disposed;
+
+ 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;
+
+ ///
+ /// 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.Snapshots;
+ }
+ }
+
+ public event Action EntitySpawned
+ {
+ add
+ {
+ ArgumentNullException.ThrowIfNull(value);
+ ObjectDisposedException.ThrowIf(_disposed, this);
+ lock (_eventGate)
+ _entitySpawned += value;
+
+ // Match the graphical WorldEvents contract: a late subscriber
+ // immediately observes the canonical world that exists now.
+ foreach (WorldEntitySnapshot snapshot in Entities)
+ Invoke(value, snapshot);
+ }
+ remove
+ {
+ if (value is null)
+ return;
+ lock (_eventGate)
+ _entitySpawned -= value;
+ }
+ }
+
+ public void Dispose()
+ {
+ if (_disposed)
+ return;
+ _eventSubscription.Dispose();
+ _disposed = true;
+ lock (_eventGate)
+ _entitySpawned = null;
+ }
+
+ public void OnEntity(in RuntimeEntityDelta delta)
+ {
+ if (_disposed || delta.Change != RuntimeEntityChange.Registered)
+ return;
+ Action? handlers;
+ lock (_eventGate)
+ handlers = _entitySpawned;
+ if (handlers is null)
+ return;
+
+ WorldEntitySnapshot snapshot = Convert(_runtime, delta.Entity);
+ foreach (Action handler
+ in handlers.GetInvocationList().Cast>())
+ {
+ Invoke(handler, 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)
+ : IRuntimeEntityVisitor
+ {
+ internal List Snapshots { get; } =
+ new(runtime.Entities.Count);
+
+ public void Visit(in RuntimeEntitySnapshot entity) =>
+ Snapshots.Add(Convert(runtime, entity));
+ }
+}
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..cff1226a
--- /dev/null
+++ b/src/AcDream.Headless/Plugins/HeadlessPluginSession.cs
@@ -0,0 +1,109 @@
+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 int _disposeStage;
+ private bool _disposed;
+
+ private HeadlessPluginSession(
+ HeadlessPluginHost host,
+ PluginSession plugins)
+ {
+ _host = host;
+ _plugins = plugins;
+ }
+
+ internal int LoadedCount => _plugins.LoadedCount;
+ internal IPluginHost Host => _host;
+
+ internal IReadOnlyList CaptureLoadContextWeakReferences() =>
+ _plugins.CaptureLoadContextWeakReferences();
+
+ internal static HeadlessPluginSession Start(
+ 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));
+ try
+ {
+ plugins.Start(roots, allowList);
+ return new HeadlessPluginSession(host, plugins);
+ }
+ catch
+ {
+ plugins.Dispose();
+ host.Dispose();
+ throw;
+ }
+ }
+
+ 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.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..0550f170 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,21 @@ public interface IUiRegistry
/// Object whose properties the markup's {Bindings} resolve against.
void AddMarkupPanel(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 : IUiRegistry
+{
+ public static NoOpUiRegistry Instance { get; } = new();
+
+ private NoOpUiRegistry()
+ {
+ }
+
+ public void AddMarkupPanel(string markupPath, object binding)
+ {
+ }
+}
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/Plugins/GraphicalPluginSessionTests.cs b/tests/AcDream.App.Tests/Plugins/GraphicalPluginSessionTests.cs
new file mode 100644
index 00000000..b77e2cb6
--- /dev/null
+++ b/tests/AcDream.App.Tests/Plugins/GraphicalPluginSessionTests.cs
@@ -0,0 +1,202 @@
+using System.Text.Json;
+using System.Runtime.CompilerServices;
+using AcDream.App.Plugins;
+using AcDream.Core.Plugins;
+using AcDream.Core.Selection;
+using AcDream.Platform;
+using AcDream.Plugin.Abstractions;
+using AcDream.Runtime.Session;
+
+namespace AcDream.App.Tests.Plugins;
+
+public sealed class GraphicalPluginSessionTests
+{
+ private const string FixtureId = "acdream.test.host-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.Start(
+ paths,
+ [FixtureId.ToUpperInvariant(), "acdream.test.missing"],
+ "gui-session",
+ host,
+ new SessionStatusWriter(statusPath));
+
+ 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(["pluginLoaded", "pluginFailed"], EventNames(statuses));
+ Assert.Equal(FixtureId, statuses[0].GetProperty("plugin").GetString());
+ Assert.Equal(
+ "acdream.test.missing",
+ statuses[1].GetProperty("plugin").GetString());
+ Assert.Contains(
+ "not found",
+ statuses[1].GetProperty("error").GetString(),
+ StringComparison.OrdinalIgnoreCase);
+
+ WeakReference context = Assert.Single(
+ plugins.CaptureLoadContextWeakReferences());
+ plugins.Dispose();
+ 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");
+ var ui = new BufferedUiRegistry();
+ var host = new AppPluginHost(
+ new CapturingLogger(),
+ new WorldGameState(),
+ new WorldEvents(),
+ new SelectionState(),
+ ui);
+
+ using GraphicalPluginSession plugins = GraphicalPluginSession.Start(
+ paths,
+ [],
+ "gui-session",
+ host,
+ new SessionStatusWriter(statusPath));
+
+ Assert.Equal(0, plugins.LoadedCount);
+ Assert.Empty(ui.Drain());
+ Assert.False(File.Exists(statusPath));
+ }
+
+ 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 void InstallFixture(string root, string id)
+ {
+ string source = FixtureAssemblyPath();
+ Assert.True(File.Exists(source), $"fixture DLL not found: {source}");
+ string pluginDirectory = Path.Combine(root, "host-fixture");
+ 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,
+ }));
+ }
+
+ 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()
+ {
+ if (Directory.Exists(Path))
+ Directory.Delete(Path, recursive: true);
+ }
+ }
+}
diff --git a/tests/AcDream.Core.Tests/Plugins/PluginLoaderTests.cs b/tests/AcDream.Core.Tests/Plugins/PluginLoaderTests.cs
index a55fb398..3deebb94 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]
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..130dc6d3
--- /dev/null
+++ b/tests/AcDream.Headless.Tests/HeadlessPluginSessionTests.cs
@@ -0,0 +1,235 @@
+using System.Text.Json;
+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;
+
+namespace AcDream.Headless.Tests;
+
+public sealed class HeadlessPluginSessionTests
+{
+ private const string FixtureId = "acdream.test.host-fixture";
+ private const string BrokenId = "acdream.test.broken";
+
+ [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,
+ pluginRoots: [temporary.Path]);
+ HeadlessPluginSession plugins = session.Plugins;
+ _ = 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(["pluginLoaded", "pluginFailed"], EventNames(statuses));
+ Assert.Equal(FixtureId, statuses[0].GetProperty("plugin").GetString());
+ Assert.Equal(BrokenId, statuses[1].GetProperty("plugin").GetString());
+ Assert.Contains(
+ "entry dll not found",
+ statuses[1].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),
+ pluginRoots: [temporary.Path]);
+
+ Assert.Equal(0, session.Plugins.LoadedCount);
+ Assert.False(File.Exists(statusPath));
+ Assert.DoesNotContain("fixture-", output.ToString());
+ }
+
+ 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 void InstallFixture(string root, string id)
+ {
+ string source = FixtureAssemblyPath();
+ Assert.True(File.Exists(source), $"fixture DLL not found: {source}");
+ string pluginDirectory = Path.Combine(root, "host-fixture");
+ Directory.CreateDirectory(pluginDirectory);
+ string fileName = Path.GetFileName(source);
+ File.Copy(source, Path.Combine(pluginDirectory, fileName));
+ WriteManifest(pluginDirectory, id, fileName);
+ }
+
+ 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 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()
+ {
+ if (Directory.Exists(Path))
+ Directory.Delete(Path, recursive: true);
+ }
+ }
+}
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..9a1a9143
--- /dev/null
+++ b/tests/AcDream.Plugin.Tests.Fixtures.HostPlugin/HostPlugin.cs
@@ -0,0 +1,46 @@
+using AcDream.Plugin.Abstractions;
+
+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 int _entitiesSeen;
+
+ public void Initialize(IPluginHost host)
+ {
+ _host = host ?? throw new ArgumentNullException(nameof(host));
+ host.Log.Info($"fixture-initialized:hasUi={host.HasUi}");
+ }
+
+ public void Enable()
+ {
+ IPluginHost host = _host
+ ?? throw new InvalidOperationException("The fixture was not initialized.");
+ host.Ui.AddMarkupPanel(
+ Path.Combine(AppContext.BaseDirectory, "fixture-panel.xml"),
+ this);
+ host.Events.EntitySpawned += OnEntitySpawned;
+ host.Log.Info(
+ $"fixture-enabled:hasUi={host.HasUi}:entities={host.State.Entities.Count}");
+ }
+
+ public void Disable()
+ {
+ IPluginHost? host = _host;
+ if (host is null)
+ return;
+ host.Events.EntitySpawned -= OnEntitySpawned;
+ host.Log.Info($"fixture-disabled:entitiesSeen={_entitiesSeen}");
+ _host = null;
+ }
+
+ private void OnEntitySpawned(WorldEntitySnapshot snapshot) =>
+ _entitiesSeen++;
+}
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.