feat(render): implement Campaign AR and terrain fidelity
This commit is contained in:
parent
99cf26e00c
commit
7a5f96ede5
368 changed files with 50611 additions and 950 deletions
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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; }
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
96
src/AcDream.Core/Plugins/ScopedRenderPackRegistry.cs
Normal file
96
src/AcDream.Core/Plugins/ScopedRenderPackRegistry.cs
Normal 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();
|
||||
}
|
||||
}
|
||||
|
|
@ -53,6 +53,15 @@ public sealed class TranslucencyFadeManager
|
|||
// frames keep reading the settled value.
|
||||
private readonly Dictionary<uint, Dictionary<uint, float>> _committed = new();
|
||||
|
||||
private ulong _revision = 1;
|
||||
|
||||
/// <summary>
|
||||
/// Advances whenever the committed per-part opacity topology changes.
|
||||
/// Render products may use this to retain classification while no fade
|
||||
/// state changes, and to rebuild exact caster membership when it does.
|
||||
/// </summary>
|
||||
public ulong Revision => _revision;
|
||||
|
||||
/// <summary>
|
||||
/// Start (or replace) a translucency ramp for one Setup part of one
|
||||
/// entity. Mirrors <c>CPhysicsObj::SetPartTranslucency</c>: a
|
||||
|
|
@ -151,8 +160,10 @@ public sealed class TranslucencyFadeManager
|
|||
/// <summary>Drop all fade state for an entity (despawn / unload).</summary>
|
||||
public void ClearEntity(uint entityId)
|
||||
{
|
||||
_activeFades.Remove(entityId);
|
||||
_committed.Remove(entityId);
|
||||
bool changed = _activeFades.Remove(entityId);
|
||||
changed |= _committed.Remove(entityId);
|
||||
if (changed)
|
||||
AdvanceRevision();
|
||||
}
|
||||
|
||||
private void Commit(uint entityId, uint partIndex, float value)
|
||||
|
|
@ -162,6 +173,26 @@ public sealed class TranslucencyFadeManager
|
|||
parts = new Dictionary<uint, float>();
|
||||
_committed[entityId] = parts;
|
||||
}
|
||||
|
||||
if (parts.TryGetValue(partIndex, out float prior)
|
||||
&& BitConverter.SingleToInt32Bits(prior)
|
||||
== BitConverter.SingleToInt32Bits(value))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
parts[partIndex] = value;
|
||||
AdvanceRevision();
|
||||
}
|
||||
|
||||
private void AdvanceRevision()
|
||||
{
|
||||
if (_revision == ulong.MaxValue)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
"Translucency fade revision space was exhausted.");
|
||||
}
|
||||
|
||||
_revision++;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -56,27 +56,21 @@ public static class LandblockMesh
|
|||
throw new ArgumentException("heightTable must have 256 entries", nameof(heightTable));
|
||||
|
||||
// Pre-sample all 81 heights into a 2D array (x-major indexing). This
|
||||
// doubles as the source for per-vertex normals via central differences
|
||||
// (Phase 3b lighting, preserved through the per-cell refactor).
|
||||
// is also the source for retail's topology-aware vertex normals.
|
||||
var heights = new float[HeightmapSide, HeightmapSide];
|
||||
for (int x = 0; x < HeightmapSide; x++)
|
||||
for (int y = 0; y < HeightmapSide; y++)
|
||||
heights[x, y] = heightTable[block.Height[x * HeightmapSide + y]];
|
||||
|
||||
// Pre-compute all 81 vertex normals so the inner cell loop is a pure
|
||||
// lookup. Central differences on the heightmap → smooth normal field.
|
||||
var normals = new Vector3[HeightmapSide, HeightmapSide];
|
||||
for (int x = 0; x < HeightmapSide; x++)
|
||||
for (int y = 0; y < HeightmapSide; y++)
|
||||
{
|
||||
int xL = Math.Max(x - 1, 0);
|
||||
int xR = Math.Min(x + 1, HeightmapSide - 1);
|
||||
int yD = Math.Max(y - 1, 0);
|
||||
int yU = Math.Min(y + 1, HeightmapSide - 1);
|
||||
float dx = (heights[xR, y] - heights[xL, y]) / ((xR - xL) * CellSize);
|
||||
float dy = (heights[x, yU] - heights[x, yD]) / ((yU - yD) * CellSize);
|
||||
normals[x, y] = Vector3.Normalize(new Vector3(-dx, -dy, 1f));
|
||||
}
|
||||
// Retail CLandBlockStruct::calc_lighting accumulates the normalized
|
||||
// plane normal of every incident terrain polygon at each of the 81
|
||||
// shared height-sample vertices, then normalizes the sum. Use the same
|
||||
// split hash and triangle topology as the emitted mesh; this changes
|
||||
// lighting only, never positions, indices, or the collision surface.
|
||||
var normals = BuildRetailVertexNormals(
|
||||
heights,
|
||||
landblockX,
|
||||
landblockY);
|
||||
|
||||
var vertices = new TerrainVertex[VerticesPerLandblock];
|
||||
var indices = new uint[VerticesPerLandblock]; // 1 index per vertex (no deduplication)
|
||||
|
|
@ -173,6 +167,85 @@ public static class LandblockMesh
|
|||
return new LandblockMeshData(vertices, indices);
|
||||
}
|
||||
|
||||
private static Vector3[,] BuildRetailVertexNormals(
|
||||
float[,] heights,
|
||||
uint landblockX,
|
||||
uint landblockY)
|
||||
{
|
||||
var normalSums = new Vector3[HeightmapSide, HeightmapSide];
|
||||
|
||||
for (int cy = 0; cy < CellsPerSide; cy++)
|
||||
{
|
||||
for (int cx = 0; cx < CellsPerSide; cx++)
|
||||
{
|
||||
var posBL = new Vector3( cx * CellSize, cy * CellSize, heights[cx, cy ]);
|
||||
var posBR = new Vector3((cx + 1) * CellSize, cy * CellSize, heights[cx + 1, cy ]);
|
||||
var posTR = new Vector3((cx + 1) * CellSize, (cy + 1) * CellSize, heights[cx + 1, cy + 1]);
|
||||
var posTL = new Vector3( cx * CellSize, (cy + 1) * CellSize, heights[cx, cy + 1]);
|
||||
|
||||
var split = TerrainBlending.CalculateSplitDirection(
|
||||
landblockX, (uint)cx, landblockY, (uint)cy);
|
||||
|
||||
if (split == CellSplitDirection.SWtoNE)
|
||||
{
|
||||
AccumulateFaceNormal(
|
||||
normalSums,
|
||||
posBL, cx, cy,
|
||||
posBR, cx + 1, cy,
|
||||
posTR, cx + 1, cy + 1);
|
||||
AccumulateFaceNormal(
|
||||
normalSums,
|
||||
posBL, cx, cy,
|
||||
posTR, cx + 1, cy + 1,
|
||||
posTL, cx, cy + 1);
|
||||
}
|
||||
else
|
||||
{
|
||||
AccumulateFaceNormal(
|
||||
normalSums,
|
||||
posBL, cx, cy,
|
||||
posBR, cx + 1, cy,
|
||||
posTL, cx, cy + 1);
|
||||
AccumulateFaceNormal(
|
||||
normalSums,
|
||||
posBR, cx + 1, cy,
|
||||
posTR, cx + 1, cy + 1,
|
||||
posTL, cx, cy + 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var normals = new Vector3[HeightmapSide, HeightmapSide];
|
||||
for (int x = 0; x < HeightmapSide; x++)
|
||||
{
|
||||
for (int y = 0; y < HeightmapSide; y++)
|
||||
{
|
||||
Vector3 sum = normalSums[x, y];
|
||||
normals[x, y] = sum.LengthSquared() > 0f
|
||||
? Vector3.Normalize(sum)
|
||||
: Vector3.UnitZ;
|
||||
}
|
||||
}
|
||||
|
||||
return normals;
|
||||
}
|
||||
|
||||
private static void AccumulateFaceNormal(
|
||||
Vector3[,] normalSums,
|
||||
Vector3 p0, int x0, int y0,
|
||||
Vector3 p1, int x1, int y1,
|
||||
Vector3 p2, int x2, int y2)
|
||||
{
|
||||
Vector3 cross = Vector3.Cross(p1 - p0, p2 - p0);
|
||||
if (cross.LengthSquared() <= 0f)
|
||||
return;
|
||||
|
||||
Vector3 faceNormal = Vector3.Normalize(cross);
|
||||
normalSums[x0, y0] += faceNormal;
|
||||
normalSums[x1, y1] += faceNormal;
|
||||
normalSums[x2, y2] += faceNormal;
|
||||
}
|
||||
|
||||
private static void WriteCell(
|
||||
TerrainVertex[] verts, ref int vi,
|
||||
uint d0, uint d1, uint d2, uint d3,
|
||||
|
|
|
|||
|
|
@ -11,11 +11,11 @@ namespace AcDream.Core.Terrain;
|
|||
/// which of the 4 cell corners a given vertex represents from
|
||||
/// <c>gl_VertexID % 6</c> plus the split direction bit.
|
||||
///
|
||||
/// Normal is stored per vertex via Phase 3b's central-difference scheme on
|
||||
/// the 9×9 heightmap — this lets the fragment shader interpolate a smooth
|
||||
/// normal across triangles (softer than WorldBuilder's <c>dFdx</c>/<c>dFdy</c>
|
||||
/// flat-shaded approach). UVs are derived from the corner index in the
|
||||
/// vertex shader — not stored here.
|
||||
/// Normal is stored per vertex using retail's terrain-lighting rule: each
|
||||
/// shared height-sample vertex receives the normalized plane normals of its
|
||||
/// incident, split-aware terrain triangles and normalizes their sum. The
|
||||
/// fragment shader interpolates that smooth result across triangles. UVs are
|
||||
/// derived from the corner index in the vertex shader — not stored here.
|
||||
///
|
||||
/// Size: 12 (position) + 12 (normal) + 4*4 (Data0..3) = 40 bytes.
|
||||
/// </summary>
|
||||
|
|
|
|||
|
|
@ -38,6 +38,14 @@ public sealed class SkyObjectData
|
|||
public uint PesObjectId;
|
||||
public uint Properties;
|
||||
|
||||
/// <summary>
|
||||
/// Source GfxObj sort centre. Celestial billboards are authored at their
|
||||
/// apparent direction from the camera, so transforming and normalizing
|
||||
/// this point yields the exact direction rendered by the sky pass. Zero
|
||||
/// means the optional DAT lookup was unavailable.
|
||||
/// </summary>
|
||||
public Vector3 AuthoredSortCenter;
|
||||
|
||||
/// <summary>
|
||||
/// True when this SkyObject is gated on the weather system (Properties
|
||||
/// bit <c>0x04</c>). Per the named retail decomp,
|
||||
|
|
@ -140,6 +148,9 @@ public sealed class SkyObjectReplaceData
|
|||
public float Transparent;
|
||||
public float Luminosity;
|
||||
public float MaxBright;
|
||||
|
||||
/// <summary>Sort centre for a replacement <see cref="GfxObjId"/>.</summary>
|
||||
public Vector3 AuthoredSortCenter;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
|
@ -334,7 +345,7 @@ public static class SkyDescLoader
|
|||
ArgumentNullException.ThrowIfNull(dats);
|
||||
var region = dats.Get<Region>(RegionDatId);
|
||||
if (region is null) return null;
|
||||
return LoadFromRegion(region);
|
||||
return LoadFromRegion(region, dats);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
|
@ -352,7 +363,9 @@ public static class SkyDescLoader
|
|||
/// GfxObjReplace swap pattern.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public static LoadedSkyDesc? LoadFromRegion(Region region)
|
||||
public static LoadedSkyDesc? LoadFromRegion(
|
||||
Region region,
|
||||
IDatObjectSource? dats = null)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(region);
|
||||
if (!region.PartsMask.HasFlag(PartsMask.HasSkyInfo) || region.SkyInfo is null)
|
||||
|
|
@ -367,8 +380,12 @@ public static class SkyDescLoader
|
|||
|
||||
foreach (var dg in sky.DayGroups)
|
||||
{
|
||||
var objs = dg.SkyObjects.Select(ConvertSkyObject).ToList();
|
||||
var times = dg.SkyTime.Select(ConvertTimeOfDay).ToList();
|
||||
var objs = dg.SkyObjects
|
||||
.Select(value => ConvertSkyObject(value, dats))
|
||||
.ToList();
|
||||
var times = dg.SkyTime
|
||||
.Select(value => ConvertTimeOfDay(value, dats))
|
||||
.ToList();
|
||||
|
||||
dayGroups.Add(new DayGroupData
|
||||
{
|
||||
|
|
@ -495,7 +512,9 @@ public static class SkyDescLoader
|
|||
Console.WriteLine("[sky-dump] ======== END SkyDesc dump ========");
|
||||
}
|
||||
|
||||
private static SkyObjectData ConvertSkyObject(SkyObject s) => new()
|
||||
private static SkyObjectData ConvertSkyObject(
|
||||
SkyObject s,
|
||||
IDatObjectSource? dats) => new()
|
||||
{
|
||||
BeginTime = s.BeginTime,
|
||||
EndTime = s.EndTime,
|
||||
|
|
@ -506,9 +525,14 @@ public static class SkyDescLoader
|
|||
GfxObjId = s.DefaultGfxObjectId?.DataId ?? 0u,
|
||||
PesObjectId = s.DefaultPesObjectId?.DataId ?? 0u,
|
||||
Properties = s.Properties,
|
||||
AuthoredSortCenter = ResolveSortCenter(
|
||||
s.DefaultGfxObjectId?.DataId ?? 0u,
|
||||
dats),
|
||||
};
|
||||
|
||||
private static DatSkyKeyframeData ConvertTimeOfDay(SkyTimeOfDay s)
|
||||
private static DatSkyKeyframeData ConvertTimeOfDay(
|
||||
SkyTimeOfDay s,
|
||||
IDatObjectSource? dats)
|
||||
{
|
||||
// Transparent / Luminosity / MaxBright are stored in the retail
|
||||
// Region dat as PERCENTAGES (0..100), not fractions (0..1). Our
|
||||
|
|
@ -537,6 +561,9 @@ public static class SkyDescLoader
|
|||
Transparent = r.Transparent / 100f,
|
||||
Luminosity = r.Luminosity / 100f,
|
||||
MaxBright = r.MaxBright / 100f,
|
||||
AuthoredSortCenter = ResolveSortCenter(
|
||||
r.GfxObjId?.DataId ?? 0u,
|
||||
dats),
|
||||
}).ToList();
|
||||
|
||||
var fogMode = s.WorldFog switch
|
||||
|
|
@ -577,6 +604,26 @@ public static class SkyDescLoader
|
|||
};
|
||||
}
|
||||
|
||||
private static Vector3 ResolveSortCenter(
|
||||
uint gfxObjId,
|
||||
IDatObjectSource? dats)
|
||||
{
|
||||
if (dats is null || (gfxObjId & 0xFF000000u) != 0x01000000u)
|
||||
return Vector3.Zero;
|
||||
|
||||
try
|
||||
{
|
||||
return dats.TryGet<GfxObj>(gfxObjId, out var gfx) && gfx is not null
|
||||
? gfx.SortCenter
|
||||
: Vector3.Zero;
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Enhancement metadata cannot make authoritative sky loading fail.
|
||||
return Vector3.Zero;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// <see cref="ColorARGB"/> stores bytes as B,G,R,A — but the logical
|
||||
/// channel mapping is just "R/G/B in 0..255". Convert to linear
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue