merge: Campaign LA LA5 - plugin hosting review-closed

This commit is contained in:
Erik 2026-08-14 19:33:27 +02:00
commit 5535d0adac
44 changed files with 3009 additions and 195 deletions

View file

@ -30,6 +30,7 @@
<Project Path="tests/AcDream.Launcher.Core.Tests/AcDream.Launcher.Core.Tests.csproj" /> <Project Path="tests/AcDream.Launcher.Core.Tests/AcDream.Launcher.Core.Tests.csproj" />
<Project Path="tests/AcDream.Launcher.Tests/AcDream.Launcher.Tests.csproj" /> <Project Path="tests/AcDream.Launcher.Tests/AcDream.Launcher.Tests.csproj" />
<Project Path="tests/AcDream.Platform.Tests/AcDream.Platform.Tests.csproj" /> <Project Path="tests/AcDream.Platform.Tests/AcDream.Platform.Tests.csproj" />
<Project Path="tests/AcDream.Plugin.Tests.Fixtures.HostPlugin/AcDream.Plugin.Tests.Fixtures.HostPlugin.csproj" />
<Project Path="tests/AcDream.Runtime.Tests/AcDream.Runtime.Tests.csproj" /> <Project Path="tests/AcDream.Runtime.Tests/AcDream.Runtime.Tests.csproj" />
<Project Path="tests/AcDream.UI.Abstractions.Tests/AcDream.UI.Abstractions.Tests.csproj" /> <Project Path="tests/AcDream.UI.Abstractions.Tests/AcDream.UI.Abstractions.Tests.csproj" />
</Folder> </Folder>

View file

@ -120,7 +120,16 @@ handlers and controllers translate those intents to `WorldSession`; panels
never inspect or construct wire messages. never inspect or construct wire messages.
Plugins register retained gameplay markup through the BCL-only Plugins register retained gameplay markup through the BCL-only
`AcDream.Plugin.Abstractions.IUiRegistry`; they do not import App or `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; radar, inventory, paperdoll, toolbar, use/examine consumers, and plugins;
`IPluginHost.Selection` exposes that same state and retail-style old/new callback. `IPluginHost.Selection` exposes that same state and retail-style old/new callback.
Temporary pointer modes are separate App orchestration in `InteractionState` and Temporary pointer modes are separate App orchestration in `InteractionState` and
@ -174,6 +183,9 @@ parallel window-lifecycle map.
``` ```
src/ src/
AcDream.Core/ Layer 2-4: no Vulkan, no Silk.NET, pure logic 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/ Physics/
PhysicsBody.cs -> body state / integration foundation (done) PhysicsBody.cs -> body state / integration foundation (done)
CollisionPrimitives.cs -> retail primitive helpers (partial, active) CollisionPrimitives.cs -> retail primitive helpers (partial, active)
@ -304,6 +316,8 @@ src/
Configuration/ -> strict versioned process/session config Configuration/ -> strict versioned process/session config
Credentials/ -> redacted env/stdin/owner-only-file providers Credentials/ -> redacted env/stdin/owner-only-file providers
Hosting/ -> one GameRuntime/session/lease/policy lifetime 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 Policies/ -> typed Runtime-view/command consumers
-> references Runtime only; no presentation/backend package -> references Runtime only; no presentation/backend package
-> Slice K complete: portable single/multi-session production host, -> Slice K complete: portable single/multi-session production host,
@ -314,6 +328,7 @@ src/
AcDream.Plugin.Abstractions/ Layer 5: plugin interfaces AcDream.Plugin.Abstractions/ Layer 5: plugin interfaces
IAcDreamPlugin.cs -> done IAcDreamPlugin.cs -> done
IPluginHost.cs -> done IPluginHost.cs -> done
IUiRegistry.cs -> capability-aware retained/no-op UI contract
IGameState.cs -> done IGameState.cs -> done
IEvents.cs -> done IEvents.cs -> done
ISelectionService.cs -> done ISelectionService.cs -> done
@ -368,6 +383,7 @@ src/
PlayerMovementController.cs -> active movement driver PlayerMovementController.cs -> active movement driver
Plugins/ Plugins/
AppPluginHost.cs -> done AppPluginHost.cs -> done
GraphicalPluginSession.cs -> thin shared-session/root/status adapter
``` ```
The 4B2 production SetPosition routes and shared local-controller body remain The 4B2 production SetPosition routes and shared local-controller body remain

View file

@ -155,7 +155,11 @@ Field rules:
for gui/guiSelect/probe. for gui/guiSelect/probe.
- `credential`: always `{ "provider": "standardInput", "reference": - `credential`: always `{ "provider": "standardInput", "reference":
"session" }` for launcher-composed configs. "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 omitted-when-unset (never null, never `[]` for empty). Absent
`loginCommandDelayMs` means 500. `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). genuinely App-only. Headless has zero plugin hosting today (confirmed).
1. Session-config `Plugins` allow-list filters the discovery result on BOTH 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 2. `HeadlessPluginHost : IPluginHost` in Headless over the same Core-owned
`State`/`Events`/`Selection`; `Ui` is an explicit no-op behind a new `State`/`Events`/`Selection`; `Ui` is an explicit no-op behind a new
capability flag on `IPluginHost` (e.g. `HasUi`) so plugins can detect capability flag on `IPluginHost` (e.g. `HasUi`) so plugins can detect

View file

@ -168,7 +168,9 @@ Hand-editability is a property of the format, not a required workflow.
`HeadlessConfiguration` shape extended with: `HeadlessConfiguration` shape extended with:
- `Plugins: string[]` — plugin names to load from the standard - `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. - `LoginCommands: string[]` — ordered chat-typed strings.
- Graphical host: `Character` selector may be ABSENT → character-select - Graphical host: `Character` selector may be ABSENT → character-select
screen instead of auto-enter. screen instead of auto-enter.

View file

@ -4,11 +4,7 @@ using AcDream.Runtime.Session;
namespace AcDream.App.Composition; namespace AcDream.App.Composition;
internal sealed record SessionStartDependencies( internal sealed record SessionStartDependencies(
Action<string> Log, Action<string> Log);
/// <summary>Campaign LA slice LA1: no-op when no statusFile was
/// configured.</summary>
SessionStatusWriter StatusWriter,
string SessionId);
/// <summary> /// <summary>
/// Terminal startup phase. Every callback, command target, and frame root is /// Terminal startup phase. Every callback, command target, and frame root is
@ -26,9 +22,6 @@ internal sealed class SessionStartCompositionPhase
public void Start(FrameRootResult frame) public void Start(FrameRootResult frame)
{ {
ArgumentNullException.ThrowIfNull(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 = RuntimeSessionStartResult result =
frame.GameRuntime.Session.Start(frame.GameRuntime.Generation); frame.GameRuntime.Session.Start(frame.GameRuntime.Generation);
Report(result, _dependencies.Log); Report(result, _dependencies.Log);

View file

@ -18,6 +18,7 @@ public sealed class AppPluginHost : IPluginHost
Ui = ui; Ui = ui;
} }
public bool HasUi => true;
public IPluginLogger Log { get; } public IPluginLogger Log { get; }
public IGameState State { get; } public IGameState State { get; }
public IEvents Events { get; } public IEvents Events { get; }

View file

@ -1,4 +1,5 @@
using System.Collections.Generic; using System.Collections.Generic;
using AcDream.App.UI;
using AcDream.Plugin.Abstractions; using AcDream.Plugin.Abstractions;
namespace AcDream.App.Plugins; 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 /// Program.cs before the GL window opens) until GameWindow drains them into the
/// UiHost tree after construction. /// UiHost tree after construction.
/// </summary> /// </summary>
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> _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<long, Registration> _registrations = [];
private long _nextRegistrationId;
public void AddMarkupPanel(string markupPath, object binding) public void AddMarkupPanel(string markupPath, object binding)
=> _pending.Add(new Pending(markupPath, binding)); => _ = RegisterMarkupPanel(markupPath, binding);
/// <summary>Return + clear all buffered registrations.</summary> 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);
}
/// <summary>Returns each not-yet-drained active registration once.</summary>
public IReadOnlyList<Pending> Drain() public IReadOnlyList<Pending> Drain()
{ {
var copy = _pending.ToArray(); lock (_gate)
_pending.Clear(); {
return copy; var pending = new List<Pending>(_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);
} }
} }

View file

@ -0,0 +1,99 @@
using AcDream.Core.Plugins;
using AcDream.Platform;
using AcDream.Plugin.Abstractions;
using AcDream.Runtime.Session;
namespace AcDream.App.Plugins;
/// <summary>
/// Graphical-host composition for one plugin set. The shared
/// <see cref="PluginSession"/> owns discovery and collectible lifetimes; this
/// adapter supplies the graphical roots and translates outcomes into the
/// launcher status stream.
/// </summary>
internal sealed class GraphicalPluginSession : IDisposable
{
private readonly PluginSession _plugins;
private readonly string[] _roots;
private readonly IReadOnlyList<string>? _allowList;
private readonly string _sessionId;
private readonly SessionStatusWriter _statusWriter;
private bool _started;
private GraphicalPluginSession(
PluginSession plugins,
string[] roots,
IReadOnlyList<string>? allowList,
string sessionId,
SessionStatusWriter statusWriter)
{
_plugins = plugins;
_roots = roots;
_allowList = allowList;
_sessionId = sessionId;
_statusWriter = statusWriter;
}
internal int LoadedCount => _plugins.LoadedCount;
internal IReadOnlyList<WeakReference> CaptureLoadContextWeakReferences() =>
_plugins.CaptureLoadContextWeakReferences();
internal static GraphicalPluginSession Create(
ApplicationPathSet paths,
IReadOnlyList<string>? 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");
}
}

View file

@ -4,7 +4,6 @@ using AcDream.App.Credentials;
using AcDream.App.Plugins; using AcDream.App.Plugins;
using AcDream.App.Platform; using AcDream.App.Platform;
using AcDream.App.Rendering; using AcDream.App.Rendering;
using AcDream.Core.Plugins;
using AcDream.Platform; using AcDream.Platform;
using Serilog; using Serilog;
@ -162,76 +161,16 @@ var host = new AppPluginHost(
worldEvents, worldEvents,
window.Selection, window.Selection,
uiRegistry); uiRegistry);
GraphicalPluginSession pluginSession = GraphicalPluginSession.Create(
var loaded = new List<LoadedPlugin>(); applicationPaths,
var loadedPluginIds = new HashSet<string>(StringComparer.OrdinalIgnoreCase); runtimeOptions.Plugins,
StringComparer pathComparer = runtimeOptions.SessionId ?? "app",
graphicalPlatform.OperatingSystem host,
== GraphicalHostOperatingSystem.Windows window.StatusWriter);
? StringComparer.OrdinalIgnoreCase window.StartPluginHosting(pluginSession);
: 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);
}
}
try try
{ {
foreach (var plugin in loaded)
{
try { plugin.Plugin!.Enable(); }
catch (Exception ex) { Log.Error(ex, "plugin enable failed: {Id}", plugin.Manifest.Id); }
}
try try
{ {
window.Run(); window.Run();
@ -244,11 +183,6 @@ try
} }
finally 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(); Log.CloseAndFlush();
} }

View file

@ -362,6 +362,7 @@ public sealed class GameWindow :
private RuntimeActionState _runtimeActions => _runtime.ActionOwner; private RuntimeActionState _runtimeActions => _runtime.ActionOwner;
public AcDream.Core.Selection.SelectionState Selection => public AcDream.Core.Selection.SelectionState Selection =>
_runtimeActions.Selection; _runtimeActions.Selection;
internal SessionStatusWriter StatusWriter => _statusWriter;
public AcDream.Core.Chat.ChatLog Chat => _runtimeCommunication.Chat; public AcDream.Core.Chat.ChatLog Chat => _runtimeCommunication.Chat;
public AcDream.Core.Chat.TurbineChatState TurbineChat => public AcDream.Core.Chat.TurbineChatState TurbineChat =>
_runtimeCommunication.TurbineChat; _runtimeCommunication.TurbineChat;
@ -430,6 +431,7 @@ public sealed class GameWindow :
_creatureAppraisalFramePresenter; _creatureAppraisalFramePresenter;
// Phase D.2b Task 9 — plugin UI registrations buffered before OnLoad; drained in OnLoad. // Phase D.2b Task 9 — plugin UI registrations buffered before OnLoad; drained in OnLoad.
private readonly AcDream.App.Plugins.BufferedUiRegistry? _uiRegistry; private readonly AcDream.App.Plugins.BufferedUiRegistry? _uiRegistry;
private AcDream.App.Plugins.GraphicalPluginSession? _pluginSession;
// Campaign V slice V11 deleted the ImGui developer-tools frontend along // Campaign V slice V11 deleted the ImGui developer-tools frontend along
// with the OpenGL backend it required, so no host ever composes a // with the OpenGL backend it required, so no host ever composes a
// developer UI regardless of ACDREAM_DEVTOOLS. The flag still reaches // developer UI regardless of ACDREAM_DEVTOOLS. The flag still reaches
@ -722,6 +724,24 @@ public sealed class GameWindow :
_movementTruthDiagnostics); _movementTruthDiagnostics);
} }
/// <summary>
/// Transfers the graphical plugin lifetime into the window shutdown graph
/// and starts it before retained UI construction drains registrations.
/// </summary>
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() public void Run()
{ {
_platformServices.ConfigureWindowBackend(); _platformServices.ConfigureWindowBackend();
@ -1554,9 +1574,7 @@ public sealed class GameWindow :
sessionPlayer), sessionPlayer),
frameRoots => new SessionStartCompositionPhase( frameRoots => new SessionStartCompositionPhase(
new SessionStartDependencies( new SessionStartDependencies(
Console.WriteLine, Console.WriteLine))
_statusWriter,
_options.SessionId ?? "app"))
.Start(frameRoots)); .Start(frameRoots));
} }
@ -1703,6 +1721,7 @@ public sealed class GameWindow :
_kbSource, _kbSource,
_retailUiLease, _retailUiLease,
_uiHost, _uiHost,
_pluginSession,
_runtime, _runtime,
_movementInput, _movementInput,
_cameraInput, _cameraInput,

View file

@ -71,6 +71,7 @@ internal sealed record IngressShutdownRoots(
RetailUiRuntimeLease RetailUi, RetailUiRuntimeLease RetailUi,
// Keeps failed physical UI bindings alive through native-window release. // Keeps failed physical UI bindings alive through native-window release.
UiHost? RetainedUiHost, UiHost? RetainedUiHost,
IDisposable? Plugins,
GameRuntime Runtime, GameRuntime Runtime,
DispatcherMovementInputSource MovementInput, DispatcherMovementInputSource MovementInput,
DispatcherCameraInputSource CameraInput, DispatcherCameraInputSource CameraInput,
@ -422,6 +423,10 @@ internal static class GameWindowShutdownManifest
Soft("keyboard source", () => DisposeKeyboardSource(ingress.KeyboardSource)), Soft("keyboard source", () => DisposeKeyboardSource(ingress.KeyboardSource)),
Soft("native window callbacks", () => DisposeWindowCallbacks(ingress.WindowCallbacks)), Soft("native window callbacks", () => DisposeWindowCallbacks(ingress.WindowCallbacks)),
]), ]),
new ResourceShutdownStage("plugin host",
[
Hard("plugins", () => ingress.Plugins?.Dispose()),
]),
new ResourceShutdownStage("frame borrowers", new ResourceShutdownStage("frame borrowers",
[ [
Hard("world frame composition", () => frame.FrameGraphPublication?.Dispose()), Hard("world frame composition", () => frame.FrameGraphPublication?.Dispose()),

View file

@ -3273,10 +3273,12 @@ public sealed class RetailUiRuntime : IDisposable
_bindings.Assets.ResolveSprite, _bindings.Assets.ResolveSprite,
_bindings.Assets.Controls); _bindings.Assets.Controls);
Host.Root.AddChild(element); Host.Root.AddChild(element);
_bindings.Plugins.CompleteMount(panel, Host.Root, element);
Console.WriteLine($"[D.2b] plugin UI panel loaded: {panel.MarkupPath}"); Console.WriteLine($"[D.2b] plugin UI panel loaded: {panel.MarkupPath}");
} }
catch (Exception ex) catch (Exception ex)
{ {
_bindings.Plugins.FailMount(panel);
Console.WriteLine($"[D.2b] plugin UI panel '{panel.MarkupPath}' failed to load: {ex.Message}"); Console.WriteLine($"[D.2b] plugin UI panel '{panel.MarkupPath}' failed to load: {ex.Message}");
} }
} }

View file

@ -7,8 +7,10 @@ namespace AcDream.Core.Plugins;
/// Outcome of a plugin load attempt. /// Outcome of a plugin load attempt.
/// <para>On success, <see cref="Plugin"/> is the instantiated plugin, <see cref="LoadContext"/> /// <para>On success, <see cref="Plugin"/> is the instantiated plugin, <see cref="LoadContext"/>
/// owns its assembly, and <see cref="Error"/> is null.</para> /// owns its assembly, and <see cref="Error"/> is null.</para>
/// <para>On failure, <see cref="Plugin"/> and <see cref="LoadContext"/> are null and /// <para>On failure, <see cref="Error"/> describes what went wrong. A partial
/// <see cref="Error"/> describes what went wrong.</para> /// <see cref="Plugin"/> and/or <see cref="LoadContext"/> 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.</para>
/// </summary> /// </summary>
public sealed record LoadedPlugin( public sealed record LoadedPlugin(
PluginManifest Manifest, PluginManifest Manifest,
@ -16,5 +18,6 @@ public sealed record LoadedPlugin(
AssemblyLoadContext? LoadContext, AssemblyLoadContext? LoadContext,
Exception? Error) 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;
} }

View file

@ -11,9 +11,15 @@ public static class PluginLoader
/// implementing <see cref="IAcDreamPlugin"/>, instantiate it, and call its /// implementing <see cref="IAcDreamPlugin"/>, instantiate it, and call its
/// <see cref="IAcDreamPlugin.Initialize"/> with the supplied host. Any failure /// <see cref="IAcDreamPlugin.Initialize"/> with the supplied host. Any failure
/// is returned as a failed <see cref="LoadedPlugin"/> rather than thrown. /// is returned as a failed <see cref="LoadedPlugin"/> rather than thrown.
/// A returned partial plugin/context remains caller-owned; this method never
/// requests unload because the caller must close host registrations first.
/// </summary> /// </summary>
public static LoadedPlugin Load(string pluginDirectory, PluginManifest manifest, IPluginHost host) 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); var dllPath = Path.Combine(pluginDirectory, manifest.EntryDll);
if (!File.Exists(dllPath)) if (!File.Exists(dllPath))
return new LoadedPlugin( return new LoadedPlugin(
@ -22,9 +28,11 @@ public static class PluginLoader
LoadContext: null, LoadContext: null,
Error: new FileNotFoundException($"entry dll not found: {dllPath}", dllPath)); Error: new FileNotFoundException($"entry dll not found: {dllPath}", dllPath));
PluginAssemblyLoadContext? alc = null;
IAcDreamPlugin? instance = null;
try try
{ {
var alc = new PluginAssemblyLoadContext(pluginDirectory, dllPath); alc = new PluginAssemblyLoadContext(pluginDirectory, dllPath);
var asm = alc.LoadFromAssemblyPath(dllPath); var asm = alc.LoadFromAssemblyPath(dllPath);
IEnumerable<Type> types; IEnumerable<Type> types;
@ -41,20 +49,30 @@ public static class PluginLoader
.FirstOrDefault(t => !t.IsAbstract && typeof(IAcDreamPlugin).IsAssignableFrom(t)); .FirstOrDefault(t => !t.IsAbstract && typeof(IAcDreamPlugin).IsAssignableFrom(t));
if (pluginType is null) if (pluginType is null)
{
return new LoadedPlugin( return new LoadedPlugin(
manifest, manifest,
Plugin: null, Plugin: null,
LoadContext: null, LoadContext: alc,
Error: new InvalidOperationException( Error: new InvalidOperationException(
$"no IAcDreamPlugin implementation found in {manifest.EntryDll}")); $"no IAcDreamPlugin implementation found in {manifest.EntryDll}"));
}
var instance = (IAcDreamPlugin)Activator.CreateInstance(pluginType)!; instance = (IAcDreamPlugin)Activator.CreateInstance(pluginType)!;
instance.Initialize(host); instance.Initialize(host);
return new LoadedPlugin(manifest, instance, alc, Error: null); return new LoadedPlugin(manifest, instance, alc, Error: null);
} }
catch (Exception ex) 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);
} }
} }
} }

View file

@ -0,0 +1,421 @@
using AcDream.Plugin.Abstractions;
namespace AcDream.Core.Plugins;
public enum PluginSessionStatusKind
{
Loaded,
Failed,
}
/// <summary>
/// Final startup outcome for one configured plugin id. Hosts translate these
/// outcomes into their own diagnostics and the Campaign LA status stream.
/// </summary>
public readonly record struct PluginSessionStatus(
string Plugin,
PluginSessionStatusKind Kind,
string? Error = null);
/// <summary>
/// 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.
/// </summary>
public sealed class PluginSession : IDisposable
{
private readonly IPluginHost _host;
private readonly Action<PluginSessionStatus>? _report;
private readonly List<ActivePlugin> _loaded = [];
private readonly List<WeakReference> _releasedContexts = [];
private bool _started;
private bool _disposed;
public PluginSession(
IPluginHost host,
Action<PluginSessionStatus>? report = null)
{
_host = host ?? throw new ArgumentNullException(nameof(host));
_report = report;
}
public int LoadedCount => _loaded.Count;
public IReadOnlyList<string> LoadedPluginIds =>
_loaded.Select(static active => active.Loaded.Manifest.Id).ToArray();
/// <summary>
/// Discovers and starts the configured set exactly once. A
/// <see langword="null"/> 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.
/// </summary>
public void Start(
IEnumerable<string> pluginRoots,
IReadOnlyList<string>? 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<string, List<PluginDiscoveryResult>>(
StringComparer.OrdinalIgnoreCase);
var errors = new Dictionary<string, List<Exception>>(
StringComparer.OrdinalIgnoreCase);
var discoveredOrder = new List<string>();
HashSet<string>? requestedSet = requested is null
? null
: new HashSet<string>(requested, StringComparer.OrdinalIgnoreCase);
foreach (string root in roots)
{
IReadOnlyList<PluginDiscoveryResult> 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<PluginDiscoveryResult>? list))
{
list = [];
candidates.Add(id, list);
}
list.Add(result);
}
}
IEnumerable<string> loadOrder = requested is null
? discoveredOrder
: requested;
foreach (string id in loadOrder)
LoadOne(id, candidates, errors);
}
/// <summary>
/// Test/diagnostic observation of the exact collectible contexts currently
/// owned by this session. The returned weak references do not delay unload.
/// </summary>
public IReadOnlyList<WeakReference> 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<string, List<PluginDiscoveryResult>> candidates,
Dictionary<string, List<Exception>> errors)
{
if (candidates.TryGetValue(id, out List<PluginDiscoveryResult>? 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<Exception>? 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<IPluginLogger, string, Exception?> write,
string message,
Exception? error)
{
try { write(_host.Log, message, error); }
catch { }
}
private static string[] DistinctRoots(IEnumerable<string> 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<string> ordered, string id)
{
if (!ordered.Contains(id, StringComparer.OrdinalIgnoreCase))
ordered.Add(id);
}
private static void AddError(
Dictionary<string, List<Exception>> errors,
string id,
Exception error)
{
if (!errors.TryGetValue(id, out List<Exception>? 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);
}

View file

@ -0,0 +1,272 @@
using AcDream.Plugin.Abstractions;
namespace AcDream.Core.Plugins;
/// <summary>
/// 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.
/// </summary>
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<Action<SelectionChangedEvent>> _registrations = [];
private bool _disposed;
public uint? SelectedObjectId => inner.SelectedObjectId;
public uint? PreviousObjectId => inner.PreviousObjectId;
public event Action<SelectionChangedEvent> 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<SelectionChangedEvent>[] 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<SelectionChangedEvent> 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<Action<WorldEntitySnapshot>> _registrations = [];
private bool _disposed;
public event Action<WorldEntitySnapshot> 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<WorldEntitySnapshot>[] 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<WorldEntitySnapshot> 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<IDisposable> _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 { }
}
}
}
}

View file

