feat(render): implement Campaign AR and terrain fidelity

This commit is contained in:
Erik 2026-08-22 13:13:29 +02:00
parent 99cf26e00c
commit 7a5f96ede5
368 changed files with 50611 additions and 950 deletions

View file

@ -1,14 +1,16 @@
using System.Runtime.Loader;
using AcDream.Plugin.Abstractions;
using AcDream.Plugin.Abstractions.Rendering;
namespace AcDream.Core.Plugins;
/// <summary>
/// Outcome of a plugin load attempt.
/// <para>On success, <see cref="Plugin"/> is the instantiated plugin, <see cref="LoadContext"/>
/// owns its assembly, and <see cref="Error"/> is null.</para>
/// <para>On success, at least one of <see cref="Plugin"/> or
/// <see cref="RenderPackPlugin"/> is instantiated, <see cref="LoadContext"/>
/// owns the assembly, and <see cref="Error"/> is null.</para>
/// <para>On failure, <see cref="Error"/> describes what went wrong. A partial
/// <see cref="Plugin"/> and/or <see cref="LoadContext"/> may still be present;
/// entry-point instances 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>
@ -16,8 +18,11 @@ public sealed record LoadedPlugin(
PluginManifest Manifest,
IAcDreamPlugin? Plugin,
AssemblyLoadContext? LoadContext,
Exception? Error)
Exception? Error,
IRenderPackPlugin? RenderPackPlugin = null)
{
public bool Success =>
Plugin is not null && LoadContext is not null && Error is null;
(Plugin is not null || RenderPackPlugin is not null)
&& LoadContext is not null
&& Error is null;
}

View file

@ -1,5 +1,6 @@
using System.Reflection;
using AcDream.Plugin.Abstractions;
using AcDream.Plugin.Abstractions.Rendering;
namespace AcDream.Core.Plugins;
@ -7,14 +8,19 @@ public static class PluginLoader
{
/// <summary>
/// Load a plugin DLL from <paramref name="pluginDirectory"/> into a collectible
/// <see cref="System.Runtime.Loader.AssemblyLoadContext"/>, find the first type
/// implementing <see cref="IAcDreamPlugin"/>, instantiate it, and call its
/// <see cref="IAcDreamPlugin.Initialize"/> with the supplied host. Any failure
/// is returned as a failed <see cref="LoadedPlugin"/> rather than thrown.
/// <see cref="System.Runtime.Loader.AssemblyLoadContext"/>, resolve the gameplay
/// and/or render-pack entry points declared by its manifest, and invoke only
/// the facilities this host supplied. Any failure 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.
/// requests unload because the caller must close host and render-pack
/// registrations first.
/// </summary>
public static LoadedPlugin Load(string pluginDirectory, PluginManifest manifest, IPluginHost host)
public static LoadedPlugin Load(
string pluginDirectory,
PluginManifest manifest,
IPluginHost host,
IRenderPackRegistry? renderPacks = null)
{
ArgumentException.ThrowIfNullOrWhiteSpace(pluginDirectory);
ArgumentNullException.ThrowIfNull(manifest);
@ -45,6 +51,7 @@ public static class PluginLoader
PluginAssemblyLoadContext? alc = null;
IAcDreamPlugin? instance = null;
IRenderPackPlugin? renderPackInstance = null;
try
{
alc = new PluginAssemblyLoadContext(pluginDirectory, dllPath);
@ -60,10 +67,51 @@ public static class PluginLoader
types = rtle.Types.OfType<Type>();
}
var pluginType = types
.FirstOrDefault(t => !t.IsAbstract && typeof(IAcDreamPlugin).IsAssignableFrom(t));
Type[] concreteTypes = types
.Where(static type => !type.IsAbstract && !type.IsInterface)
.ToArray();
Type? pluginType = manifest.Declares(PluginKind.Gameplay)
? concreteTypes.FirstOrDefault(
static type => typeof(IAcDreamPlugin).IsAssignableFrom(type))
: null;
bool registerRenderPack =
manifest.Declares(PluginKind.RenderPack) && renderPacks is not null;
CountingRenderPackRegistry? countedRenderPacks = registerRenderPack
? new CountingRenderPackRegistry(renderPacks!)
: null;
Type? renderPackType = null;
if (registerRenderPack)
{
Type[] renderPackTypes = concreteTypes
.Where(static type => typeof(IRenderPackPlugin).IsAssignableFrom(type))
.ToArray();
if (renderPackTypes.Length != 1)
{
return new LoadedPlugin(
manifest,
Plugin: null,
LoadContext: alc,
Error: new InvalidOperationException(
$"render-pack entry DLL '{manifest.EntryDll}' must contain exactly "
+ "one IRenderPackPlugin implementation; found "
+ renderPackTypes.Length));
}
if (pluginType is null)
renderPackType = renderPackTypes[0];
if (!renderPackType.IsVisible
|| renderPackType.GetConstructor(Type.EmptyTypes) is null)
{
return new LoadedPlugin(
manifest,
Plugin: null,
LoadContext: alc,
Error: new InvalidOperationException(
$"render-pack entry type '{renderPackType.FullName}' must be public, "
+ "non-abstract, and expose a public parameterless constructor"));
}
}
if (manifest.Declares(PluginKind.Gameplay) && pluginType is null)
{
return new LoadedPlugin(
manifest,
@ -73,9 +121,58 @@ public static class PluginLoader
$"no IAcDreamPlugin implementation found in {manifest.EntryDll}"));
}
instance = (IAcDreamPlugin)Activator.CreateInstance(pluginType)!;
instance.Initialize(host);
return new LoadedPlugin(manifest, instance, alc, Error: null);
if (registerRenderPack && renderPackType is null)
{
return new LoadedPlugin(
manifest,
Plugin: null,
LoadContext: alc,
Error: new InvalidOperationException(
$"no IRenderPackPlugin implementation found in {manifest.EntryDll}"));
}
if (pluginType is null && renderPackType is null)
{
return new LoadedPlugin(
manifest,
Plugin: null,
LoadContext: alc,
Error: new InvalidOperationException(
"the host did not supply any facility declared by this plugin"));
}
object? sharedInstance = null;
if (pluginType is not null)
{
sharedInstance = Activator.CreateInstance(pluginType);
instance = (IAcDreamPlugin?)sharedInstance
?? throw new InvalidOperationException(
$"could not construct IAcDreamPlugin {pluginType.FullName}");
instance.Initialize(host);
}
if (renderPackType is not null)
{
object renderObject = ReferenceEquals(renderPackType, pluginType)
? sharedInstance!
: Activator.CreateInstance(renderPackType)
?? throw new InvalidOperationException(
$"could not construct IRenderPackPlugin {renderPackType.FullName}");
renderPackInstance = (IRenderPackPlugin)renderObject;
renderPackInstance.Register(countedRenderPacks!);
if (countedRenderPacks!.RegistrationCount == 0)
{
throw new InvalidOperationException(
$"render-pack entry point '{renderPackType.FullName}' registered no packs");
}
}
return new LoadedPlugin(
manifest,
instance,
alc,
Error: null,
renderPackInstance);
}
catch (Exception ex)
{
@ -87,7 +184,45 @@ public static class PluginLoader
manifest,
Plugin: instance,
LoadContext: alc,
Error: ex);
Error: ex,
RenderPackPlugin: renderPackInstance);
}
}
private sealed class CountingRenderPackRegistry(IRenderPackRegistry inner) :
IRenderPackRegistry
{
private int _registrationCount;
internal int RegistrationCount => Volatile.Read(ref _registrationCount);
public IDisposable Register(
RenderPackDescriptor descriptor,
IRenderPackAssets assets)
{
IDisposable registration = inner.Register(descriptor, assets)
?? throw new InvalidOperationException(
"The render-pack registry returned a null registration handle.");
Interlocked.Increment(ref _registrationCount);
return new CountedRegistration(this, registration);
}
private sealed class CountedRegistration(
CountingRenderPackRegistry owner,
IDisposable inner) : IDisposable
{
private CountingRenderPackRegistry? _owner = owner;
private IDisposable? _inner = inner;
public void Dispose()
{
CountingRenderPackRegistry? activeOwner =
Interlocked.Exchange(ref _owner, null);
if (activeOwner is null)
return;
Interlocked.Decrement(ref activeOwner._registrationCount);
Interlocked.Exchange(ref _inner, null)?.Dispose();
}
}
}
}