@ -56,6 +56,11 @@ internal sealed class HeadlessProcessHost : IDisposable
paths.ConfigDirectory); paths.ConfigDirectory);
var sessions = new List<HeadlessSessionHost>( var sessions = new List<HeadlessSessionHost>(
configuration.Sessions.Count); configuration.Sessions.Count);
string[] pluginRoots =
[
Path.Combine(AppContext.BaseDirectory, "plugins"),
paths.PluginsDirectory,
];
HeadlessProcessContentOwner? content = null; HeadlessProcessContentOwner? content = null;
HeadlessProcessResourceSampler? resources = null; HeadlessProcessResourceSampler? resources = null;
// FA6: constructed unconditionally — cheap, and every non-gate // FA6: constructed unconditionally — cheap, and every non-gate
@ -104,7 +109,8 @@ internal sealed class HeadlessProcessHost : IDisposable
sessionOperations, sessionOperations,
timeProvider, timeProvider,
contentLease: contentLease, contentLease: contentLease,
gateCoordinator: gateCoordinator)); gateCoordinator: gateCoordinator,
pluginRoots: pluginRoots));
} }
catch catch
{ {

View file

@ -1,6 +1,7 @@
using AcDream.Headless.Configuration; using AcDream.Headless.Configuration;
using AcDream.Headless.Credentials; using AcDream.Headless.Credentials;
using AcDream.Headless.Diagnostics; using AcDream.Headless.Diagnostics;
using AcDream.Headless.Plugins;
using AcDream.Headless.Policies; using AcDream.Headless.Policies;
using AcDream.Core.Net.Messages; using AcDream.Core.Net.Messages;
using AcDream.Core.Physics; using AcDream.Core.Physics;
@ -155,6 +156,7 @@ internal sealed class HeadlessSessionHost : IDisposable
private readonly IDisposable _hostLease; private readonly IDisposable _hostLease;
private readonly IHeadlessBotPolicy _policy; private readonly IHeadlessBotPolicy _policy;
private readonly IDisposable _policySubscription; private readonly IDisposable _policySubscription;
private readonly HeadlessPluginSession _pluginSession;
private readonly LiveSessionHost _liveSession; private readonly LiveSessionHost _liveSession;
private readonly RuntimeLocalPlayerFrameController _localPlayerFrame; private readonly RuntimeLocalPlayerFrameController _localPlayerFrame;
private readonly HeadlessProcessContentOwner.HeadlessProcessContentLease? private readonly HeadlessProcessContentOwner.HeadlessProcessContentLease?
@ -238,7 +240,8 @@ internal sealed class HeadlessSessionHost : IDisposable
contentLease = null, contentLease = null,
IHeadlessBotPolicy? policyOverride = null, IHeadlessBotPolicy? policyOverride = null,
IRuntimePlacementProjectionSink? placementSinkOverride = null, IRuntimePlacementProjectionSink? placementSinkOverride = null,
FellowshipAllegianceGateCoordinator? gateCoordinator = null) FellowshipAllegianceGateCoordinator? gateCoordinator = null,
IEnumerable<string>? pluginRoots = null)
{ {
_descriptor = descriptor _descriptor = descriptor
?? throw new ArgumentNullException(nameof(descriptor)); ?? throw new ArgumentNullException(nameof(descriptor));
@ -263,6 +266,7 @@ internal sealed class HeadlessSessionHost : IDisposable
IDisposable? hostLease = null; IDisposable? hostLease = null;
IHeadlessBotPolicy? policy = null; IHeadlessBotPolicy? policy = null;
IDisposable? policySubscription = null; IDisposable? policySubscription = null;
HeadlessPluginSession? pluginSession = null;
try try
{ {
var gameplay = new HeadlessGameplayOperations(); var gameplay = new HeadlessGameplayOperations();
@ -297,6 +301,13 @@ internal sealed class HeadlessSessionHost : IDisposable
// descriptor.StatusFile is unset — every call site below stays // descriptor.StatusFile is unset — every call site below stays
// unconditional. // unconditional.
var statusWriter = new SessionStatusWriter(descriptor.StatusFile); var statusWriter = new SessionStatusWriter(descriptor.StatusFile);
pluginSession = HeadlessPluginSession.Create(
runtime,
diagnostics,
statusWriter,
descriptor.Id,
pluginRoots ?? [],
descriptor.Plugins);
var liveSession = new LiveSessionHost( var liveSession = new LiveSessionHost(
runtime.Session, runtime.Session,
new LiveSessionHostBindings( new LiveSessionHostBindings(
@ -402,9 +413,11 @@ internal sealed class HeadlessSessionHost : IDisposable
_hostLease = hostLease; _hostLease = hostLease;
_policy = policy; _policy = policy;
_policySubscription = policySubscription; _policySubscription = policySubscription;
_pluginSession = pluginSession;
} }
catch catch
{ {
pluginSession?.Dispose();
policySubscription?.Dispose(); policySubscription?.Dispose();
policy?.Dispose(); policy?.Dispose();
hostLease?.Dispose(); hostLease?.Dispose();
@ -427,6 +440,7 @@ internal sealed class HeadlessSessionHost : IDisposable
/// production code uses to reach the same state. /// production code uses to reach the same state.
/// </summary> /// </summary>
internal HeadlessCharacterOptionsSeeder? OptionsSeeder => _optionsSeeder; internal HeadlessCharacterOptionsSeeder? OptionsSeeder => _optionsSeeder;
internal HeadlessPluginSession Plugins => _pluginSession;
internal string SessionId => _descriptor.Id; internal string SessionId => _descriptor.Id;
internal string ActiveCharacterName { get; private set; } = internal string ActiveCharacterName { get; private set; } =
string.Empty; string.Empty;
@ -474,6 +488,7 @@ internal sealed class HeadlessSessionHost : IDisposable
// Campaign LA slice LA1: "started" = session host start — the // Campaign LA slice LA1: "started" = session host start — the
// earliest point this session actually attempts to connect. // earliest point this session actually attempts to connect.
_statusWriter.Started(_descriptor.Id); _statusWriter.Started(_descriptor.Id);
_pluginSession.Start();
RuntimeSessionStartResult result = RuntimeSessionStartResult result =
Commands.Session.Start(Runtime.Generation); Commands.Session.Start(Runtime.Generation);
_startOutcome = result.Status; _startOutcome = result.Status;
@ -638,22 +653,26 @@ internal sealed class HeadlessSessionHost : IDisposable
_disposeStage++; _disposeStage++;
break; break;
case 4: case 4:
_hostLease.Dispose(); _pluginSession.Dispose();
_disposeStage++; _disposeStage++;
break; break;
case 5: case 5:
_credential.Dispose(); _hostLease.Dispose();
_disposeStage++; _disposeStage++;
break; break;
case 6: case 6:
Runtime.Dispose(); _credential.Dispose();
_disposeStage++; _disposeStage++;
break; break;
case 7: case 7:
_contentLease?.Dispose(); Runtime.Dispose();
_disposeStage++; _disposeStage++;
break; break;
case 8: case 8:
_contentLease?.Dispose();
_disposeStage++;
break;
case 9:
_diagnostics.Message( _diagnostics.Message(
_descriptor.Id, _descriptor.Id,
"disposed", "disposed",

View file

@ -8,6 +8,9 @@ internal sealed record HeadlessPathSet(
string DataDirectory, string DataDirectory,
string CacheDirectory) string CacheDirectory)
{ {
internal string PluginsDirectory =>
Path.Combine(DataDirectory, "plugins");
internal static HeadlessPathSet Resolve( internal static HeadlessPathSet Resolve(
HeadlessPathOverrides overrides, HeadlessPathOverrides overrides,
IHeadlessPlatformEnvironment? platform = null) IHeadlessPlatformEnvironment? platform = null)

View file

@ -0,0 +1,265 @@
using AcDream.Plugin.Abstractions;
using AcDream.Runtime;
namespace AcDream.Headless.Plugins;
/// <summary>
/// No-window plugin surface over one exact <see cref="GameRuntime"/>. 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.
/// </summary>
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<Subscription> _subscriptions = [];
private Subscription[] _liveSnapshot = [];
private bool _disposed;
private readonly record struct ReplayEntity(
RuntimeEntityIdentity Identity,
WorldEntitySnapshot Snapshot);
private sealed class Subscription(Action<WorldEntitySnapshot> handler)
{
internal Action<WorldEntitySnapshot> Handler { get; } = handler;
internal Queue<ReplayEntity> Pending { get; } = new();
internal HashSet<RuntimeEntityIdentity> 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;
/// <summary>Test-only barrier invoked after the first replay item is
/// captured while Runtime's exact active-membership read lease is still
/// held.</summary>
internal Action? ReplayCapturedForTest { get; set; }
/// <summary>
/// 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.
/// </summary>
public IReadOnlyList<WorldEntitySnapshot> 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<WorldEntitySnapshot> 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<WorldEntitySnapshot> handler,
WorldEntitySnapshot snapshot)
{
try { handler(snapshot); }
catch { }
}
private sealed class SnapshotVisitor(
GameRuntime runtime,
Action? captureBarrier = null)
: IRuntimeEntityVisitor
{
private Action? _captureBarrier = captureBarrier;
internal List<ReplayEntity> 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();
}
}

View file

@ -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<ulong> _generation;
internal HeadlessPluginLogger(
HeadlessDiagnosticWriter diagnostics,
string sessionId,
Func<ulong> 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());
}

View file

@ -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;
/// <summary>
/// Headless composition wrapper that keeps plugin disable/unsubscribe/unload
/// ahead of canonical Runtime disposal.
/// </summary>
internal sealed class HeadlessPluginSession : IDisposable
{
private readonly HeadlessPluginHost _host;
private readonly PluginSession _plugins;
private readonly string[] _roots;
private readonly IReadOnlyList<string>? _allowList;
private int _disposeStage;
private bool _started;
private bool _disposed;
private HeadlessPluginSession(
HeadlessPluginHost host,
PluginSession plugins,
string[] roots,
IReadOnlyList<string>? allowList)
{
_host = host;
_plugins = plugins;
_roots = roots;
_allowList = allowList;
}
internal int LoadedCount => _plugins.LoadedCount;
internal HeadlessPluginHost Host => _host;
internal IReadOnlyList<WeakReference> CaptureLoadContextWeakReferences() =>
_plugins.CaptureLoadContextWeakReferences();
internal static HeadlessPluginSession Create(
GameRuntime runtime,
HeadlessDiagnosticWriter diagnostics,
SessionStatusWriter statusWriter,
string sessionId,
IEnumerable<string> roots,
IReadOnlyList<string>? 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");
}
}

View file

@ -132,7 +132,9 @@ public static class SessionConfigComposer
Character = selector, Character = selector,
Policy = policy, Policy = policy,
Credential = new SessionCredentialDescriptor(), 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 LoginCommands = character.LoginCommands.Count > 0
? [.. character.LoginCommands] ? [.. character.LoginCommands]
: null, : null,
@ -165,9 +167,9 @@ public static class SessionConfigComposer
/// §LA3 review finding F2): the session carries <c>mode: "probe"</c>, /// §LA3 review finding F2): the session carries <c>mode: "probe"</c>,
/// no <c>character</c> selector, and no <c>policy</c> — the host /// no <c>character</c> selector, and no <c>policy</c> — the host
/// reports the account's character roster over the status stream and /// reports the account's character roster over the status stream and
/// exits without entering the world. <c>plugins</c>/<c>loginCommands</c> /// exits without entering the world. Probes carry an explicit empty
/// don't apply to a probe and are always omitted, exactly like an /// <c>plugins</c> allow-list so a plugin installed on the machine cannot
/// empty configured set on a normal session. /// run merely because the probe has no character-level plugin settings.
/// </summary> /// </summary>
public static ComposedSessionConfig ComposeProbe( public static ComposedSessionConfig ComposeProbe(
ServerProfile server, ServerProfile server,
@ -197,7 +199,7 @@ public static class SessionConfigComposer
Character = null, Character = null,
Policy = null, Policy = null,
Credential = new SessionCredentialDescriptor(), Credential = new SessionCredentialDescriptor(),
Plugins = null, Plugins = [],
LoginCommands = null, LoginCommands = null,
LoginCommandDelayMs = null, LoginCommandDelayMs = null,
StatusFile = statusFilePath, StatusFile = statusFilePath,

View file

@ -98,8 +98,10 @@ public sealed class SessionDescriptor
public SessionCredentialDescriptor Credential { get; init; } = new(); public SessionCredentialDescriptor Credential { get; init; } = new();
/// <summary>Omitted (never an empty array) when the character has no /// <summary>Plugin allow-list. Omitted or JSON <c>null</c> means load all
/// configured plugin set.</summary> /// discovered plugins (the developer flow); an explicit empty array means
/// load none. Launcher-composed normal-empty and probe sessions therefore
/// emit <c>[]</c>.</summary>
public List<string>? Plugins { get; init; } public List<string>? Plugins { get; init; }
/// <summary>Omitted (never an empty array) when the character has no /// <summary>Omitted (never an empty array) when the character has no

View file

@ -7,6 +7,14 @@ namespace AcDream.Plugin.Abstractions;
/// </summary> /// </summary>
public interface IPluginHost public interface IPluginHost
{ {
/// <summary>
/// <see langword="true"/> when <see cref="Ui"/> registrations can be
/// projected by this host. No-window hosts return <see langword="false"/>
/// and expose <see cref="NoOpUiRegistry.Instance"/> so a plugin may keep
/// one code path while deliberately omitting presentation work.
/// </summary>
bool HasUi { get; }
IPluginLogger Log { get; } IPluginLogger Log { get; }
IGameState State { get; } IGameState State { get; }
IEvents Events { get; } IEvents Events { get; }

View file

@ -3,8 +3,10 @@ namespace AcDream.Plugin.Abstractions;
/// <summary> /// <summary>
/// Plugin-facing UI registration. A plugin ships a markup file (KSML-style) + /// Plugin-facing UI registration. A plugin ships a markup file (KSML-style) +
/// a binding object exposing the data properties the markup binds to, and /// a binding object exposing the data properties the markup binds to, and
/// registers it from <c>Enable()</c>. Calls made before the GL window opens are /// registers it from <c>Enable()</c>. Graphical hosts buffer registrations until
/// buffered and drained once the UI host exists. /// their retained UI exists. A host whose <see cref="IPluginHost.HasUi"/> is
/// <see langword="false"/> exposes <see cref="NoOpUiRegistry.Instance"/> and
/// intentionally discards registrations.
/// </summary> /// </summary>
public interface IUiRegistry public interface IUiRegistry
{ {
@ -12,3 +14,45 @@ public interface IUiRegistry
/// <param name="binding">Object whose properties the markup's {Bindings} resolve against.</param> /// <param name="binding">Object whose properties the markup's {Bindings} resolve against.</param>
void AddMarkupPanel(string markupPath, object binding); void AddMarkupPanel(string markupPath, object binding);
} }
/// <summary>
/// Host-infrastructure extension used to give each plugin a removable UI
/// registration lifetime. Plugins continue to call
/// <see cref="IUiRegistry.AddMarkupPanel"/>; 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.
/// </summary>
public interface IScopedUiRegistry : IUiRegistry
{
IDisposable RegisterMarkupPanel(string markupPath, object binding);
}
/// <summary>
/// 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.
/// </summary>
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()
{
}
}
}

View file

@ -16,6 +16,7 @@ public sealed class RuntimeEntityDirectory
public const uint LastLocalEntityId = 0x3FFF_FFFFu; public const uint LastLocalEntityId = 0x3FFF_FFFFu;
private readonly InboundPhysicsStateController _inbound = new(); private readonly InboundPhysicsStateController _inbound = new();
private readonly object _activeGate = new();
private readonly Dictionary<uint, RuntimeEntityRecord> _activeByGuid = new(); private readonly Dictionary<uint, RuntimeEntityRecord> _activeByGuid = new();
private readonly Dictionary<(uint Guid, ushort Incarnation), RuntimeEntityRecord> private readonly Dictionary<(uint Guid, ushort Incarnation), RuntimeEntityRecord>
_teardownByIncarnation = new(); _teardownByIncarnation = new();
@ -35,10 +36,27 @@ public sealed class RuntimeEntityDirectory
_nextLocalEntityId = firstLocalEntityId; _nextLocalEntityId = firstLocalEntityId;
} }
public int Count => _activeByGuid.Count; public int Count
{
get
{
lock (_activeGate)
return _activeByGuid.Count;
}
}
public int PendingTeardownCount => _teardownByIncarnation.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; } public ulong SessionLifetimeVersion { get; private set; }
/// <summary>Update-thread-only borrowed collection. Cross-thread hosts use
/// <see cref="AcquireActiveRead"/> through <c>IRuntimeEntityView.Visit</c>
/// so membership cannot change during enumeration.</summary>
public IReadOnlyCollection<RuntimeEntityRecord> ActiveRecords => _activeByGuid.Values; public IReadOnlyCollection<RuntimeEntityRecord> ActiveRecords => _activeByGuid.Values;
public IReadOnlyCollection<RuntimeEntityRecord> TeardownRecords => public IReadOnlyCollection<RuntimeEntityRecord> TeardownRecords =>
_teardownByIncarnation.Values; _teardownByIncarnation.Values;
@ -87,34 +105,48 @@ public sealed class RuntimeEntityDirectory
public RuntimeEntityRecord AddActive(WorldSession.EntitySpawn snapshot) public RuntimeEntityRecord AddActive(WorldSession.EntitySpawn snapshot)
{ {
if (_activeByGuid.ContainsKey(snapshot.Guid)) lock (_activeGate)
{ {
throw new InvalidOperationException( if (_activeByGuid.ContainsKey(snapshot.Guid))
$"Live entity 0x{snapshot.Guid:X8} already has an active incarnation."); {
} throw new InvalidOperationException(
$"Live entity 0x{snapshot.Guid:X8} already has an active incarnation.");
}
var record = new RuntimeEntityRecord(snapshot); var record = new RuntimeEntityRecord(snapshot);
_activeByGuid.Add(snapshot.Guid, record); _activeByGuid.Add(snapshot.Guid, record);
try try
{ {
ClaimLocalId(record); ClaimLocalId(record);
return record; return record;
} }
catch catch
{ {
_activeByGuid.Remove(snapshot.Guid); _activeByGuid.Remove(snapshot.Guid);
throw; throw;
}
} }
} }
public bool RemoveActive(uint guid, out RuntimeEntityRecord? record) => public bool RemoveActive(uint guid, out RuntimeEntityRecord? record)
_activeByGuid.Remove(guid, out record); {
lock (_activeGate)
return _activeByGuid.Remove(guid, out record);
}
public bool RemoveActive(RuntimeEntityRecord expected) public bool RemoveActive(RuntimeEntityRecord expected)
{ {
if (!IsCurrent(expected)) lock (_activeGate)
return false; {
return _activeByGuid.Remove(expected.ServerGuid); if (!_activeByGuid.TryGetValue(
expected.ServerGuid,
out RuntimeEntityRecord? current)
|| !ReferenceEquals(current, expected))
{
return false;
}
return _activeByGuid.Remove(expected.ServerGuid);
}
} }
public void RetainTeardown(RuntimeEntityRecord record) public void RetainTeardown(RuntimeEntityRecord record)
@ -157,46 +189,52 @@ public sealed class RuntimeEntityDirectory
public uint ClaimLocalId(RuntimeEntityRecord record) public uint ClaimLocalId(RuntimeEntityRecord record)
{ {
if (!IsKnown(record)) lock (_activeGate)
{ {
throw new InvalidOperationException( if (!IsKnown(record))
"A local id can only be claimed for an active or retained incarnation."); {
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) public bool ReleaseLocalId(RuntimeEntityRecord record)
{ {
if (record.LocalEntityId is not { } localId) lock (_activeGate)
return false;
if (_byLocalId.TryGetValue(localId, out RuntimeEntityRecord? retained)
&& ReferenceEquals(retained, record))
{ {
_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; record.LocalEntityId = null;
return true; return true;
}
} }
public ulong AdvanceLifetimeMutation(uint serverGuid) public ulong AdvanceLifetimeMutation(uint serverGuid)
@ -219,15 +257,39 @@ public sealed class RuntimeEntityDirectory
public bool CompleteSessionClearIfConverged() public bool CompleteSessionClearIfConverged()
{ {
if (_activeByGuid.Count != 0 || _teardownByIncarnation.Count != 0) lock (_activeGate)
return false; {
if (_activeByGuid.Count != 0 || _teardownByIncarnation.Count != 0)
return false;
_byLocalId.Clear(); _byLocalId.Clear();
}
ParentAttachments.Clear(); ParentAttachments.Clear();
_inbound.Clear(); _inbound.Clear();
return true; return true;
} }
/// <summary>
/// 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.
/// </summary>
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( public void RefreshSnapshot(
RuntimeEntityRecord record, RuntimeEntityRecord record,
WorldSession.EntitySpawn accepted, WorldSession.EntitySpawn accepted,

View file

@ -91,6 +91,8 @@ internal sealed class RuntimeEntityObjectViews
uint serverGuid, uint serverGuid,
out RuntimeEntitySnapshot entity) out RuntimeEntitySnapshot entity)
{ {
using RuntimeEntityDirectory.ActiveReadLease lease =
owner.AcquireActiveRead();
if (owner.TryGetActive( if (owner.TryGetActive(
serverGuid, serverGuid,
out RuntimeEntityRecord record)) out RuntimeEntityRecord record))
@ -106,6 +108,8 @@ internal sealed class RuntimeEntityObjectViews
public void Visit(IRuntimeEntityVisitor visitor) public void Visit(IRuntimeEntityVisitor visitor)
{ {
ArgumentNullException.ThrowIfNull(visitor); ArgumentNullException.ThrowIfNull(visitor);
using RuntimeEntityDirectory.ActiveReadLease lease =
owner.AcquireActiveRead();
foreach (RuntimeEntityRecord record in owner.ActiveRecords) foreach (RuntimeEntityRecord record in owner.ActiveRecords)
{ {
RuntimeEntitySnapshot entity = Snapshot(record); RuntimeEntitySnapshot entity = Snapshot(record);

View file

@ -13,9 +13,9 @@ namespace AcDream.Runtime.Session;
/// is a single shared-stdout JSONL diagnostics stream with no per-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 /// file; this class writes one file per session, meant to be read by an
/// external process (the launcher) rather than scraped from console output. /// external process (the launcher) rather than scraped from console output.
/// Event shapes are versioned (<c>"v":1</c>) so a future event kind /// Event shapes are versioned (<c>"v":1</c>); LA5's
/// (<c>pluginLoaded</c>/<c>pluginFailed</c>, LA5) can be added without /// <c>pluginLoaded</c>/<c>pluginFailed</c> additions use that same envelope
/// breaking an existing reader. /// without breaking an existing reader.
/// </para> /// </para>
/// ///
/// <para> /// <para>
@ -29,9 +29,11 @@ namespace AcDream.Runtime.Session;
/// </para> /// </para>
/// ///
/// <para> /// <para>
/// <strong>Never write credential material into this stream.</strong> Every /// <strong>Never write credential material into this stream.</strong> LA5's
/// event method below takes only identifiers, names, and counts — there is no /// <see cref="PluginFailed"/> diagnostic is caller-supplied text, so hosts may
/// parameter shape that could carry a password, by construction. /// pass only the plugin lifecycle failure and must never append session
/// credentials or other secrets. Neither plugin host exposes credentials
/// through <c>IPluginHost</c>.
/// </para> /// </para>
/// ///
/// <para> /// <para>
@ -205,6 +207,27 @@ public sealed class SessionStatusWriter
characterName, 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) public void Disconnected(string sessionId, string reason)
{ {
if (!IsEnabled) if (!IsEnabled)

View file

@ -25,6 +25,15 @@
<ProjectReference Include="..\..\src\AcDream.Launcher.Core\AcDream.Launcher.Core.csproj" /> <ProjectReference Include="..\..\src\AcDream.Launcher.Core\AcDream.Launcher.Core.csproj" />
</ItemGroup> </ItemGroup>
<ItemGroup>
<!-- Build ordering only. Tests copy this DLL into a temporary plugin root
and the production loader loads it through a collectible ALC. -->
<ProjectReference Include="..\AcDream.Plugin.Tests.Fixtures.HostPlugin\AcDream.Plugin.Tests.Fixtures.HostPlugin.csproj">
<ReferenceOutputAssembly>false</ReferenceOutputAssembly>
<SkipGetTargetFrameworkProperties>true</SkipGetTargetFrameworkProperties>
</ProjectReference>
</ItemGroup>
<ItemGroup> <ItemGroup>
<Compile Include="..\Fixtures\campaign-la\LauncherCoreSessionConfigFixture.cs" <Compile Include="..\Fixtures\campaign-la\LauncherCoreSessionConfigFixture.cs"
Link="Fixtures\LauncherCoreSessionConfigFixture.cs" /> Link="Fixtures\LauncherCoreSessionConfigFixture.cs" />

View file

@ -56,6 +56,18 @@ public sealed class SessionConfigurationSharedFixtureTests
StringComparison.Ordinal); 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] [Fact]
public void AppReaderAcceptsTheProductionShapedSharedFixture() public void AppReaderAcceptsTheProductionShapedSharedFixture()
{ {

View file

@ -1,4 +1,5 @@
using AcDream.App.Plugins; using AcDream.App.Plugins;
using AcDream.App.UI;
namespace AcDream.App.Tests.Plugins; namespace AcDream.App.Tests.Plugins;
@ -18,4 +19,26 @@ public class BufferedUiRegistryTests
Assert.Empty(reg.Drain()); // consumed 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);
}
} }

View file

@ -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<JsonElement> 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<string> 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);
}
}
}
}
}

View file

@ -447,6 +447,11 @@ public sealed class GameWindowSlice8BoundaryTests
public void Shutdown_PreservesDependencyStagesAndNativeWindowLast() public void Shutdown_PreservesDependencyStagesAndNativeWindowLast()
{ {
string source = GameWindowSource(); string source = GameWindowSource();
string program = File.ReadAllText(Path.Combine(
FindRepoRoot(),
"src",
"AcDream.App",
"Program.cs"));
string lifetime = GameWindowLifetimeSource(); string lifetime = GameWindowLifetimeSource();
string manifest = Slice( string manifest = Slice(
lifetime, lifetime,
@ -464,6 +469,7 @@ public sealed class GameWindowSlice8BoundaryTests
[ [
"new ResourceShutdownStage(\"host and session barriers\"", "new ResourceShutdownStage(\"host and session barriers\"",
"new ResourceShutdownStage(\"physical ingress cleanup\"", "new ResourceShutdownStage(\"physical ingress cleanup\"",
"new ResourceShutdownStage(\"plugin host\"",
"new ResourceShutdownStage(\"frame borrowers\"", "new ResourceShutdownStage(\"frame borrowers\"",
"new ResourceShutdownStage(\"session dependents\"", "new ResourceShutdownStage(\"session dependents\"",
"new ResourceShutdownStage(\"live entities\"", "new ResourceShutdownStage(\"live entities\"",
@ -499,6 +505,8 @@ public sealed class GameWindowSlice8BoundaryTests
"Soft(\"gameplay actions\", () => DisposeGameplayActions(ingress.GameplayActions))", "Soft(\"gameplay actions\", () => DisposeGameplayActions(ingress.GameplayActions))",
"Soft(\"camera pointer\", () => DisposeCameraPointer(ingress.CameraPointer))", "Soft(\"camera pointer\", () => DisposeCameraPointer(ingress.CameraPointer))",
"Soft(\"native window callbacks\", () => DisposeWindowCallbacks(ingress.WindowCallbacks))", "Soft(\"native window callbacks\", () => DisposeWindowCallbacks(ingress.WindowCallbacks))",
"new ResourceShutdownStage(\"plugin host\"",
"Hard(\"plugins\", () => ingress.Plugins?.Dispose())",
"new ResourceShutdownStage(\"session dependents\"", "new ResourceShutdownStage(\"session dependents\"",
"Hard(\"mouse capture\", () => live.CameraPointer?.ReleaseMouseLookAfterSessionRetirement())"); "Hard(\"mouse capture\", () => live.CameraPointer?.ReleaseMouseLookAfterSessionRetirement())");
Assert.Contains( Assert.Contains(
@ -509,7 +517,27 @@ public sealed class GameWindowSlice8BoundaryTests
"UiHost? RetainedUiHost,", "UiHost? RetainedUiHost,",
lifetime, lifetime,
StringComparison.Ordinal); StringComparison.Ordinal);
Assert.Contains(
"IDisposable? Plugins,",
lifetime,
StringComparison.Ordinal);
Assert.Contains("_uiHost,", source, 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( AssertAppearsInOrder(
nativeRelease, nativeRelease,
"TryComplete();", "TryComplete();",

View file

@ -28,6 +28,7 @@ public class PluginLoaderTests
private sealed class StubHost : IPluginHost private sealed class StubHost : IPluginHost
{ {
public bool HasUi => true;
public IPluginLogger Log { get; } = new StubLogger(); public IPluginLogger Log { get; } = new StubLogger();
public IGameState State { get; } = new StubState(); public IGameState State { get; } = new StubState();
public IEvents Events { get; } = new StubEvents(); public IEvents Events { get; } = new StubEvents();
@ -84,6 +85,8 @@ public class PluginLoaderTests
Assert.True(loaded.Success); Assert.True(loaded.Success);
Assert.NotNull(loaded.Plugin); Assert.NotNull(loaded.Plugin);
Assert.Equal("HelloPlugin", loaded.Plugin!.GetType().Name); Assert.Equal("HelloPlugin", loaded.Plugin!.GetType().Name);
loaded.Plugin.Disable();
loaded.LoadContext!.Unload();
} }
[Fact] [Fact]
@ -122,5 +125,7 @@ public class PluginLoaderTests
Assert.False(loaded.Success); Assert.False(loaded.Success);
Assert.Contains("IAcDreamPlugin", loaded.Error!.Message); Assert.Contains("IAcDreamPlugin", loaded.Error!.Message);
Assert.NotNull(loaded.LoadContext);
loaded.LoadContext!.Unload();
} }
} }

View file

@ -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<PluginSessionStatus>();
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<PluginSessionStatus>();
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<PluginSessionStatus>();
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<WeakReference> 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<WorldEntitySnapshot> Entities => [];
}
private sealed class StubEvents : IEvents
{
public event Action<WorldEntitySnapshot> 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);
}
}
}

View file

@ -24,6 +24,15 @@
<ProjectReference Include="..\..\src\AcDream.Launcher.Core\AcDream.Launcher.Core.csproj" /> <ProjectReference Include="..\..\src\AcDream.Launcher.Core\AcDream.Launcher.Core.csproj" />
</ItemGroup> </ItemGroup>
<ItemGroup>
<!-- Build ordering only. Tests copy this DLL into a temporary plugin root
and the production loader loads it through a collectible ALC. -->
<ProjectReference Include="..\AcDream.Plugin.Tests.Fixtures.HostPlugin\AcDream.Plugin.Tests.Fixtures.HostPlugin.csproj">
<ReferenceOutputAssembly>false</ReferenceOutputAssembly>
<SkipGetTargetFrameworkProperties>true</SkipGetTargetFrameworkProperties>
</ProjectReference>
</ItemGroup>
<ItemGroup> <ItemGroup>
<Compile Include="..\Fixtures\campaign-la\LauncherCoreSessionConfigFixture.cs" <Compile Include="..\Fixtures\campaign-la\LauncherCoreSessionConfigFixture.cs"
Link="Fixtures\LauncherCoreSessionConfigFixture.cs" /> Link="Fixtures\LauncherCoreSessionConfigFixture.cs" />

View file

@ -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<uint>();
Action<WorldEntitySnapshot> 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<string> 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<JsonElement> 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);
}
}
}
}
}

View file

@ -60,6 +60,33 @@ public sealed class SessionConfigurationSharedFixtureTests
StringComparison.Ordinal); 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] [Fact]
public void HeadlessReaderAcceptsTheProductionShapedSharedFixture() public void HeadlessReaderAcceptsTheProductionShapedSharedFixture()
{ {

View file

@ -175,7 +175,7 @@ public sealed class SessionConfigComposerTests
} }
[Fact] [Fact]
public void PluginsAndLoginCommandsAreOmittedWhenEmptyRatherThanEmptyArrays() public void EmptyPluginsRemainAnExplicitLoadNoneAllowListWhileLoginCommandsAreOmitted()
{ {
CharacterProfile character = Character(LaunchMode.Gui); CharacterProfile character = Character(LaunchMode.Gui);
character.Plugins = []; character.Plugins = [];
@ -190,7 +190,8 @@ public sealed class SessionConfigComposerTests
sessionId: "session-empty-lists"); sessionId: "session-empty-lists");
JsonObject session = SingleSession(composed); JsonObject session = SingleSession(composed);
Assert.False(session.ContainsKey("plugins")); Assert.True(session.ContainsKey("plugins"));
Assert.Empty(session["plugins"]!.AsArray());
Assert.False(session.ContainsKey("loginCommands")); Assert.False(session.ContainsKey("loginCommands"));
} }
@ -243,7 +244,7 @@ public sealed class SessionConfigComposerTests
} }
[Fact] [Fact]
public void ProbeModeSetsModeAndOmitsCharacterPolicyPluginsAndLoginCommands() public void ProbeModeSetsModeAndCarriesExplicitLoadNonePluginAllowList()
{ {
ComposedSessionConfig composed = SessionConfigComposer.ComposeProbe( ComposedSessionConfig composed = SessionConfigComposer.ComposeProbe(
Server(), Server(),
@ -256,7 +257,7 @@ public sealed class SessionConfigComposerTests
AssertKeys( AssertKeys(
session, session,
"id", "mode", "endpoint", "account", "credential", "statusFile"); "id", "mode", "endpoint", "account", "credential", "plugins", "statusFile");
Assert.Equal("session-probe", (string?)session["id"]); Assert.Equal("session-probe", (string?)session["id"]);
Assert.Equal("probe", (string?)session["mode"]); Assert.Equal("probe", (string?)session["mode"]);
@ -266,7 +267,7 @@ public sealed class SessionConfigComposerTests
Assert.Equal("standardInput", (string?)session["credential"]!["provider"]); Assert.Equal("standardInput", (string?)session["credential"]!["provider"]);
Assert.False(session.ContainsKey("character")); Assert.False(session.ContainsKey("character"));
Assert.False(session.ContainsKey("policy")); Assert.False(session.ContainsKey("policy"));
Assert.False(session.ContainsKey("plugins")); Assert.Empty(session["plugins"]!.AsArray());
Assert.False(session.ContainsKey("loginCommands")); Assert.False(session.ContainsKey("loginCommands"));
Assert.False(session.ContainsKey("loginCommandDelayMs")); Assert.False(session.ContainsKey("loginCommandDelayMs"));
Assert.Equal( Assert.Equal(

View file

@ -0,0 +1,19 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<LangVersion>latest</LangVersion>
<IsPackable>false</IsPackable>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
</PropertyGroup>
<ItemGroup>
<!-- The host owns this contract assembly. Keeping it out of the fixture's
output is required for IAcDreamPlugin type identity in the collectible
load context. -->
<ProjectReference Include="..\..\src\AcDream.Plugin.Abstractions\AcDream.Plugin.Abstractions.csproj">
<Private>false</Private>
<ExcludeAssets>runtime</ExcludeAssets>
</ProjectReference>
</ItemGroup>
</Project>

View file

@ -0,0 +1,130 @@
using AcDream.Plugin.Abstractions;
using System.Runtime.Loader;
namespace AcDream.Plugin.Tests.Fixtures.HostPlugin;
/// <summary>
/// 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.
/// </summary>
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;
}
}
}

View file

@ -30,11 +30,13 @@ public sealed class SessionStatusWriterTests
new LiveSessionRosterEntry(0x50000002u, "Grey", 10u), new LiveSessionRosterEntry(0x50000002u, "Grey", 10u),
])); ]));
writer.EnteredWorld("s1", 0x50000001u, "Ready"); writer.EnteredWorld("s1", 0x50000001u, "Ready");
writer.PluginLoaded("s1", "acdream.good");
writer.PluginFailed("s1", "acdream.bad", "enable failed");
writer.Disconnected("s1", "stopped"); writer.Disconnected("s1", "stopped");
writer.Exited("s1", 0, "disposed"); writer.Exited("s1", 0, "disposed");
string[] lines = File.ReadAllLines(file.Path); string[] lines = File.ReadAllLines(file.Path);
Assert.Equal(6, lines.Length); Assert.Equal(8, lines.Length);
JsonElement started = Parse(lines[0]); JsonElement started = Parse(lines[0]);
Assert.Equal(1, started.GetProperty("v").GetInt32()); Assert.Equal(1, started.GetProperty("v").GetInt32());
@ -62,11 +64,20 @@ public sealed class SessionStatusWriterTests
Assert.Equal(0x50000001u, enteredWorld.GetProperty("characterId").GetUInt32()); Assert.Equal(0x50000001u, enteredWorld.GetProperty("characterId").GetUInt32());
Assert.Equal("Ready", enteredWorld.GetProperty("characterName").GetString()); 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("disconnected", disconnected.GetProperty("e").GetString());
Assert.Equal("stopped", disconnected.GetProperty("reason").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("exited", exited.GetProperty("e").GetString());
Assert.Equal(0, exited.GetProperty("code").GetInt32()); Assert.Equal(0, exited.GetProperty("code").GetInt32());
Assert.Equal("disposed", exited.GetProperty("reason").GetString()); Assert.Equal("disposed", exited.GetProperty("reason").GetString());
@ -80,6 +91,8 @@ public sealed class SessionStatusWriterTests
writer.Started("s1"); writer.Started("s1");
writer.Connected("s1"); writer.Connected("s1");
writer.PluginLoaded("s1", "acdream.good");
writer.PluginFailed("s1", "acdream.bad", "failed");
writer.Disconnected("s1", "stopped"); writer.Disconnected("s1", "stopped");
writer.Exited("s1", 0, "disposed"); writer.Exited("s1", 0, "disposed");
@ -164,9 +177,10 @@ public sealed class SessionStatusWriterTests
/// credential material into this stream" contract: each event kind /// credential material into this stream" contract: each event kind
/// serializes EXACTLY its pinned property set — the shared envelope /// serializes EXACTLY its pinned property set — the shared envelope
/// (<c>v</c>/<c>e</c>/<c>t</c>/<c>sessionId</c>) plus that event's own /// (<c>v</c>/<c>e</c>/<c>t</c>/<c>sessionId</c>) plus that event's own
/// named fields, nothing else. An extra property (a smuggled password, /// named fields, nothing else. An extra credential-shaped or otherwise
/// or any other accidental field) fails this test by construction, /// accidental property fails this test by construction. LA5's documented
/// regardless of what value it carries. /// <c>pluginFailed.error</c> diagnostic is the one free-text value and its
/// caller remains responsible for never appending session secrets.
/// </summary> /// </summary>
[Fact] [Fact]
public void EachEventSerializesExactlyItsPinnedPropertySetAndNothingElse() public void EachEventSerializesExactlyItsPinnedPropertySetAndNothingElse()
@ -183,11 +197,13 @@ public sealed class SessionStatusWriterTests
11, 11,
[new LiveSessionRosterEntry(0x50000001u, "Ready", 0u)])); [new LiveSessionRosterEntry(0x50000001u, "Ready", 0u)]));
writer.EnteredWorld("bot", 0x50000001u, "Ready"); writer.EnteredWorld("bot", 0x50000001u, "Ready");
writer.PluginLoaded("bot", "acdream.good");
writer.PluginFailed("bot", "acdream.bad", "enable failed");
writer.Disconnected("bot", "stopped"); writer.Disconnected("bot", "stopped");
writer.Exited("bot", 0, "disposed"); writer.Exited("bot", 0, "disposed");
string[] lines = File.ReadAllLines(file.Path); 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[0], "v", "e", "t", "sessionId");
AssertExactProperties(lines[1], "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"); "v", "e", "t", "sessionId", "accountName", "slotCount", "characters");
AssertExactProperties( AssertExactProperties(
lines[3], "v", "e", "t", "sessionId", "characterId", "characterName"); lines[3], "v", "e", "t", "sessionId", "characterId", "characterName");
AssertExactProperties(lines[4], "v", "e", "t", "sessionId", "reason"); AssertExactProperties(lines[4], "v", "e", "t", "sessionId", "plugin");
AssertExactProperties(lines[5], "v", "e", "t", "sessionId", "code", "reason"); 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 // The nested characters[] entries are exact too — the exact shape a
// password could otherwise be smuggled through. // password could otherwise be smuggled through.

View file

@ -55,4 +55,66 @@ internal static class LauncherCoreSessionConfigFixture
return SessionConfigComposer.Serialize(composed.Document); 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));
} }