View file

@ -2,6 +2,13 @@ using System.Text.Json;
namespace AcDream.Core.Plugins;
/// <summary>Host facility an entry assembly declares in <c>plugin.json</c>.</summary>
public enum PluginKind
{
Gameplay,
RenderPack,
}
public sealed record PluginManifest(
string Id,
string DisplayName,
@ -10,6 +17,32 @@ public sealed record PluginManifest(
int ApiVersion,
IReadOnlyList<string> Dependencies)
{
/// <summary>
/// Declared entry-point kinds. Old manifests omit <c>kinds</c> and remain
/// gameplay plugins, preserving the pre-render-pack loading contract.
/// </summary>
public IReadOnlyList<PluginKind> Kinds { get; init; } = [PluginKind.Gameplay];
public PluginManifest(
string Id,
string DisplayName,
string Version,
string EntryDll,
int ApiVersion,
IReadOnlyList<string> Dependencies,
IReadOnlyList<PluginKind> Kinds)
: this(Id, DisplayName, Version, EntryDll, ApiVersion, Dependencies)
{
ArgumentNullException.ThrowIfNull(Kinds);
if (Kinds.Count == 0)
throw new ArgumentException("At least one plugin kind is required.", nameof(Kinds));
this.Kinds = Kinds
.Distinct()
.ToArray();
}
public bool Declares(PluginKind kind) => Kinds.Contains(kind);
public static PluginManifest Parse(string json)
{
PluginManifestDto? dto;
@ -32,13 +65,40 @@ public sealed record PluginManifest(
if (dto.ApiVersion <= 0)
throw new PluginManifestException("apiVersion must be >= 1");
IReadOnlyList<PluginKind> kinds = ParseKinds(dto.Kinds);
return new PluginManifest(
dto.Id!,
dto.DisplayName!,
dto.Version!,
dto.EntryDll!,
dto.ApiVersion,
dto.Dependencies ?? Array.Empty<string>());
dto.Dependencies ?? Array.Empty<string>(),
kinds);
}
private static IReadOnlyList<PluginKind> ParseKinds(IReadOnlyList<string>? values)
{
if (values is null)
return [PluginKind.Gameplay];
if (values.Count == 0)
throw new PluginManifestException("kinds must contain at least one entry");
var kinds = new List<PluginKind>(values.Count);
foreach (string? value in values)
{
if (string.IsNullOrWhiteSpace(value)
|| !Enum.TryParse(value, ignoreCase: true, out PluginKind kind)
|| !Enum.IsDefined(kind))
{
throw new PluginManifestException(
$"unknown plugin kind: {value ?? "<null>"}");
}
if (!kinds.Contains(kind))
kinds.Add(kind);
}
return kinds;
}
private static void Require(string? value, string jsonFieldName)
@ -61,6 +121,7 @@ public sealed record PluginManifest(
public string? EntryDll { get; set; }
public int ApiVersion { get; set; }
public IReadOnlyList<string>? Dependencies { get; set; }
public IReadOnlyList<string>? Kinds { get; set; }
}
}

View file

@ -1,4 +1,5 @@
using AcDream.Plugin.Abstractions;
using AcDream.Plugin.Abstractions.Rendering;
namespace AcDream.Core.Plugins;
@ -17,16 +18,25 @@ public readonly record struct PluginSessionStatus(
PluginSessionStatusKind Kind,
string? Error = null);
/// <summary>A configured plugin declares no entry point usable by this host.</summary>
public sealed class PluginHostKindException : Exception
{
public PluginHostKindException(string message) : base(message) { }
}
/// <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.
/// initialize/enable, declarative render-pack registration, 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. Unsupported kinds are filtered before assembly loading.
/// </summary>
public sealed class PluginSession : IDisposable
{
private readonly IPluginHost _host;
private readonly Action<PluginSessionStatus>? _report;
private readonly IRenderPackRegistry? _renderPacks;
private readonly HashSet<PluginKind> _supportedKinds;
private readonly List<ActivePlugin> _loaded = [];
private readonly List<WeakReference> _releasedContexts = [];
private bool _started;
@ -34,10 +44,28 @@ public sealed class PluginSession : IDisposable
public PluginSession(
IPluginHost host,
Action<PluginSessionStatus>? report = null)
Action<PluginSessionStatus>? report = null,
IRenderPackRegistry? renderPacks = null,
IEnumerable<PluginKind>? supportedKinds = null)
{
_host = host ?? throw new ArgumentNullException(nameof(host));
_report = report;
_renderPacks = renderPacks;
_supportedKinds = new HashSet<PluginKind>(
supportedKinds
?? (renderPacks is null
? [PluginKind.Gameplay]
: [PluginKind.Gameplay, PluginKind.RenderPack]));
if (_supportedKinds.Count == 0)
throw new ArgumentException(
"At least one supported plugin kind is required.",
nameof(supportedKinds));
if (_supportedKinds.Contains(PluginKind.RenderPack) && renderPacks is null)
{
throw new ArgumentException(
"A host that supports render-pack plugins must supply a render-pack registry.",
nameof(renderPacks));
}
}
public int LoadedCount => _loaded.Count;
@ -122,6 +150,25 @@ public sealed class PluginSession : IDisposable
string id = result.Manifest!.Id;
if (requestedSet is not null && !requestedSet.Contains(id))
continue;
if (!result.Manifest.Kinds.Any(_supportedKinds.Contains))
{
// A shared plugin root may contain graphical-only packs.
// An unfiltered/headless scan omits those silently. An
// explicitly requested id gets one precise host-kind
// failure, still without loading its assembly.
if (requestedSet is not null)
{
AddOrdered(discoveredOrder, id);
AddError(
errors,
id,
new PluginHostKindException(
$"plugin '{id}' declares only "
+ $"{string.Join(", ", result.Manifest.Kinds)} entry points, "
+ "which this host does not support."));
}
continue;
}
AddOrdered(discoveredOrder, id);
if (!candidates.TryGetValue(id, out List<PluginDiscoveryResult>? list))
{
@ -160,23 +207,27 @@ public sealed class PluginSession : IDisposable
{
ActivePlugin active = _loaded[index];
LoadedPlugin loaded = active.Loaded;
try
if (loaded.Plugin is not null)
{
loaded.Plugin!.Disable();
}
catch (Exception error)
{
SafeLog(
static (log, message, exception) =>
log.Error(message, exception),
$"plugin disable failed: {loaded.Manifest.Id}",
error);
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();
ReleaseRenderScope(active.RenderPackScope, loaded.Manifest.Id);
try
{
@ -208,16 +259,23 @@ public sealed class PluginSession : IDisposable
foreach (PluginDiscoveryResult candidate in available)
{
var scope = new ScopedPluginHost(_host);
ScopedRenderPackRegistry? renderPackScope =
candidate.Manifest!.Declares(PluginKind.RenderPack)
&& _renderPacks is not null
? new ScopedRenderPackRegistry(_renderPacks)
: null;
LoadedPlugin loaded = PluginLoader.Load(
candidate.PluginDirectory,
candidate.Manifest!,
scope);
candidate.Manifest,
scope,
renderPackScope);
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();
ReleaseRenderScope(renderPackScope, candidate.Manifest.Id);
ReleaseFailedLoad(loaded);
AddError(
errors,
@ -229,8 +287,8 @@ public sealed class PluginSession : IDisposable
try
{
loaded.Plugin!.Enable();
_loaded.Add(new ActivePlugin(loaded, scope));
loaded.Plugin?.Enable();
_loaded.Add(new ActivePlugin(loaded, scope, renderPackScope));
SafeLog(
static (log, message, _) => log.Info(message),
$"plugin loaded: {loaded.Manifest.Id} "
@ -244,7 +302,7 @@ public sealed class PluginSession : IDisposable
catch (Exception error)
{
AddError(errors, id, error);
ReleaseFailedEnable(loaded, scope);
ReleaseFailedEnable(loaded, scope, renderPackScope);
}
}
}
@ -274,22 +332,27 @@ public sealed class PluginSession : IDisposable
private void ReleaseFailedEnable(
LoadedPlugin loaded,
ScopedPluginHost scope)
ScopedPluginHost scope,
ScopedRenderPackRegistry? renderPackScope)
{
try
if (loaded.Plugin is not null)
{
loaded.Plugin!.Disable();
}
catch (Exception error)
{
SafeLog(
static (log, message, exception) =>
log.Error(message, exception),
$"plugin cleanup after enable failure failed: {loaded.Manifest.Id}",
error);
try
{
loaded.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();
ReleaseRenderScope(renderPackScope, loaded.Manifest.Id);
_releasedContexts.Add(new WeakReference(loaded.LoadContext!));
try
@ -342,6 +405,26 @@ public sealed class PluginSession : IDisposable
}
}
private void ReleaseRenderScope(
ScopedRenderPackRegistry? scope,
string pluginId)
{
if (scope is null)
return;
try
{
scope.Dispose();
}
catch (Exception error)
{
SafeLog(
static (log, message, exception) =>
log.Error(message, exception),
$"render-pack registration cleanup failed: {pluginId}",
error);
}
}
private void Report(PluginSessionStatus status)
{
if (_report is null)
@ -417,5 +500,6 @@ public sealed class PluginSession : IDisposable
private sealed record ActivePlugin(
LoadedPlugin Loaded,
ScopedPluginHost Scope);
ScopedPluginHost Scope,
ScopedRenderPackRegistry? RenderPackScope);
}

View file

@ -0,0 +1,96 @@
using AcDream.Plugin.Abstractions.Rendering;
namespace AcDream.Core.Plugins;
/// <summary>
/// Per-plugin render-pack registration transaction. Every successful catalog
/// registration is withdrawn before the plugin's collectible load context is
/// released, including partial Register failures.
/// </summary>
internal sealed class ScopedRenderPackRegistry : IRenderPackRegistry, IDisposable
{
private readonly IRenderPackRegistry _inner;
private readonly object _gate = new();
private readonly List<RegistrationHandle> _registrations = [];
private bool _disposed;
internal ScopedRenderPackRegistry(IRenderPackRegistry inner) =>
_inner = inner ?? throw new ArgumentNullException(nameof(inner));
public IDisposable Register(
RenderPackDescriptor descriptor,
IRenderPackAssets assets)
{
ArgumentNullException.ThrowIfNull(descriptor);
ArgumentNullException.ThrowIfNull(assets);
lock (_gate)
ObjectDisposedException.ThrowIf(_disposed, this);
IDisposable innerRegistration = _inner.Register(descriptor, assets)
?? throw new InvalidOperationException(
"The render-pack registry returned a null registration handle.");
var registration = new RegistrationHandle(this, innerRegistration);
lock (_gate)
{
if (!_disposed)
{
_registrations.Add(registration);
return registration;
}
}
registration.Dispose();
throw new ObjectDisposedException(nameof(ScopedRenderPackRegistry));
}
public void Dispose()
{
RegistrationHandle[] registrations;
lock (_gate)
{
if (_disposed)
return;
_disposed = true;
registrations = _registrations.ToArray();
_registrations.Clear();
}
List<Exception>? failures = null;
for (int index = registrations.Length - 1; index >= 0; index--)
{
try { registrations[index].Dispose(); }
catch (Exception error) { (failures ??= []).Add(error); }
}
if (failures is not null)
{
throw new AggregateException(
"One or more render-pack registrations could not be withdrawn.",
failures);
}
}
private void Release(RegistrationHandle registration)
{
lock (_gate)
_registrations.Remove(registration);
registration.DisposeInner();
}
private sealed class RegistrationHandle(
ScopedRenderPackRegistry owner,
IDisposable inner) : IDisposable
{
private ScopedRenderPackRegistry? _owner = owner;
private IDisposable? _inner = inner;
public void Dispose()
{
Interlocked.Exchange(ref _owner, null)?.Release(this);
}
internal void DisposeInner() =>
Interlocked.Exchange(ref _inner, null)?.Dispose();
}
}