refactor(app): compose content effects and audio startup
Move Phase-2 startup behind typed composition/publication boundaries, replace the GameWindow-capturing PhysicsScript gate with a focused deferred source, and own animation-hook registrations reversibly. Make OpenAL construction and teardown transactional so every device, context, source, and buffer prefix is retryable without replay.
This commit is contained in:
parent
1d51e35c14
commit
60a1698ce7
12 changed files with 1901 additions and 152 deletions
|
|
@ -52,11 +52,10 @@ namespace AcDream.App.Audio;
|
||||||
public sealed unsafe class OpenAlAudioEngine : IAudioEngine
|
public sealed unsafe class OpenAlAudioEngine : IAudioEngine
|
||||||
{
|
{
|
||||||
// ── Backends ─────────────────────────────────────────────────────────────
|
// ── Backends ─────────────────────────────────────────────────────────────
|
||||||
private readonly ALContext? _alc;
|
private AL? _al;
|
||||||
private readonly AL? _al;
|
private OpenAlResourceLifetime? _resources;
|
||||||
private readonly Device* _device;
|
private bool _available;
|
||||||
private readonly Context* _context;
|
private bool _disposed;
|
||||||
private readonly bool _available;
|
|
||||||
|
|
||||||
// ── Pools ────────────────────────────────────────────────────────────────
|
// ── Pools ────────────────────────────────────────────────────────────────
|
||||||
private const int PoolSize3D = 16; // retail 16-slot voice pool
|
private const int PoolSize3D = 16; // retail 16-slot voice pool
|
||||||
|
|
@ -91,44 +90,48 @@ public sealed unsafe class OpenAlAudioEngine : IAudioEngine
|
||||||
public bool IsAvailable => _available;
|
public bool IsAvailable => _available;
|
||||||
|
|
||||||
public OpenAlAudioEngine()
|
public OpenAlAudioEngine()
|
||||||
|
: this(new SilkOpenAlResourceApiFactory())
|
||||||
{
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
internal OpenAlAudioEngine(IOpenAlResourceApiFactory apiFactory)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(apiFactory);
|
||||||
|
IOpenAlResourceApi api;
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
_alc = ALContext.GetApi(soft: true);
|
api = apiFactory.Create();
|
||||||
_al = AL.GetApi(soft: true);
|
}
|
||||||
_device = _alc.OpenDevice(string.Empty);
|
catch
|
||||||
if (_device == null)
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_al = api.AudioApi;
|
||||||
|
_resources = new OpenAlResourceLifetime(api);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (!_resources.TryOpenDevice())
|
||||||
{
|
{
|
||||||
_available = false;
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
_context = _alc.CreateContext(_device, null);
|
if (!_resources.TryCreateContext())
|
||||||
if (_context == null)
|
|
||||||
{
|
{
|
||||||
_alc.CloseDevice(_device);
|
DisableAfterInitializationFailure(
|
||||||
_device = null;
|
new InvalidOperationException("OpenAL could not create a context."));
|
||||||
_available = false;
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!_alc.MakeContextCurrent(_context))
|
if (!_resources.TryMakeCurrent())
|
||||||
{
|
{
|
||||||
_alc.DestroyContext(_context);
|
DisableAfterInitializationFailure(
|
||||||
_context = null;
|
new InvalidOperationException("OpenAL could not activate its context."));
|
||||||
_alc.CloseDevice(_device);
|
|
||||||
_device = null;
|
|
||||||
_available = false;
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Initialise 3D source pool.
|
// Initialise 3D source pool.
|
||||||
for (int i = 0; i < PoolSize3D; i++)
|
for (int i = 0; i < PoolSize3D; i++)
|
||||||
{
|
{
|
||||||
uint src = _al.GenSource();
|
uint src = _resources.Create3DSource();
|
||||||
_al.SetSourceProperty(src, SourceFloat.Gain, 1f);
|
|
||||||
_al.SetSourceProperty(src, SourceFloat.MaxDistance, 1000f);
|
|
||||||
_al.SetSourceProperty(src, SourceFloat.RolloffFactor, 1f);
|
|
||||||
_al.SetSourceProperty(src, SourceFloat.ReferenceDistance, 2f);
|
|
||||||
_al.SetSourceProperty(src, SourceBoolean.Looping, false);
|
|
||||||
_pool3D[i] = new Slot3D { SourceId = src, InUse = false };
|
_pool3D[i] = new Slot3D { SourceId = src, InUse = false };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -136,61 +139,57 @@ public sealed unsafe class OpenAlAudioEngine : IAudioEngine
|
||||||
// ignore 3D position.
|
// ignore 3D position.
|
||||||
for (int i = 0; i < PoolSizeUi; i++)
|
for (int i = 0; i < PoolSizeUi; i++)
|
||||||
{
|
{
|
||||||
uint src = _al.GenSource();
|
uint src = _resources.CreateUiSource();
|
||||||
_al.SetSourceProperty(src, SourceBoolean.SourceRelative, true);
|
|
||||||
_al.SetSourceProperty(src, SourceFloat.Gain, 1f);
|
|
||||||
_al.SetSourceProperty(src, SourceBoolean.Looping, false);
|
|
||||||
_poolUi[i] = src;
|
_poolUi[i] = src;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Global distance model = inverse-square clamped (classic retail feel).
|
// Global distance model = inverse-square clamped (classic retail feel).
|
||||||
_al.DistanceModel(DistanceModel.InverseDistanceClamped);
|
api.SelectRetailDistanceModel();
|
||||||
|
|
||||||
_available = true;
|
_available = true;
|
||||||
}
|
}
|
||||||
catch
|
catch (OpenAlInitializationException)
|
||||||
{
|
{
|
||||||
// OpenAL driver unavailable (headless CI, missing libopenal).
|
throw;
|
||||||
_available = false;
|
}
|
||||||
|
catch (Exception failure)
|
||||||
|
{
|
||||||
|
DisableAfterInitializationFailure(failure);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public void Dispose()
|
public void Dispose()
|
||||||
{
|
{
|
||||||
if (!_available || _al is null) return;
|
if (_disposed)
|
||||||
|
return;
|
||||||
|
|
||||||
|
_available = false;
|
||||||
|
_resources?.RetryCleanup();
|
||||||
|
_disposed = _resources is null || _resources.IsCleanupComplete;
|
||||||
|
}
|
||||||
|
|
||||||
|
internal bool IsDisposalComplete =>
|
||||||
|
_disposed || _resources is null || _resources.IsCleanupComplete;
|
||||||
|
|
||||||
|
private void DisableAfterInitializationFailure(Exception failure)
|
||||||
|
{
|
||||||
|
_available = false;
|
||||||
|
if (_resources is null)
|
||||||
|
return;
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
for (int i = 0; i < PoolSize3D; i++)
|
_resources.RetryCleanup();
|
||||||
{
|
|
||||||
var slot = _pool3D[i];
|
|
||||||
if (slot is null) continue;
|
|
||||||
_al.SourceStop(slot.SourceId);
|
|
||||||
_al.DeleteSource(slot.SourceId);
|
|
||||||
}
|
|
||||||
for (int i = 0; i < PoolSizeUi; i++)
|
|
||||||
{
|
|
||||||
_al.SourceStop(_poolUi[i]);
|
|
||||||
_al.DeleteSource(_poolUi[i]);
|
|
||||||
}
|
|
||||||
foreach (var kv in _ambientSources)
|
|
||||||
{
|
|
||||||
_al.SourceStop(kv.Value);
|
|
||||||
_al.DeleteSource(kv.Value);
|
|
||||||
}
|
|
||||||
foreach (var buf in _bufferByWaveId.Values)
|
|
||||||
{
|
|
||||||
_al.DeleteBuffer(buf);
|
|
||||||
}
|
|
||||||
if (_context != null && _alc is not null)
|
|
||||||
{
|
|
||||||
_alc.MakeContextCurrent(null);
|
|
||||||
_alc.DestroyContext(_context);
|
|
||||||
}
|
|
||||||
if (_device != null && _alc is not null)
|
|
||||||
_alc.CloseDevice(_device);
|
|
||||||
}
|
}
|
||||||
catch { /* shutdown — ignore */ }
|
catch (AggregateException cleanupFailure)
|
||||||
|
{
|
||||||
|
throw new OpenAlInitializationException(
|
||||||
|
failure,
|
||||||
|
_resources,
|
||||||
|
cleanupFailure);
|
||||||
|
}
|
||||||
|
|
||||||
|
_al = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── IAudioEngine ─────────────────────────────────────────────────────────
|
// ── IAudioEngine ─────────────────────────────────────────────────────────
|
||||||
|
|
@ -339,10 +338,11 @@ public sealed unsafe class OpenAlAudioEngine : IAudioEngine
|
||||||
if (_bufferByWaveId.TryGetValue(waveId, out var existing)) return existing;
|
if (_bufferByWaveId.TryGetValue(waveId, out var existing)) return existing;
|
||||||
|
|
||||||
uint buf = _al.GenBuffer();
|
uint buf = _al.GenBuffer();
|
||||||
|
_resources!.OwnBuffer(buf);
|
||||||
BufferFormat fmt = PickFormat(wave);
|
BufferFormat fmt = PickFormat(wave);
|
||||||
if (fmt == 0)
|
if (fmt == 0)
|
||||||
{
|
{
|
||||||
_al.DeleteBuffer(buf);
|
_resources.ReleaseBuffer(buf);
|
||||||
_bufferByWaveId[waveId] = 0;
|
_bufferByWaveId[waveId] = 0;
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
335
src/AcDream.App/Audio/OpenAlResourceLifetime.cs
Normal file
335
src/AcDream.App/Audio/OpenAlResourceLifetime.cs
Normal file
|
|
@ -0,0 +1,335 @@
|
||||||
|
using AcDream.App.Rendering;
|
||||||
|
using Silk.NET.OpenAL;
|
||||||
|
|
||||||
|
namespace AcDream.App.Audio;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Narrow native-resource surface used by the OpenAL construction transaction.
|
||||||
|
/// Runtime playback still calls Silk's typed <see cref="AL"/> API directly.
|
||||||
|
/// </summary>
|
||||||
|
internal interface IOpenAlResourceApi
|
||||||
|
{
|
||||||
|
AL? AudioApi { get; }
|
||||||
|
ALContext? ContextApi { get; }
|
||||||
|
|
||||||
|
nint OpenDevice();
|
||||||
|
nint CreateContext(nint device);
|
||||||
|
bool MakeContextCurrent(nint context);
|
||||||
|
uint GenerateSource();
|
||||||
|
void Configure3DSource(uint source);
|
||||||
|
void ConfigureUiSource(uint source);
|
||||||
|
void SelectRetailDistanceModel();
|
||||||
|
void StopSource(uint source);
|
||||||
|
void DeleteSource(uint source);
|
||||||
|
void DeleteBuffer(uint buffer);
|
||||||
|
void DestroyContext(nint context);
|
||||||
|
void CloseDevice(nint device);
|
||||||
|
}
|
||||||
|
|
||||||
|
internal interface IOpenAlResourceApiFactory
|
||||||
|
{
|
||||||
|
IOpenAlResourceApi Create();
|
||||||
|
}
|
||||||
|
|
||||||
|
internal sealed class SilkOpenAlResourceApiFactory : IOpenAlResourceApiFactory
|
||||||
|
{
|
||||||
|
public IOpenAlResourceApi Create() => new SilkOpenAlResourceApi();
|
||||||
|
}
|
||||||
|
|
||||||
|
internal sealed unsafe class SilkOpenAlResourceApi : IOpenAlResourceApi
|
||||||
|
{
|
||||||
|
public SilkOpenAlResourceApi()
|
||||||
|
{
|
||||||
|
ContextApi = ALContext.GetApi(soft: true);
|
||||||
|
AudioApi = AL.GetApi(soft: true);
|
||||||
|
}
|
||||||
|
|
||||||
|
public AL AudioApi { get; }
|
||||||
|
public ALContext ContextApi { get; }
|
||||||
|
|
||||||
|
public nint OpenDevice() => (nint)ContextApi.OpenDevice(string.Empty);
|
||||||
|
|
||||||
|
public nint CreateContext(nint device) =>
|
||||||
|
(nint)ContextApi.CreateContext((Device*)device, null);
|
||||||
|
|
||||||
|
public bool MakeContextCurrent(nint context) =>
|
||||||
|
ContextApi.MakeContextCurrent((Context*)context);
|
||||||
|
|
||||||
|
public uint GenerateSource() => AudioApi.GenSource();
|
||||||
|
|
||||||
|
public void Configure3DSource(uint source)
|
||||||
|
{
|
||||||
|
AudioApi.SetSourceProperty(source, SourceFloat.Gain, 1f);
|
||||||
|
AudioApi.SetSourceProperty(source, SourceFloat.MaxDistance, 1000f);
|
||||||
|
AudioApi.SetSourceProperty(source, SourceFloat.RolloffFactor, 1f);
|
||||||
|
AudioApi.SetSourceProperty(source, SourceFloat.ReferenceDistance, 2f);
|
||||||
|
AudioApi.SetSourceProperty(source, SourceBoolean.Looping, false);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void ConfigureUiSource(uint source)
|
||||||
|
{
|
||||||
|
AudioApi.SetSourceProperty(source, SourceBoolean.SourceRelative, true);
|
||||||
|
AudioApi.SetSourceProperty(source, SourceFloat.Gain, 1f);
|
||||||
|
AudioApi.SetSourceProperty(source, SourceBoolean.Looping, false);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void SelectRetailDistanceModel() =>
|
||||||
|
AudioApi.DistanceModel(DistanceModel.InverseDistanceClamped);
|
||||||
|
|
||||||
|
public void StopSource(uint source) => AudioApi.SourceStop(source);
|
||||||
|
|
||||||
|
public void DeleteSource(uint source) => AudioApi.DeleteSource(source);
|
||||||
|
|
||||||
|
public void DeleteBuffer(uint buffer) => AudioApi.DeleteBuffer(buffer);
|
||||||
|
|
||||||
|
public void DestroyContext(nint context) =>
|
||||||
|
ContextApi.DestroyContext((Context*)context);
|
||||||
|
|
||||||
|
public void CloseDevice(nint device) =>
|
||||||
|
ContextApi.CloseDevice((Device*)device);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Owns every native handle immediately after acquisition. Cleanup is
|
||||||
|
/// all-attempted, reverse dependency ordered, retryable, and never replays a
|
||||||
|
/// successful release.
|
||||||
|
/// </summary>
|
||||||
|
internal sealed class OpenAlResourceLifetime : IRetryableResourceCleanup
|
||||||
|
{
|
||||||
|
private sealed class SourceState(uint id)
|
||||||
|
{
|
||||||
|
public uint Id { get; } = id;
|
||||||
|
public bool Released { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class BufferState(uint id)
|
||||||
|
{
|
||||||
|
public uint Id { get; } = id;
|
||||||
|
public bool Released { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
private readonly IOpenAlResourceApi _api;
|
||||||
|
private readonly List<SourceState> _sources = [];
|
||||||
|
private readonly List<BufferState> _buffers = [];
|
||||||
|
private nint _device;
|
||||||
|
private nint _context;
|
||||||
|
private bool _contextCurrent;
|
||||||
|
private bool _cleanupActive;
|
||||||
|
|
||||||
|
public OpenAlResourceLifetime(IOpenAlResourceApi api)
|
||||||
|
{
|
||||||
|
_api = api ?? throw new ArgumentNullException(nameof(api));
|
||||||
|
}
|
||||||
|
|
||||||
|
public nint Device => _device;
|
||||||
|
public nint Context => _context;
|
||||||
|
|
||||||
|
public bool IsCleanupComplete =>
|
||||||
|
_sources.All(static source => source.Released)
|
||||||
|
&& _buffers.All(static buffer => buffer.Released)
|
||||||
|
&& _context == 0
|
||||||
|
&& _device == 0;
|
||||||
|
|
||||||
|
public bool TryOpenDevice()
|
||||||
|
{
|
||||||
|
if (_device != 0)
|
||||||
|
throw new InvalidOperationException("The OpenAL device is already open.");
|
||||||
|
_device = _api.OpenDevice();
|
||||||
|
return _device != 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool TryCreateContext()
|
||||||
|
{
|
||||||
|
if (_device == 0)
|
||||||
|
throw new InvalidOperationException("An OpenAL device is required before its context.");
|
||||||
|
if (_context != 0)
|
||||||
|
throw new InvalidOperationException("The OpenAL context already exists.");
|
||||||
|
_context = _api.CreateContext(_device);
|
||||||
|
return _context != 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool TryMakeCurrent()
|
||||||
|
{
|
||||||
|
if (_context == 0)
|
||||||
|
throw new InvalidOperationException("An OpenAL context is required before activation.");
|
||||||
|
_contextCurrent = _api.MakeContextCurrent(_context);
|
||||||
|
return _contextCurrent;
|
||||||
|
}
|
||||||
|
|
||||||
|
public uint Create3DSource()
|
||||||
|
{
|
||||||
|
uint source = _api.GenerateSource();
|
||||||
|
_sources.Add(new SourceState(source));
|
||||||
|
_api.Configure3DSource(source);
|
||||||
|
return source;
|
||||||
|
}
|
||||||
|
|
||||||
|
public uint CreateUiSource()
|
||||||
|
{
|
||||||
|
uint source = _api.GenerateSource();
|
||||||
|
_sources.Add(new SourceState(source));
|
||||||
|
_api.ConfigureUiSource(source);
|
||||||
|
return source;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void OwnBuffer(uint buffer)
|
||||||
|
{
|
||||||
|
if (buffer == 0)
|
||||||
|
throw new ArgumentOutOfRangeException(nameof(buffer));
|
||||||
|
if (_buffers.Any(existing => existing.Id == buffer && !existing.Released))
|
||||||
|
throw new InvalidOperationException($"OpenAL buffer {buffer} is already owned.");
|
||||||
|
_buffers.Add(new BufferState(buffer));
|
||||||
|
}
|
||||||
|
|
||||||
|
public void ReleaseBuffer(uint buffer)
|
||||||
|
{
|
||||||
|
BufferState state = _buffers.LastOrDefault(candidate =>
|
||||||
|
candidate.Id == buffer && !candidate.Released)
|
||||||
|
?? throw new InvalidOperationException($"OpenAL buffer {buffer} is not owned.");
|
||||||
|
_api.DeleteBuffer(buffer);
|
||||||
|
state.Released = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void RetryCleanup()
|
||||||
|
{
|
||||||
|
if (_cleanupActive || IsCleanupComplete)
|
||||||
|
return;
|
||||||
|
|
||||||
|
_cleanupActive = true;
|
||||||
|
List<Exception>? failures = null;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
// Sources borrow buffers; retire all sources first. Each deletion
|
||||||
|
// is still attempted even when SourceStop reports a driver error.
|
||||||
|
for (int i = _sources.Count - 1; i >= 0; i--)
|
||||||
|
{
|
||||||
|
SourceState source = _sources[i];
|
||||||
|
if (source.Released)
|
||||||
|
continue;
|
||||||
|
|
||||||
|
Exception? stopFailure = null;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
_api.StopSource(source.Id);
|
||||||
|
}
|
||||||
|
catch (Exception failure)
|
||||||
|
{
|
||||||
|
stopFailure = failure;
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
_api.DeleteSource(source.Id);
|
||||||
|
source.Released = true;
|
||||||
|
}
|
||||||
|
catch (Exception failure)
|
||||||
|
{
|
||||||
|
(failures ??= []).Add(new AggregateException(
|
||||||
|
$"OpenAL source {source.Id} could not be released.",
|
||||||
|
stopFailure is null ? [failure] : [stopFailure, failure]));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (int i = _buffers.Count - 1; i >= 0; i--)
|
||||||
|
{
|
||||||
|
BufferState buffer = _buffers[i];
|
||||||
|
if (buffer.Released)
|
||||||
|
continue;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
_api.DeleteBuffer(buffer.Id);
|
||||||
|
buffer.Released = true;
|
||||||
|
}
|
||||||
|
catch (Exception failure)
|
||||||
|
{
|
||||||
|
(failures ??= []).Add(new InvalidOperationException(
|
||||||
|
$"OpenAL buffer {buffer.Id} could not be released.",
|
||||||
|
failure));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
bool childrenReleased =
|
||||||
|
_sources.All(static source => source.Released)
|
||||||
|
&& _buffers.All(static buffer => buffer.Released);
|
||||||
|
if (childrenReleased && _context != 0)
|
||||||
|
{
|
||||||
|
if (_contextCurrent)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (!_api.MakeContextCurrent(0))
|
||||||
|
throw new InvalidOperationException(
|
||||||
|
"OpenAL rejected clearing the current context.");
|
||||||
|
_contextCurrent = false;
|
||||||
|
}
|
||||||
|
catch (Exception failure)
|
||||||
|
{
|
||||||
|
(failures ??= []).Add(new InvalidOperationException(
|
||||||
|
"The current OpenAL context could not be cleared.",
|
||||||
|
failure));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!_contextCurrent)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
_api.DestroyContext(_context);
|
||||||
|
_context = 0;
|
||||||
|
}
|
||||||
|
catch (Exception failure)
|
||||||
|
{
|
||||||
|
(failures ??= []).Add(new InvalidOperationException(
|
||||||
|
"The OpenAL context could not be destroyed.",
|
||||||
|
failure));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_context == 0 && _device != 0)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
_api.CloseDevice(_device);
|
||||||
|
_device = 0;
|
||||||
|
}
|
||||||
|
catch (Exception failure)
|
||||||
|
{
|
||||||
|
(failures ??= []).Add(new InvalidOperationException(
|
||||||
|
"The OpenAL device could not be closed.",
|
||||||
|
failure));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
_cleanupActive = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (failures is not null)
|
||||||
|
throw new AggregateException(
|
||||||
|
"OpenAL native-resource cleanup remains incomplete.",
|
||||||
|
failures);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
internal sealed class OpenAlInitializationException : AggregateException,
|
||||||
|
IRetryableResourceCleanup
|
||||||
|
{
|
||||||
|
private readonly OpenAlResourceLifetime _lifetime;
|
||||||
|
|
||||||
|
public OpenAlInitializationException(
|
||||||
|
Exception initializationFailure,
|
||||||
|
OpenAlResourceLifetime lifetime,
|
||||||
|
AggregateException cleanupFailure)
|
||||||
|
: base(
|
||||||
|
"OpenAL initialization failed and native-resource cleanup remains incomplete.",
|
||||||
|
[initializationFailure, .. cleanupFailure.InnerExceptions])
|
||||||
|
{
|
||||||
|
_lifetime = lifetime ?? throw new ArgumentNullException(nameof(lifetime));
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool IsCleanupComplete => _lifetime.IsCleanupComplete;
|
||||||
|
|
||||||
|
public void RetryCleanup() => _lifetime.RetryCleanup();
|
||||||
|
}
|
||||||
105
src/AcDream.App/Composition/AnimationHookRegistrationSet.cs
Normal file
105
src/AcDream.App/Composition/AnimationHookRegistrationSet.cs
Normal file
|
|
@ -0,0 +1,105 @@
|
||||||
|
using AcDream.App.Rendering;
|
||||||
|
using AcDream.Core.Physics;
|
||||||
|
|
||||||
|
namespace AcDream.App.Composition;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Lifetime owner for animation-hook router edges. Registration order remains
|
||||||
|
/// retail-observable dispatch order; teardown runs in reverse and retains only
|
||||||
|
/// unregister operations that actually failed.
|
||||||
|
/// </summary>
|
||||||
|
internal sealed class AnimationHookRegistrationSet : IDisposable,
|
||||||
|
IRetryableResourceCleanup
|
||||||
|
{
|
||||||
|
private sealed class Entry(IAnimationHookSink sink)
|
||||||
|
{
|
||||||
|
public IAnimationHookSink Sink { get; } = sink;
|
||||||
|
public bool Removed { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
private readonly Action<IAnimationHookSink> _register;
|
||||||
|
private readonly Action<IAnimationHookSink> _unregister;
|
||||||
|
private readonly List<Entry> _entries = [];
|
||||||
|
private bool _disposing;
|
||||||
|
private bool _closed;
|
||||||
|
|
||||||
|
public AnimationHookRegistrationSet(AnimationHookRouter router)
|
||||||
|
: this(
|
||||||
|
(router ?? throw new ArgumentNullException(nameof(router))).Register,
|
||||||
|
router.Unregister)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
internal AnimationHookRegistrationSet(
|
||||||
|
Action<IAnimationHookSink> register,
|
||||||
|
Action<IAnimationHookSink> unregister)
|
||||||
|
{
|
||||||
|
_register = register ?? throw new ArgumentNullException(nameof(register));
|
||||||
|
_unregister = unregister ?? throw new ArgumentNullException(nameof(unregister));
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool IsCleanupComplete =>
|
||||||
|
_entries.All(static entry => entry.Removed);
|
||||||
|
|
||||||
|
public int ActiveCount =>
|
||||||
|
_entries.Count(static entry => !entry.Removed);
|
||||||
|
|
||||||
|
public void Register(IAnimationHookSink sink)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(sink);
|
||||||
|
ObjectDisposedException.ThrowIf(_closed, this);
|
||||||
|
if (_entries.Any(entry =>
|
||||||
|
!entry.Removed && ReferenceEquals(entry.Sink, sink)))
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_register(sink);
|
||||||
|
_entries.Add(new Entry(sink));
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
_closed = true;
|
||||||
|
RetryCleanup();
|
||||||
|
}
|
||||||
|
|
||||||
|
public void RetryCleanup()
|
||||||
|
{
|
||||||
|
if (_disposing || IsCleanupComplete)
|
||||||
|
return;
|
||||||
|
|
||||||
|
_closed = true;
|
||||||
|
_disposing = true;
|
||||||
|
List<Exception>? failures = null;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
for (int i = _entries.Count - 1; i >= 0; i--)
|
||||||
|
{
|
||||||
|
Entry entry = _entries[i];
|
||||||
|
if (entry.Removed)
|
||||||
|
continue;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
_unregister(entry.Sink);
|
||||||
|
entry.Removed = true;
|
||||||
|
}
|
||||||
|
catch (Exception failure)
|
||||||
|
{
|
||||||
|
(failures ??= []).Add(new InvalidOperationException(
|
||||||
|
$"Animation-hook sink '{entry.Sink.GetType().Name}' could not be unregistered.",
|
||||||
|
failure));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
_disposing = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (failures is not null)
|
||||||
|
throw new AggregateException(
|
||||||
|
"Animation-hook registration cleanup remains incomplete.",
|
||||||
|
failures);
|
||||||
|
}
|
||||||
|
}
|
||||||
466
src/AcDream.App/Composition/ContentEffectsAudioComposition.cs
Normal file
466
src/AcDream.App/Composition/ContentEffectsAudioComposition.cs
Normal file
|
|
@ -0,0 +1,466 @@
|
||||||
|
using AcDream.App.Audio;
|
||||||
|
using AcDream.App.Physics;
|
||||||
|
using AcDream.App.Rendering;
|
||||||
|
using AcDream.App.Rendering.Vfx;
|
||||||
|
using AcDream.App.Spells;
|
||||||
|
using AcDream.Content;
|
||||||
|
using AcDream.Content.Vfx;
|
||||||
|
using AcDream.Core.Audio;
|
||||||
|
using AcDream.Core.Lighting;
|
||||||
|
using AcDream.Core.Physics;
|
||||||
|
using AcDream.Core.Rendering;
|
||||||
|
using AcDream.Core.Spells;
|
||||||
|
using AcDream.Core.Vfx;
|
||||||
|
using DatReaderWriter;
|
||||||
|
using Silk.NET.Input;
|
||||||
|
using Silk.NET.OpenGL;
|
||||||
|
|
||||||
|
namespace AcDream.App.Composition;
|
||||||
|
|
||||||
|
internal sealed record ContentAudioGraph(
|
||||||
|
DatSoundCache SoundCache,
|
||||||
|
OpenAlAudioEngine Engine,
|
||||||
|
DictionaryEntitySoundTable EntitySoundTables,
|
||||||
|
AudioHookSink? HookSink);
|
||||||
|
|
||||||
|
internal sealed record ContentEffectsAudioResult(
|
||||||
|
IDatReaderWriter Dats,
|
||||||
|
MagicCatalog MagicCatalog,
|
||||||
|
IAnimationLoader AnimationLoader,
|
||||||
|
LiveEntityCollisionBuilder CollisionBuilder,
|
||||||
|
EmitterDescRegistry EmitterRegistry,
|
||||||
|
ParticleSystem ParticleSystem,
|
||||||
|
ParticleHookSink ParticleSink,
|
||||||
|
AnimationHookFrameQueue AnimationHookFrames,
|
||||||
|
RetailPhysicsScriptLoader PhysicsScriptLoader,
|
||||||
|
PhysicsScriptRunner ScriptRunner,
|
||||||
|
LightingHookSink LightingSink,
|
||||||
|
TranslucencyHookSink TranslucencySink,
|
||||||
|
AnimationHookRegistrationSet HookRegistrations,
|
||||||
|
ContentAudioGraph? Audio);
|
||||||
|
|
||||||
|
internal sealed record ContentEffectsAudioDependencies(
|
||||||
|
string DatDirectory,
|
||||||
|
PhysicsDataCache PhysicsDataCache,
|
||||||
|
bool DumpMotionEnabled,
|
||||||
|
Spellbook SpellBook,
|
||||||
|
AnimationHookRouter HookRouter,
|
||||||
|
EntityEffectPoseRegistry EffectPoses,
|
||||||
|
DeferredEntityEffectAdvanceSource EntityEffectAdvance,
|
||||||
|
LightManager Lighting,
|
||||||
|
TranslucencyFadeManager TranslucencyFades,
|
||||||
|
bool NoAudio,
|
||||||
|
Action<string> Log,
|
||||||
|
Action<string> Error);
|
||||||
|
|
||||||
|
internal interface IGameWindowContentEffectsAudioPublication
|
||||||
|
{
|
||||||
|
void PublishDatCollection(IDatReaderWriter value);
|
||||||
|
void PublishMagicCatalog(MagicCatalog value);
|
||||||
|
void PublishAnimationLoader(IAnimationLoader value);
|
||||||
|
void PublishLiveEntityCollisionBuilder(LiveEntityCollisionBuilder value);
|
||||||
|
void PublishEmitterRegistry(EmitterDescRegistry value);
|
||||||
|
void PublishParticleSystem(ParticleSystem value);
|
||||||
|
void PublishParticleSink(ParticleHookSink value);
|
||||||
|
void PublishAnimationHookFrames(AnimationHookFrameQueue value);
|
||||||
|
void PublishPhysicsScriptLoader(RetailPhysicsScriptLoader value);
|
||||||
|
void PublishPhysicsScriptRunner(PhysicsScriptRunner value);
|
||||||
|
void PublishLightingSink(LightingHookSink value);
|
||||||
|
void PublishTranslucencySink(TranslucencyHookSink value);
|
||||||
|
void PublishHookRegistrations(AnimationHookRegistrationSet value);
|
||||||
|
void PublishAudio(ContentAudioGraph value);
|
||||||
|
}
|
||||||
|
|
||||||
|
internal interface IContentEffectsAudioCompositionFactory
|
||||||
|
{
|
||||||
|
IDatReaderWriter OpenDatCollection(string datDirectory);
|
||||||
|
MagicCatalog LoadMagicCatalog(IDatReaderWriter dats);
|
||||||
|
void InstallSpellMetadata(Spellbook spellBook, MagicCatalog catalog);
|
||||||
|
int GetSpellCount(MagicCatalog catalog);
|
||||||
|
IAnimationLoader CreateAnimationLoader(IDatReaderWriter dats);
|
||||||
|
LiveEntityCollisionBuilder CreateCollisionBuilder(
|
||||||
|
PhysicsDataCache physicsData,
|
||||||
|
IDatReaderWriter dats,
|
||||||
|
IAnimationLoader animationLoader,
|
||||||
|
bool dumpMotionEnabled);
|
||||||
|
EmitterDescRegistry CreateEmitterRegistry(IDatReaderWriter dats);
|
||||||
|
ParticleSystem CreateParticleSystem(EmitterDescRegistry emitters);
|
||||||
|
ParticleHookSink CreateParticleSink(
|
||||||
|
ParticleSystem particles,
|
||||||
|
EntityEffectPoseRegistry poses);
|
||||||
|
AnimationHookFrameQueue CreateAnimationHookFrames(
|
||||||
|
AnimationHookRouter router,
|
||||||
|
EntityEffectPoseRegistry poses);
|
||||||
|
RetailPhysicsScriptLoader CreatePhysicsScriptLoader(IDatReaderWriter dats);
|
||||||
|
PhysicsScriptRunner CreatePhysicsScriptRunner(
|
||||||
|
RetailPhysicsScriptLoader loader,
|
||||||
|
AnimationHookRouter router,
|
||||||
|
IEntityEffectAdvanceSource advanceSource);
|
||||||
|
LightingHookSink CreateLightingSink(
|
||||||
|
LightManager lighting,
|
||||||
|
EntityEffectPoseRegistry poses);
|
||||||
|
TranslucencyHookSink CreateTranslucencySink(TranslucencyFadeManager fades);
|
||||||
|
AnimationHookRegistrationSet CreateHookRegistrations(AnimationHookRouter router);
|
||||||
|
DatSoundCache CreateSoundCache(IDatReaderWriter dats);
|
||||||
|
OpenAlAudioEngine CreateAudioEngine();
|
||||||
|
DictionaryEntitySoundTable CreateEntitySoundTables();
|
||||||
|
AudioHookSink CreateAudioSink(
|
||||||
|
OpenAlAudioEngine engine,
|
||||||
|
DatSoundCache cache,
|
||||||
|
DictionaryEntitySoundTable entitySoundTables);
|
||||||
|
}
|
||||||
|
|
||||||
|
internal sealed class RetailContentEffectsAudioCompositionFactory
|
||||||
|
: IContentEffectsAudioCompositionFactory
|
||||||
|
{
|
||||||
|
public IDatReaderWriter OpenDatCollection(string datDirectory) =>
|
||||||
|
RuntimeDatCollectionFactory.OpenReadOnly(datDirectory);
|
||||||
|
|
||||||
|
public MagicCatalog LoadMagicCatalog(IDatReaderWriter dats) =>
|
||||||
|
MagicCatalog.Load(dats);
|
||||||
|
|
||||||
|
public void InstallSpellMetadata(Spellbook spellBook, MagicCatalog catalog) =>
|
||||||
|
spellBook.InstallMetadata(catalog.SpellTable);
|
||||||
|
|
||||||
|
public int GetSpellCount(MagicCatalog catalog) => catalog.SpellTable.Count;
|
||||||
|
|
||||||
|
public IAnimationLoader CreateAnimationLoader(IDatReaderWriter dats) =>
|
||||||
|
new RetailAnimationLoader(dats);
|
||||||
|
|
||||||
|
public LiveEntityCollisionBuilder CreateCollisionBuilder(
|
||||||
|
PhysicsDataCache physicsData,
|
||||||
|
IDatReaderWriter dats,
|
||||||
|
IAnimationLoader animationLoader,
|
||||||
|
bool dumpMotionEnabled) =>
|
||||||
|
new(
|
||||||
|
physicsData,
|
||||||
|
new LiveEntityDefaultPoseResolver(
|
||||||
|
id => dats.Get<DatReaderWriter.DBObjs.MotionTable>(id),
|
||||||
|
animationLoader,
|
||||||
|
dumpMotionEnabled));
|
||||||
|
|
||||||
|
public EmitterDescRegistry CreateEmitterRegistry(IDatReaderWriter dats) =>
|
||||||
|
new(dats);
|
||||||
|
|
||||||
|
public ParticleSystem CreateParticleSystem(EmitterDescRegistry emitters) =>
|
||||||
|
new(emitters);
|
||||||
|
|
||||||
|
public ParticleHookSink CreateParticleSink(
|
||||||
|
ParticleSystem particles,
|
||||||
|
EntityEffectPoseRegistry poses) =>
|
||||||
|
new(particles, poses);
|
||||||
|
|
||||||
|
public AnimationHookFrameQueue CreateAnimationHookFrames(
|
||||||
|
AnimationHookRouter router,
|
||||||
|
EntityEffectPoseRegistry poses) =>
|
||||||
|
new(router, poses);
|
||||||
|
|
||||||
|
public RetailPhysicsScriptLoader CreatePhysicsScriptLoader(IDatReaderWriter dats) =>
|
||||||
|
new(dats);
|
||||||
|
|
||||||
|
public PhysicsScriptRunner CreatePhysicsScriptRunner(
|
||||||
|
RetailPhysicsScriptLoader loader,
|
||||||
|
AnimationHookRouter router,
|
||||||
|
IEntityEffectAdvanceSource advanceSource) =>
|
||||||
|
new(
|
||||||
|
loader.LoadPhysicsScript,
|
||||||
|
router,
|
||||||
|
canAdvanceOwner: advanceSource.CanAdvanceOwner);
|
||||||
|
|
||||||
|
public LightingHookSink CreateLightingSink(
|
||||||
|
LightManager lighting,
|
||||||
|
EntityEffectPoseRegistry poses) =>
|
||||||
|
new(lighting, poses);
|
||||||
|
|
||||||
|
public TranslucencyHookSink CreateTranslucencySink(
|
||||||
|
TranslucencyFadeManager fades) =>
|
||||||
|
new(fades);
|
||||||
|
|
||||||
|
public AnimationHookRegistrationSet CreateHookRegistrations(
|
||||||
|
AnimationHookRouter router) =>
|
||||||
|
new(router);
|
||||||
|
|
||||||
|
public DatSoundCache CreateSoundCache(IDatReaderWriter dats) => new(dats);
|
||||||
|
|
||||||
|
public OpenAlAudioEngine CreateAudioEngine() => new();
|
||||||
|
|
||||||
|
public DictionaryEntitySoundTable CreateEntitySoundTables() => new();
|
||||||
|
|
||||||
|
public AudioHookSink CreateAudioSink(
|
||||||
|
OpenAlAudioEngine engine,
|
||||||
|
DatSoundCache cache,
|
||||||
|
DictionaryEntitySoundTable entitySoundTables) =>
|
||||||
|
new(engine, cache, entitySoundTables);
|
||||||
|
}
|
||||||
|
|
||||||
|
internal enum ContentEffectsAudioCompositionPoint
|
||||||
|
{
|
||||||
|
DatCollectionPublished,
|
||||||
|
MagicCatalogPublished,
|
||||||
|
SpellMetadataInstalled,
|
||||||
|
AnimationLoaderPublished,
|
||||||
|
CollisionBuilderPublished,
|
||||||
|
EmitterRegistryPublished,
|
||||||
|
ParticleSystemPublished,
|
||||||
|
ParticleSinkPublished,
|
||||||
|
AnimationHookFramesPublished,
|
||||||
|
HookRegistrationsPublished,
|
||||||
|
ParticleHookRegistered,
|
||||||
|
PhysicsScriptLoaderPublished,
|
||||||
|
PhysicsScriptRunnerPublished,
|
||||||
|
LightingSinkPublished,
|
||||||
|
LightingHookRegistered,
|
||||||
|
TranslucencySinkPublished,
|
||||||
|
TranslucencyHookRegistered,
|
||||||
|
SoundCacheCreated,
|
||||||
|
AudioEngineCreated,
|
||||||
|
EntitySoundTablesCreated,
|
||||||
|
AudioSinkCreated,
|
||||||
|
AudioPublished,
|
||||||
|
AudioHookRegistered,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Production Phase 2. The sole DAT mapping and every effect/audio owner are
|
||||||
|
/// published through typed lifetime slots. Router edges have one reversible
|
||||||
|
/// owner, and optional audio is swallowed only while its entire unpublished
|
||||||
|
/// native prefix has converged cleanup.
|
||||||
|
/// </summary>
|
||||||
|
internal sealed class ContentEffectsAudioCompositionPhase :
|
||||||
|
IContentEffectsAudioCompositionPhase<
|
||||||
|
GameWindowPlatformResult<GL, IInputContext>,
|
||||||
|
HostInputCameraResult,
|
||||||
|
ContentEffectsAudioResult>
|
||||||
|
{
|
||||||
|
private readonly ContentEffectsAudioDependencies _dependencies;
|
||||||
|
private readonly IGameWindowContentEffectsAudioPublication _publication;
|
||||||
|
private readonly IContentEffectsAudioCompositionFactory _factory;
|
||||||
|
private readonly Action<ContentEffectsAudioCompositionPoint>? _faultInjection;
|
||||||
|
|
||||||
|
public ContentEffectsAudioCompositionPhase(
|
||||||
|
ContentEffectsAudioDependencies dependencies,
|
||||||
|
IGameWindowContentEffectsAudioPublication publication,
|
||||||
|
IContentEffectsAudioCompositionFactory? factory = null,
|
||||||
|
Action<ContentEffectsAudioCompositionPoint>? faultInjection = null)
|
||||||
|
{
|
||||||
|
_dependencies = dependencies
|
||||||
|
?? throw new ArgumentNullException(nameof(dependencies));
|
||||||
|
_publication = publication
|
||||||
|
?? throw new ArgumentNullException(nameof(publication));
|
||||||
|
_factory = factory ?? new RetailContentEffectsAudioCompositionFactory();
|
||||||
|
_faultInjection = faultInjection;
|
||||||
|
}
|
||||||
|
|
||||||
|
public ContentEffectsAudioResult Compose(
|
||||||
|
GameWindowPlatformResult<GL, IInputContext> platform,
|
||||||
|
HostInputCameraResult host)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(platform);
|
||||||
|
ArgumentNullException.ThrowIfNull(host);
|
||||||
|
|
||||||
|
var mainScope = new CompositionAcquisitionScope();
|
||||||
|
try
|
||||||
|
{
|
||||||
|
IDatReaderWriter dats = mainScope.Acquire(
|
||||||
|
"DAT collection",
|
||||||
|
() => _factory.OpenDatCollection(_dependencies.DatDirectory),
|
||||||
|
static value => value.Dispose()).Publish(
|
||||||
|
_publication.PublishDatCollection);
|
||||||
|
Fault(ContentEffectsAudioCompositionPoint.DatCollectionPublished);
|
||||||
|
|
||||||
|
MagicCatalog magic = _factory.LoadMagicCatalog(dats);
|
||||||
|
_publication.PublishMagicCatalog(magic);
|
||||||
|
Fault(ContentEffectsAudioCompositionPoint.MagicCatalogPublished);
|
||||||
|
_factory.InstallSpellMetadata(_dependencies.SpellBook, magic);
|
||||||
|
_dependencies.Log(
|
||||||
|
$"spells: loaded {_factory.GetSpellCount(magic)} entries from portal.dat");
|
||||||
|
Fault(ContentEffectsAudioCompositionPoint.SpellMetadataInstalled);
|
||||||
|
|
||||||
|
IAnimationLoader animations = _factory.CreateAnimationLoader(dats);
|
||||||
|
_publication.PublishAnimationLoader(animations);
|
||||||
|
Fault(ContentEffectsAudioCompositionPoint.AnimationLoaderPublished);
|
||||||
|
|
||||||
|
LiveEntityCollisionBuilder collision = _factory.CreateCollisionBuilder(
|
||||||
|
_dependencies.PhysicsDataCache,
|
||||||
|
dats,
|
||||||
|
animations,
|
||||||
|
_dependencies.DumpMotionEnabled);
|
||||||
|
_publication.PublishLiveEntityCollisionBuilder(collision);
|
||||||
|
Fault(ContentEffectsAudioCompositionPoint.CollisionBuilderPublished);
|
||||||
|
|
||||||
|
EmitterDescRegistry emitters = _factory.CreateEmitterRegistry(dats);
|
||||||
|
_publication.PublishEmitterRegistry(emitters);
|
||||||
|
Fault(ContentEffectsAudioCompositionPoint.EmitterRegistryPublished);
|
||||||
|
|
||||||
|
ParticleSystem particles = _factory.CreateParticleSystem(emitters);
|
||||||
|
_publication.PublishParticleSystem(particles);
|
||||||
|
Fault(ContentEffectsAudioCompositionPoint.ParticleSystemPublished);
|
||||||
|
|
||||||
|
ParticleHookSink particleSink = _factory.CreateParticleSink(
|
||||||
|
particles,
|
||||||
|
_dependencies.EffectPoses);
|
||||||
|
particleSink.DiagnosticSink = message =>
|
||||||
|
_dependencies.Error($"vfx: {message}");
|
||||||
|
_publication.PublishParticleSink(particleSink);
|
||||||
|
Fault(ContentEffectsAudioCompositionPoint.ParticleSinkPublished);
|
||||||
|
|
||||||
|
AnimationHookFrameQueue hookFrames =
|
||||||
|
_factory.CreateAnimationHookFrames(
|
||||||
|
_dependencies.HookRouter,
|
||||||
|
_dependencies.EffectPoses);
|
||||||
|
_publication.PublishAnimationHookFrames(hookFrames);
|
||||||
|
Fault(ContentEffectsAudioCompositionPoint.AnimationHookFramesPublished);
|
||||||
|
|
||||||
|
AnimationHookRegistrationSet registrations = mainScope.Acquire(
|
||||||
|
"animation hook registrations",
|
||||||
|
() => _factory.CreateHookRegistrations(_dependencies.HookRouter),
|
||||||
|
static value => value.Dispose()).Publish(
|
||||||
|
_publication.PublishHookRegistrations);
|
||||||
|
Fault(ContentEffectsAudioCompositionPoint.HookRegistrationsPublished);
|
||||||
|
registrations.Register(particleSink);
|
||||||
|
Fault(ContentEffectsAudioCompositionPoint.ParticleHookRegistered);
|
||||||
|
|
||||||
|
RetailPhysicsScriptLoader scriptLoader =
|
||||||
|
_factory.CreatePhysicsScriptLoader(dats);
|
||||||
|
_publication.PublishPhysicsScriptLoader(scriptLoader);
|
||||||
|
Fault(ContentEffectsAudioCompositionPoint.PhysicsScriptLoaderPublished);
|
||||||
|
|
||||||
|
PhysicsScriptRunner scriptRunner = _factory.CreatePhysicsScriptRunner(
|
||||||
|
scriptLoader,
|
||||||
|
_dependencies.HookRouter,
|
||||||
|
_dependencies.EntityEffectAdvance);
|
||||||
|
_publication.PublishPhysicsScriptRunner(scriptRunner);
|
||||||
|
Fault(ContentEffectsAudioCompositionPoint.PhysicsScriptRunnerPublished);
|
||||||
|
|
||||||
|
LightingHookSink lightingSink = _factory.CreateLightingSink(
|
||||||
|
_dependencies.Lighting,
|
||||||
|
_dependencies.EffectPoses);
|
||||||
|
_publication.PublishLightingSink(lightingSink);
|
||||||
|
Fault(ContentEffectsAudioCompositionPoint.LightingSinkPublished);
|
||||||
|
registrations.Register(lightingSink);
|
||||||
|
Fault(ContentEffectsAudioCompositionPoint.LightingHookRegistered);
|
||||||
|
|
||||||
|
TranslucencyHookSink translucencySink =
|
||||||
|
_factory.CreateTranslucencySink(_dependencies.TranslucencyFades);
|
||||||
|
_publication.PublishTranslucencySink(translucencySink);
|
||||||
|
Fault(ContentEffectsAudioCompositionPoint.TranslucencySinkPublished);
|
||||||
|
registrations.Register(translucencySink);
|
||||||
|
Fault(ContentEffectsAudioCompositionPoint.TranslucencyHookRegistered);
|
||||||
|
|
||||||
|
ContentAudioGraph? audio = _dependencies.NoAudio
|
||||||
|
? null
|
||||||
|
: ComposeOptionalAudio(dats, registrations);
|
||||||
|
|
||||||
|
mainScope.Complete();
|
||||||
|
return new ContentEffectsAudioResult(
|
||||||
|
dats,
|
||||||
|
magic,
|
||||||
|
animations,
|
||||||
|
collision,
|
||||||
|
emitters,
|
||||||
|
particles,
|
||||||
|
particleSink,
|
||||||
|
hookFrames,
|
||||||
|
scriptLoader,
|
||||||
|
scriptRunner,
|
||||||
|
lightingSink,
|
||||||
|
translucencySink,
|
||||||
|
registrations,
|
||||||
|
audio);
|
||||||
|
}
|
||||||
|
catch (Exception failure)
|
||||||
|
{
|
||||||
|
mainScope.RollbackAndThrow(failure);
|
||||||
|
throw new System.Diagnostics.UnreachableException();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private ContentAudioGraph? ComposeOptionalAudio(
|
||||||
|
IDatReaderWriter dats,
|
||||||
|
AnimationHookRegistrationSet registrations)
|
||||||
|
{
|
||||||
|
var audioScope = new CompositionAcquisitionScope();
|
||||||
|
ContentAudioGraph graph;
|
||||||
|
CompositionAcquisitionScope.CompositionAcquisitionLease<OpenAlAudioEngine>
|
||||||
|
engineLease;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
DatSoundCache cache = _factory.CreateSoundCache(dats);
|
||||||
|
Fault(ContentEffectsAudioCompositionPoint.SoundCacheCreated);
|
||||||
|
engineLease = audioScope.Acquire(
|
||||||
|
"OpenAL audio engine",
|
||||||
|
_factory.CreateAudioEngine,
|
||||||
|
static value => value.Dispose());
|
||||||
|
OpenAlAudioEngine engine = engineLease.Resource;
|
||||||
|
Fault(ContentEffectsAudioCompositionPoint.AudioEngineCreated);
|
||||||
|
DictionaryEntitySoundTable soundTables =
|
||||||
|
_factory.CreateEntitySoundTables();
|
||||||
|
Fault(ContentEffectsAudioCompositionPoint.EntitySoundTablesCreated);
|
||||||
|
AudioHookSink? sink = null;
|
||||||
|
if (engine.IsAvailable)
|
||||||
|
{
|
||||||
|
sink = _factory.CreateAudioSink(engine, cache, soundTables);
|
||||||
|
Fault(ContentEffectsAudioCompositionPoint.AudioSinkCreated);
|
||||||
|
}
|
||||||
|
|
||||||
|
graph = new ContentAudioGraph(cache, engine, soundTables, sink);
|
||||||
|
}
|
||||||
|
catch (Exception failure)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
audioScope.RollbackAndThrow(failure);
|
||||||
|
}
|
||||||
|
catch (Exception rolledBack) when (!HasIncompleteCleanup(rolledBack))
|
||||||
|
{
|
||||||
|
_dependencies.Log(
|
||||||
|
$"audio: init failed: {rolledBack.Message} — audio disabled");
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new System.Diagnostics.UnreachableException();
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
_publication.PublishAudio(graph);
|
||||||
|
engineLease.Transfer();
|
||||||
|
audioScope.Complete();
|
||||||
|
}
|
||||||
|
catch (Exception failure)
|
||||||
|
{
|
||||||
|
audioScope.RollbackAndThrow(failure);
|
||||||
|
throw new System.Diagnostics.UnreachableException();
|
||||||
|
}
|
||||||
|
|
||||||
|
Fault(ContentEffectsAudioCompositionPoint.AudioPublished);
|
||||||
|
if (graph.HookSink is { } audioSink)
|
||||||
|
{
|
||||||
|
registrations.Register(audioSink);
|
||||||
|
Fault(ContentEffectsAudioCompositionPoint.AudioHookRegistered);
|
||||||
|
_dependencies.Log("audio: OpenAL engine ready (16 voices, 3D positional)");
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
_dependencies.Log(
|
||||||
|
"audio: OpenAL unavailable (driver missing / headless) — audio disabled");
|
||||||
|
}
|
||||||
|
|
||||||
|
return graph;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool HasIncompleteCleanup(Exception failure)
|
||||||
|
{
|
||||||
|
if (failure is IRetryableResourceCleanup cleanup
|
||||||
|
&& !cleanup.IsCleanupComplete)
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (failure is AggregateException aggregate)
|
||||||
|
return aggregate.InnerExceptions.Any(HasIncompleteCleanup);
|
||||||
|
return failure.InnerException is { } inner && HasIncompleteCleanup(inner);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void Fault(ContentEffectsAudioCompositionPoint point) =>
|
||||||
|
_faultInjection?.Invoke(point);
|
||||||
|
}
|
||||||
|
|
@ -15,7 +15,8 @@ namespace AcDream.App.Rendering;
|
||||||
public sealed class GameWindow :
|
public sealed class GameWindow :
|
||||||
IDisposable,
|
IDisposable,
|
||||||
IGameWindowPlatformPublication<GL, IInputContext>,
|
IGameWindowPlatformPublication<GL, IInputContext>,
|
||||||
IGameWindowHostInputCameraPublication
|
IGameWindowHostInputCameraPublication,
|
||||||
|
IGameWindowContentEffectsAudioPublication
|
||||||
{
|
{
|
||||||
private static double ClientTimerNow() =>
|
private static double ClientTimerNow() =>
|
||||||
System.Diagnostics.Stopwatch.GetTimestamp()
|
System.Diagnostics.Stopwatch.GetTimestamp()
|
||||||
|
|
@ -251,6 +252,10 @@ public sealed class GameWindow :
|
||||||
// register sinks at startup. The router is always non-null so the
|
// register sinks at startup. The router is always non-null so the
|
||||||
// per-entity tick loop can just call it unconditionally.
|
// per-entity tick loop can just call it unconditionally.
|
||||||
private readonly AcDream.Core.Physics.AnimationHookRouter _hookRouter = new();
|
private readonly AcDream.Core.Physics.AnimationHookRouter _hookRouter = new();
|
||||||
|
private AcDream.App.Composition.AnimationHookRegistrationSet?
|
||||||
|
_hookRegistrations;
|
||||||
|
private readonly AcDream.App.Rendering.Vfx.DeferredEntityEffectAdvanceSource
|
||||||
|
_entityEffectAdvance = new();
|
||||||
|
|
||||||
// Phase E.2 audio. Null when ACDREAM_NO_AUDIO=1 or the OpenAL driver
|
// Phase E.2 audio. Null when ACDREAM_NO_AUDIO=1 or the OpenAL driver
|
||||||
// failed to init; all three are set together.
|
// failed to init; all three are set together.
|
||||||
|
|
@ -693,6 +698,92 @@ public sealed class GameWindow :
|
||||||
AcDream.App.Input.CameraPointerInputController value) =>
|
AcDream.App.Input.CameraPointerInputController value) =>
|
||||||
PublishCompositionOwner(ref _cameraPointerInput, value, "camera pointer input");
|
PublishCompositionOwner(ref _cameraPointerInput, value, "camera pointer input");
|
||||||
|
|
||||||
|
void IGameWindowContentEffectsAudioPublication.PublishDatCollection(
|
||||||
|
IDatReaderWriter value) =>
|
||||||
|
PublishCompositionOwner(ref _dats, value, "DAT collection");
|
||||||
|
|
||||||
|
void IGameWindowContentEffectsAudioPublication.PublishMagicCatalog(
|
||||||
|
AcDream.App.Spells.MagicCatalog value) =>
|
||||||
|
PublishCompositionOwner(ref _magicCatalog, value, "magic catalog");
|
||||||
|
|
||||||
|
void IGameWindowContentEffectsAudioPublication.PublishAnimationLoader(
|
||||||
|
AcDream.Core.Physics.IAnimationLoader value) =>
|
||||||
|
PublishCompositionOwner(ref _animLoader, value, "animation loader");
|
||||||
|
|
||||||
|
void IGameWindowContentEffectsAudioPublication.PublishLiveEntityCollisionBuilder(
|
||||||
|
AcDream.App.Physics.LiveEntityCollisionBuilder value) =>
|
||||||
|
PublishCompositionOwner(
|
||||||
|
ref _liveEntityCollisionBuilder,
|
||||||
|
value,
|
||||||
|
"live-entity collision builder");
|
||||||
|
|
||||||
|
void IGameWindowContentEffectsAudioPublication.PublishEmitterRegistry(
|
||||||
|
AcDream.Core.Vfx.EmitterDescRegistry value) =>
|
||||||
|
PublishCompositionOwner(ref _emitterRegistry, value, "emitter registry");
|
||||||
|
|
||||||
|
void IGameWindowContentEffectsAudioPublication.PublishParticleSystem(
|
||||||
|
AcDream.Core.Vfx.ParticleSystem value) =>
|
||||||
|
PublishCompositionOwner(ref _particleSystem, value, "particle system");
|
||||||
|
|
||||||
|
void IGameWindowContentEffectsAudioPublication.PublishParticleSink(
|
||||||
|
AcDream.Core.Vfx.ParticleHookSink value) =>
|
||||||
|
PublishCompositionOwner(ref _particleSink, value, "particle hook sink");
|
||||||
|
|
||||||
|
void IGameWindowContentEffectsAudioPublication.PublishAnimationHookFrames(
|
||||||
|
AcDream.App.Rendering.Vfx.AnimationHookFrameQueue value) =>
|
||||||
|
PublishCompositionOwner(
|
||||||
|
ref _animationHookFrames,
|
||||||
|
value,
|
||||||
|
"animation-hook frame queue");
|
||||||
|
|
||||||
|
void IGameWindowContentEffectsAudioPublication.PublishPhysicsScriptLoader(
|
||||||
|
AcDream.Content.Vfx.RetailPhysicsScriptLoader value) =>
|
||||||
|
PublishCompositionOwner(
|
||||||
|
ref _physicsScriptLoader,
|
||||||
|
value,
|
||||||
|
"physics-script loader");
|
||||||
|
|
||||||
|
void IGameWindowContentEffectsAudioPublication.PublishPhysicsScriptRunner(
|
||||||
|
AcDream.Core.Vfx.PhysicsScriptRunner value) =>
|
||||||
|
PublishCompositionOwner(ref _scriptRunner, value, "physics-script runner");
|
||||||
|
|
||||||
|
void IGameWindowContentEffectsAudioPublication.PublishLightingSink(
|
||||||
|
AcDream.Core.Lighting.LightingHookSink value) =>
|
||||||
|
PublishCompositionOwner(ref _lightingSink, value, "lighting hook sink");
|
||||||
|
|
||||||
|
void IGameWindowContentEffectsAudioPublication.PublishTranslucencySink(
|
||||||
|
AcDream.Core.Rendering.TranslucencyHookSink value) =>
|
||||||
|
PublishCompositionOwner(
|
||||||
|
ref _translucencySink,
|
||||||
|
value,
|
||||||
|
"translucency hook sink");
|
||||||
|
|
||||||
|
void IGameWindowContentEffectsAudioPublication.PublishHookRegistrations(
|
||||||
|
AcDream.App.Composition.AnimationHookRegistrationSet value) =>
|
||||||
|
PublishCompositionOwner(
|
||||||
|
ref _hookRegistrations,
|
||||||
|
value,
|
||||||
|
"animation-hook registrations");
|
||||||
|
|
||||||
|
void IGameWindowContentEffectsAudioPublication.PublishAudio(
|
||||||
|
ContentAudioGraph value)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(value);
|
||||||
|
if (_soundCache is not null
|
||||||
|
|| _audioEngine is not null
|
||||||
|
|| _entitySoundTables is not null
|
||||||
|
|| _audioSink is not null)
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException(
|
||||||
|
"The GameWindow composition shell already owns audio state.");
|
||||||
|
}
|
||||||
|
|
||||||
|
_soundCache = value.SoundCache;
|
||||||
|
_audioEngine = value.Engine;
|
||||||
|
_entitySoundTables = value.EntitySoundTables;
|
||||||
|
_audioSink = value.HookSink;
|
||||||
|
}
|
||||||
|
|
||||||
private static void PublishCompositionOwner<T>(
|
private static void PublishCompositionOwner<T>(
|
||||||
ref T? destination,
|
ref T? destination,
|
||||||
T value,
|
T value,
|
||||||
|
|
@ -748,6 +839,23 @@ public sealed class GameWindow :
|
||||||
WorldRenderDiagnostics worldRenderDiagnostics =
|
WorldRenderDiagnostics worldRenderDiagnostics =
|
||||||
hostInputCamera.WorldRenderDiagnostics;
|
hostInputCamera.WorldRenderDiagnostics;
|
||||||
|
|
||||||
|
ContentEffectsAudioResult contentEffectsAudio =
|
||||||
|
new ContentEffectsAudioCompositionPhase(
|
||||||
|
new ContentEffectsAudioDependencies(
|
||||||
|
_datDir,
|
||||||
|
_physicsDataCache,
|
||||||
|
_animationDiagnostics.DumpMotionEnabled,
|
||||||
|
SpellBook,
|
||||||
|
_hookRouter,
|
||||||
|
_effectPoses,
|
||||||
|
_entityEffectAdvance,
|
||||||
|
Lighting,
|
||||||
|
_translucencyFades,
|
||||||
|
_options.NoAudio,
|
||||||
|
Console.WriteLine,
|
||||||
|
Console.Error.WriteLine),
|
||||||
|
this).Compose(platform, hostInputCamera);
|
||||||
|
|
||||||
_gl.ClearColor(0.05f, 0.10f, 0.18f, 1.0f);
|
_gl.ClearColor(0.05f, 0.10f, 0.18f, 1.0f);
|
||||||
_gl.Enable(EnableCap.DepthTest);
|
_gl.Enable(EnableCap.DepthTest);
|
||||||
|
|
||||||
|
|
@ -798,80 +906,6 @@ public sealed class GameWindow :
|
||||||
Console.WriteLine("world-hud font: no system monospace font found");
|
Console.WriteLine("world-hud font: no system monospace font found");
|
||||||
}
|
}
|
||||||
|
|
||||||
_dats = RuntimeDatCollectionFactory.OpenReadOnly(_datDir);
|
|
||||||
_magicCatalog = AcDream.App.Spells.MagicCatalog.Load(_dats);
|
|
||||||
SpellBook.InstallMetadata(_magicCatalog.SpellTable);
|
|
||||||
Console.WriteLine($"spells: loaded {SpellTable.Count} entries from portal.dat");
|
|
||||||
_animLoader = new AcDream.Content.Vfx.RetailAnimationLoader(_dats);
|
|
||||||
_liveEntityCollisionBuilder = new AcDream.App.Physics.LiveEntityCollisionBuilder(
|
|
||||||
_physicsDataCache,
|
|
||||||
new AcDream.App.Physics.LiveEntityDefaultPoseResolver(
|
|
||||||
id => _dats.Get<DatReaderWriter.DBObjs.MotionTable>(id),
|
|
||||||
_animLoader,
|
|
||||||
_animationDiagnostics.DumpMotionEnabled));
|
|
||||||
_emitterRegistry = new AcDream.Core.Vfx.EmitterDescRegistry(_dats);
|
|
||||||
|
|
||||||
// Phase E.3 particles: always-on, no driver dependency. Registered
|
|
||||||
// with the hook router so CreateParticle / DestroyParticle /
|
|
||||||
// StopParticle hooks fired from motion tables produce visible
|
|
||||||
// spawns. The Tick call is driven from OnRender.
|
|
||||||
_particleSystem = new AcDream.Core.Vfx.ParticleSystem(_emitterRegistry!);
|
|
||||||
_particleSink = new AcDream.Core.Vfx.ParticleHookSink(_particleSystem, _effectPoses);
|
|
||||||
_particleSink.DiagnosticSink = message =>
|
|
||||||
Console.Error.WriteLine($"vfx: {message}");
|
|
||||||
_animationHookFrames = new AcDream.App.Rendering.Vfx.AnimationHookFrameQueue(
|
|
||||||
_hookRouter,
|
|
||||||
_effectPoses);
|
|
||||||
_hookRouter.Register(_particleSink);
|
|
||||||
|
|
||||||
// Phase 6c — PhysicsScript runner. Uses the DatCollection to
|
|
||||||
// resolve PlayScript ids, and the same ParticleHookSink the
|
|
||||||
// animation system uses, so CreateParticleHook fired from a
|
|
||||||
// script spawns through the normal particle pipeline.
|
|
||||||
_physicsScriptLoader = new AcDream.Content.Vfx.RetailPhysicsScriptLoader(_dats);
|
|
||||||
_scriptRunner = new AcDream.Core.Vfx.PhysicsScriptRunner(
|
|
||||||
_physicsScriptLoader.LoadPhysicsScript,
|
|
||||||
_hookRouter,
|
|
||||||
canAdvanceOwner: ownerId => _entityEffects?.CanAdvanceOwner(ownerId) ?? true);
|
|
||||||
|
|
||||||
// Phase G.2 lighting hooks: SetLightHook flips IsLit on
|
|
||||||
// owner-tagged lights so ignite-torch animations light up,
|
|
||||||
// extinguish-torch animations go dark.
|
|
||||||
_lightingSink = new AcDream.Core.Lighting.LightingHookSink(Lighting, _effectPoses);
|
|
||||||
_hookRouter.Register(_lightingSink);
|
|
||||||
|
|
||||||
// #188 — TransparentPartHook (per-part translucency fade, e.g.
|
|
||||||
// the "fading wall" secret-passage doors) routes here.
|
|
||||||
_translucencySink = new AcDream.Core.Rendering.TranslucencyHookSink(_translucencyFades);
|
|
||||||
_hookRouter.Register(_translucencySink);
|
|
||||||
|
|
||||||
// Phase E.2 audio: init OpenAL + hook sink. Suppressible via
|
|
||||||
// ACDREAM_NO_AUDIO=1 for headless tests / broken audio drivers.
|
|
||||||
if (!_options.NoAudio)
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
_soundCache = new AcDream.Core.Audio.DatSoundCache(_dats);
|
|
||||||
_audioEngine = new AcDream.App.Audio.OpenAlAudioEngine();
|
|
||||||
_entitySoundTables = new AcDream.App.Audio.DictionaryEntitySoundTable();
|
|
||||||
if (_audioEngine.IsAvailable)
|
|
||||||
{
|
|
||||||
_audioSink = new AcDream.App.Audio.AudioHookSink(
|
|
||||||
_audioEngine, _soundCache, _entitySoundTables);
|
|
||||||
_hookRouter.Register(_audioSink);
|
|
||||||
Console.WriteLine("audio: OpenAL engine ready (16 voices, 3D positional)");
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
Console.WriteLine("audio: OpenAL unavailable (driver missing / headless) — audio disabled");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
Console.WriteLine($"audio: init failed: {ex.Message} — audio disabled");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Settings are runtime state, not devtools state. Apply the immutable
|
// Settings are runtime state, not devtools state. Apply the immutable
|
||||||
// pre-window snapshot once after camera/audio exist and before any
|
// pre-window snapshot once after camera/audio exist and before any
|
||||||
// optional frontend or world/render factory consumes it.
|
// optional frontend or world/render factory consumes it.
|
||||||
|
|
@ -1091,7 +1125,8 @@ public sealed class GameWindow :
|
||||||
uint centerLandblockId = 0xA9B4FFFFu;
|
uint centerLandblockId = 0xA9B4FFFFu;
|
||||||
Console.WriteLine($"loading world view centered on 0x{centerLandblockId:X8}");
|
Console.WriteLine($"loading world view centered on 0x{centerLandblockId:X8}");
|
||||||
|
|
||||||
var region = _dats.Get<DatReaderWriter.DBObjs.Region>(0x13000000u);
|
var region = contentEffectsAudio.Dats.Get<DatReaderWriter.DBObjs.Region>(
|
||||||
|
0x13000000u);
|
||||||
var heightTable = region?.LandDefs.LandHeightTable;
|
var heightTable = region?.LandDefs.LandHeightTable;
|
||||||
if (heightTable is null || heightTable.Length < 256)
|
if (heightTable is null || heightTable.Length < 256)
|
||||||
throw new InvalidOperationException("Region.LandDefs.LandHeightTable missing or truncated");
|
throw new InvalidOperationException("Region.LandDefs.LandHeightTable missing or truncated");
|
||||||
|
|
@ -1138,7 +1173,7 @@ public sealed class GameWindow :
|
||||||
var terrainAtlas = _renderResourceLifetime.AcquireTerrainAtlas(
|
var terrainAtlas = _renderResourceLifetime.AcquireTerrainAtlas(
|
||||||
() => AcDream.App.Rendering.TerrainAtlas.Build(
|
() => AcDream.App.Rendering.TerrainAtlas.Build(
|
||||||
_gl,
|
_gl,
|
||||||
_dats,
|
contentEffectsAudio.Dats,
|
||||||
_bindlessSupport));
|
_bindlessSupport));
|
||||||
// A.5 T22.5: apply anisotropic level from quality preset. Build()
|
// A.5 T22.5: apply anisotropic level from quality preset. Build()
|
||||||
// hard-codes 16x; override here to match the resolved quality so Low
|
// hard-codes 16x; override here to match the resolved quality so Low
|
||||||
|
|
@ -1188,7 +1223,11 @@ public sealed class GameWindow :
|
||||||
Path.Combine(shadersDir, "mesh_modern.frag"));
|
Path.Combine(shadersDir, "mesh_modern.frag"));
|
||||||
Console.WriteLine("[N.5] mesh_modern shader loaded");
|
Console.WriteLine("[N.5] mesh_modern shader loaded");
|
||||||
|
|
||||||
_textureCache = new TextureCache(_gl, _dats, _bindlessSupport, _gpuFrameFlights!);
|
_textureCache = new TextureCache(
|
||||||
|
_gl,
|
||||||
|
contentEffectsAudio.Dats,
|
||||||
|
_bindlessSupport,
|
||||||
|
_gpuFrameFlights!);
|
||||||
// Two persistent GL sampler objects (Repeat + ClampToEdge) so
|
// Two persistent GL sampler objects (Repeat + ClampToEdge) so
|
||||||
// the sky pass can pick wrap mode per submesh without mutating
|
// the sky pass can pick wrap mode per submesh without mutating
|
||||||
// shared per-texture wrap state. See SamplerCache + the
|
// shared per-texture wrap state. See SamplerCache + the
|
||||||
|
|
@ -1567,7 +1606,7 @@ public sealed class GameWindow :
|
||||||
var wbLogger = Microsoft.Extensions.Logging.Abstractions.NullLogger<AcDream.App.Rendering.Wb.WbMeshAdapter>.Instance;
|
var wbLogger = Microsoft.Extensions.Logging.Abstractions.NullLogger<AcDream.App.Rendering.Wb.WbMeshAdapter>.Instance;
|
||||||
_wbMeshAdapter = new AcDream.App.Rendering.Wb.WbMeshAdapter(
|
_wbMeshAdapter = new AcDream.App.Rendering.Wb.WbMeshAdapter(
|
||||||
_gl,
|
_gl,
|
||||||
_dats,
|
contentEffectsAudio.Dats,
|
||||||
wbLogger,
|
wbLogger,
|
||||||
_gpuFrameFlights!);
|
_gpuFrameFlights!);
|
||||||
Console.WriteLine("[N.4+N.5] WB foundation + modern path active — routing all content through ObjectMeshManager.");
|
Console.WriteLine("[N.4+N.5] WB foundation + modern path active — routing all content through ObjectMeshManager.");
|
||||||
|
|
@ -1825,6 +1864,7 @@ public sealed class GameWindow :
|
||||||
_entitySoundTables?.Set(ownerId, did);
|
_entitySoundTables?.Set(ownerId, did);
|
||||||
});
|
});
|
||||||
_entityEffects = entityEffects;
|
_entityEffects = entityEffects;
|
||||||
|
_entityEffectAdvance.Bind(entityEffects);
|
||||||
entityEffects.DiagnosticSink = message =>
|
entityEffects.DiagnosticSink = message =>
|
||||||
Console.Error.WriteLine($"vfx: {message}");
|
Console.Error.WriteLine($"vfx: {message}");
|
||||||
var partArrayLifecycle =
|
var partArrayLifecycle =
|
||||||
|
|
@ -1855,7 +1895,7 @@ public sealed class GameWindow :
|
||||||
remoteTeleportPresentation.Begin);
|
remoteTeleportPresentation.Begin);
|
||||||
_equippedChildRenderer.ProjectionPoseReady += guid =>
|
_equippedChildRenderer.ProjectionPoseReady += guid =>
|
||||||
_liveEntityLights.OnAttachedPoseReady(guid);
|
_liveEntityLights.OnAttachedPoseReady(guid);
|
||||||
_hookRouter.Register(entityEffects);
|
_hookRegistrations!.Register(entityEffects);
|
||||||
|
|
||||||
_wbDrawDispatcher = new AcDream.App.Rendering.Wb.WbDrawDispatcher(
|
_wbDrawDispatcher = new AcDream.App.Rendering.Wb.WbDrawDispatcher(
|
||||||
_gl, _meshShader!, _textureCache!, _wbMeshAdapter!, _wbEntitySpawnAdapter, _bindlessSupport!,
|
_gl, _meshShader!, _textureCache!, _wbMeshAdapter!, _wbEntitySpawnAdapter, _bindlessSupport!,
|
||||||
|
|
@ -2032,7 +2072,11 @@ public sealed class GameWindow :
|
||||||
Path.Combine(shadersDir, "sky.vert"),
|
Path.Combine(shadersDir, "sky.vert"),
|
||||||
Path.Combine(shadersDir, "sky.frag")));
|
Path.Combine(shadersDir, "sky.frag")));
|
||||||
_skyRenderer = new AcDream.App.Rendering.Sky.SkyRenderer(
|
_skyRenderer = new AcDream.App.Rendering.Sky.SkyRenderer(
|
||||||
_gl, _dats, skyShader, _textureCache!, _samplerCache);
|
_gl,
|
||||||
|
contentEffectsAudio.Dats,
|
||||||
|
skyShader,
|
||||||
|
_textureCache!,
|
||||||
|
_samplerCache);
|
||||||
|
|
||||||
// Phase G.1 particle renderer — renders rain / snow / spell auras
|
// Phase G.1 particle renderer — renders rain / snow / spell auras
|
||||||
// spawned into the shared ParticleSystem. Mode-1/no-degrade GfxObjs
|
// spawned into the shared ParticleSystem. Mode-1/no-degrade GfxObjs
|
||||||
|
|
@ -2041,9 +2085,9 @@ public sealed class GameWindow :
|
||||||
_particleRenderer = new ParticleRenderer(
|
_particleRenderer = new ParticleRenderer(
|
||||||
_gl,
|
_gl,
|
||||||
shadersDir,
|
shadersDir,
|
||||||
_particleSystem,
|
contentEffectsAudio.ParticleSystem,
|
||||||
_textureCache,
|
_textureCache,
|
||||||
_dats,
|
contentEffectsAudio.Dats,
|
||||||
_wbMeshAdapter,
|
_wbMeshAdapter,
|
||||||
_retailAlphaQueue);
|
_retailAlphaQueue);
|
||||||
|
|
||||||
|
|
@ -3540,6 +3584,28 @@ public sealed class GameWindow :
|
||||||
[
|
[
|
||||||
new("live entity runtime", () => _liveEntities?.Clear()),
|
new("live entity runtime", () => _liveEntities?.Clear()),
|
||||||
]),
|
]),
|
||||||
|
new ResourceShutdownStage("effect dispatch edges",
|
||||||
|
[
|
||||||
|
new("entity-effect advance source", () =>
|
||||||
|
{
|
||||||
|
if (_entityEffects is { } effects)
|
||||||
|
_entityEffectAdvance.Unbind(effects);
|
||||||
|
_entityEffectAdvance.Deactivate();
|
||||||
|
}),
|
||||||
|
new("animation-hook registrations", () =>
|
||||||
|
{
|
||||||
|
AnimationHookRegistrationSet? registrations = _hookRegistrations;
|
||||||
|
if (registrations is null)
|
||||||
|
return;
|
||||||
|
registrations.Dispose();
|
||||||
|
if (!registrations.IsCleanupComplete)
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException(
|
||||||
|
"Animation-hook registration cleanup remains pending.");
|
||||||
|
}
|
||||||
|
_hookRegistrations = null;
|
||||||
|
}),
|
||||||
|
]),
|
||||||
new ResourceShutdownStage("live entity dependents",
|
new ResourceShutdownStage("live entity dependents",
|
||||||
[
|
[
|
||||||
new("live lights", () =>
|
new("live lights", () =>
|
||||||
|
|
@ -3559,7 +3625,22 @@ public sealed class GameWindow :
|
||||||
_animationHookFrames?.Clear();
|
_animationHookFrames?.Clear();
|
||||||
_effectPoses.Clear();
|
_effectPoses.Clear();
|
||||||
}),
|
}),
|
||||||
new("audio", () => _audioEngine?.Dispose()),
|
new("audio", () =>
|
||||||
|
{
|
||||||
|
AcDream.App.Audio.OpenAlAudioEngine? engine = _audioEngine;
|
||||||
|
if (engine is null)
|
||||||
|
return;
|
||||||
|
engine.Dispose();
|
||||||
|
if (!engine.IsDisposalComplete)
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException(
|
||||||
|
"OpenAL native-resource cleanup remains pending.");
|
||||||
|
}
|
||||||
|
_audioEngine = null;
|
||||||
|
_audioSink = null;
|
||||||
|
_soundCache = null;
|
||||||
|
_entitySoundTables = null;
|
||||||
|
}),
|
||||||
]),
|
]),
|
||||||
new ResourceShutdownStage("submitted GPU work",
|
new ResourceShutdownStage("submitted GPU work",
|
||||||
[
|
[
|
||||||
|
|
|
||||||
63
src/AcDream.App/Rendering/Vfx/EntityEffectAdvanceSource.cs
Normal file
63
src/AcDream.App/Rendering/Vfx/EntityEffectAdvanceSource.cs
Normal file
|
|
@ -0,0 +1,63 @@
|
||||||
|
namespace AcDream.App.Rendering.Vfx;
|
||||||
|
|
||||||
|
internal interface IEntityEffectAdvanceSource
|
||||||
|
{
|
||||||
|
bool CanAdvanceOwner(uint ownerLocalId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Focused late-binding edge between the Phase-2 PhysicsScript runner and the
|
||||||
|
/// Phase-6 live effect authority. Before live presentation exists, scripts are
|
||||||
|
/// unconstrained exactly as the former null-safe callback was; after binding,
|
||||||
|
/// the canonical effect owner decides whether its current pose can advance.
|
||||||
|
/// </summary>
|
||||||
|
internal sealed class DeferredEntityEffectAdvanceSource : IEntityEffectAdvanceSource
|
||||||
|
{
|
||||||
|
private readonly object _gate = new();
|
||||||
|
private IEntityEffectAdvanceSource? _target;
|
||||||
|
private bool _deactivated;
|
||||||
|
|
||||||
|
public void Bind(IEntityEffectAdvanceSource target)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(target);
|
||||||
|
lock (_gate)
|
||||||
|
{
|
||||||
|
ObjectDisposedException.ThrowIf(_deactivated, this);
|
||||||
|
if (_target is not null && !ReferenceEquals(_target, target))
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException(
|
||||||
|
"Entity-effect script advancement is already bound.");
|
||||||
|
}
|
||||||
|
|
||||||
|
_target = target;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Unbind(IEntityEffectAdvanceSource target)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(target);
|
||||||
|
lock (_gate)
|
||||||
|
{
|
||||||
|
if (ReferenceEquals(_target, target))
|
||||||
|
_target = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Deactivate()
|
||||||
|
{
|
||||||
|
lock (_gate)
|
||||||
|
{
|
||||||
|
_deactivated = true;
|
||||||
|
_target = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool CanAdvanceOwner(uint ownerLocalId)
|
||||||
|
{
|
||||||
|
lock (_gate)
|
||||||
|
{
|
||||||
|
return _deactivated
|
||||||
|
|| _target?.CanAdvanceOwner(ownerLocalId) != false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -22,7 +22,8 @@ namespace AcDream.App.Rendering.Vfx;
|
||||||
/// (<c>0x00513260</c>) and both <c>play_default_script</c> overloads
|
/// (<c>0x00513260</c>) and both <c>play_default_script</c> overloads
|
||||||
/// (<c>0x005132B0</c>, <c>0x00513300</c>).
|
/// (<c>0x005132B0</c>, <c>0x00513300</c>).
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
public sealed class EntityEffectController : IAnimationHookSink
|
public sealed class EntityEffectController : IAnimationHookSink,
|
||||||
|
IEntityEffectAdvanceSource
|
||||||
{
|
{
|
||||||
private readonly LiveEntityRuntime _liveEntities;
|
private readonly LiveEntityRuntime _liveEntities;
|
||||||
private readonly PhysicsScriptRunner _runner;
|
private readonly PhysicsScriptRunner _runner;
|
||||||
|
|
|
||||||
168
tests/AcDream.App.Tests/Audio/OpenAlResourceLifetimeTests.cs
Normal file
168
tests/AcDream.App.Tests/Audio/OpenAlResourceLifetimeTests.cs
Normal file
|
|
@ -0,0 +1,168 @@
|
||||||
|
using AcDream.App.Audio;
|
||||||
|
using Silk.NET.OpenAL;
|
||||||
|
|
||||||
|
namespace AcDream.App.Tests.Audio;
|
||||||
|
|
||||||
|
public sealed class OpenAlResourceLifetimeTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public void SuccessfulEngineConstructionOwnsAndReleasesEveryNativePrefixOnce()
|
||||||
|
{
|
||||||
|
var api = new RecordingApi();
|
||||||
|
var engine = new OpenAlAudioEngine(new Factory(api));
|
||||||
|
|
||||||
|
Assert.True(engine.IsAvailable);
|
||||||
|
Assert.Equal(20, api.GeneratedSources.Count);
|
||||||
|
Assert.Equal(16, api.Configured3D.Count);
|
||||||
|
Assert.Equal(4, api.ConfiguredUi.Count);
|
||||||
|
|
||||||
|
engine.Dispose();
|
||||||
|
engine.Dispose();
|
||||||
|
|
||||||
|
Assert.True(engine.IsDisposalComplete);
|
||||||
|
Assert.Equal(20, api.DeletedSources.Count);
|
||||||
|
Assert.Equal(
|
||||||
|
Enumerable.Range(1, 20).Reverse().Select(value => (uint)value),
|
||||||
|
api.DeletedSources);
|
||||||
|
Assert.Equal(1, api.ClearCurrentCalls);
|
||||||
|
Assert.Equal(1, api.DestroyContextCalls);
|
||||||
|
Assert.Equal(1, api.CloseDeviceCalls);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ConfigurationFailureRollsBackTheExactGeneratedSourcePrefix()
|
||||||
|
{
|
||||||
|
var api = new RecordingApi
|
||||||
|
{
|
||||||
|
ConfigureFailureSource = 3,
|
||||||
|
};
|
||||||
|
|
||||||
|
var engine = new OpenAlAudioEngine(new Factory(api));
|
||||||
|
|
||||||
|
Assert.False(engine.IsAvailable);
|
||||||
|
Assert.True(engine.IsDisposalComplete);
|
||||||
|
Assert.Equal([3u, 2u, 1u], api.DeletedSources);
|
||||||
|
Assert.Equal(1, api.DestroyContextCalls);
|
||||||
|
Assert.Equal(1, api.CloseDeviceCalls);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void IncompleteInitializationCleanupRemainsRetryableWithoutReplay()
|
||||||
|
{
|
||||||
|
var api = new RecordingApi
|
||||||
|
{
|
||||||
|
ConfigureFailureSource = 3,
|
||||||
|
DeleteFailureSource = 2,
|
||||||
|
};
|
||||||
|
|
||||||
|
OpenAlInitializationException failure = Assert.Throws<OpenAlInitializationException>(
|
||||||
|
() => new OpenAlAudioEngine(new Factory(api)));
|
||||||
|
|
||||||
|
Assert.False(failure.IsCleanupComplete);
|
||||||
|
Assert.Equal([3u, 1u], api.DeletedSources);
|
||||||
|
Assert.Equal(0, api.DestroyContextCalls);
|
||||||
|
Assert.Equal(0, api.CloseDeviceCalls);
|
||||||
|
|
||||||
|
api.DeleteFailureSource = null;
|
||||||
|
failure.RetryCleanup();
|
||||||
|
failure.RetryCleanup();
|
||||||
|
|
||||||
|
Assert.True(failure.IsCleanupComplete);
|
||||||
|
Assert.Equal([3u, 1u, 2u], api.DeletedSources);
|
||||||
|
Assert.Equal(1, api.DeletedSources.Count(source => source == 3u));
|
||||||
|
Assert.Equal(1, api.DeletedSources.Count(source => source == 1u));
|
||||||
|
Assert.Equal(1, api.DestroyContextCalls);
|
||||||
|
Assert.Equal(1, api.CloseDeviceCalls);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ContextCreationFailureClosesTheDeviceBeforeReturningUnavailable()
|
||||||
|
{
|
||||||
|
var api = new RecordingApi { ContextResult = 0 };
|
||||||
|
|
||||||
|
var engine = new OpenAlAudioEngine(new Factory(api));
|
||||||
|
|
||||||
|
Assert.False(engine.IsAvailable);
|
||||||
|
Assert.True(engine.IsDisposalComplete);
|
||||||
|
Assert.Empty(api.GeneratedSources);
|
||||||
|
Assert.Equal(0, api.DestroyContextCalls);
|
||||||
|
Assert.Equal(1, api.CloseDeviceCalls);
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class Factory(IOpenAlResourceApi api) : IOpenAlResourceApiFactory
|
||||||
|
{
|
||||||
|
public IOpenAlResourceApi Create() => api;
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class RecordingApi : IOpenAlResourceApi
|
||||||
|
{
|
||||||
|
private uint _nextSource = 1;
|
||||||
|
|
||||||
|
public AL? AudioApi => null;
|
||||||
|
public ALContext? ContextApi => null;
|
||||||
|
public nint DeviceResult { get; set; } = 101;
|
||||||
|
public nint ContextResult { get; set; } = 202;
|
||||||
|
public uint? ConfigureFailureSource { get; set; }
|
||||||
|
public uint? DeleteFailureSource { get; set; }
|
||||||
|
public List<uint> GeneratedSources { get; } = [];
|
||||||
|
public List<uint> Configured3D { get; } = [];
|
||||||
|
public List<uint> ConfiguredUi { get; } = [];
|
||||||
|
public List<uint> DeletedSources { get; } = [];
|
||||||
|
public int ClearCurrentCalls { get; private set; }
|
||||||
|
public int DestroyContextCalls { get; private set; }
|
||||||
|
public int CloseDeviceCalls { get; private set; }
|
||||||
|
|
||||||
|
public nint OpenDevice() => DeviceResult;
|
||||||
|
|
||||||
|
public nint CreateContext(nint device) => ContextResult;
|
||||||
|
|
||||||
|
public bool MakeContextCurrent(nint context)
|
||||||
|
{
|
||||||
|
if (context == 0)
|
||||||
|
ClearCurrentCalls++;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public uint GenerateSource()
|
||||||
|
{
|
||||||
|
uint source = _nextSource++;
|
||||||
|
GeneratedSources.Add(source);
|
||||||
|
return source;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Configure3DSource(uint source)
|
||||||
|
{
|
||||||
|
Configured3D.Add(source);
|
||||||
|
ThrowIfConfiguredFailure(source);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void ConfigureUiSource(uint source)
|
||||||
|
{
|
||||||
|
ConfiguredUi.Add(source);
|
||||||
|
ThrowIfConfiguredFailure(source);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void SelectRetailDistanceModel() { }
|
||||||
|
|
||||||
|
public void StopSource(uint source) { }
|
||||||
|
|
||||||
|
public void DeleteSource(uint source)
|
||||||
|
{
|
||||||
|
if (DeleteFailureSource == source)
|
||||||
|
throw new InvalidOperationException("synthetic delete failure");
|
||||||
|
DeletedSources.Add(source);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void DeleteBuffer(uint buffer) { }
|
||||||
|
|
||||||
|
public void DestroyContext(nint context) => DestroyContextCalls++;
|
||||||
|
|
||||||
|
public void CloseDevice(nint device) => CloseDeviceCalls++;
|
||||||
|
|
||||||
|
private void ThrowIfConfiguredFailure(uint source)
|
||||||
|
{
|
||||||
|
if (ConfigureFailureSource == source)
|
||||||
|
throw new InvalidOperationException("synthetic configure failure");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,77 @@
|
||||||
|
using System.Numerics;
|
||||||
|
using AcDream.App.Composition;
|
||||||
|
using AcDream.Core.Physics;
|
||||||
|
using DatReaderWriter.Types;
|
||||||
|
|
||||||
|
namespace AcDream.App.Tests.Composition;
|
||||||
|
|
||||||
|
public sealed class AnimationHookRegistrationSetTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public void CleanupUnregistersInReverseAndNeverReplaysSuccessfulEdges()
|
||||||
|
{
|
||||||
|
var calls = new List<string>();
|
||||||
|
var first = new Sink("first");
|
||||||
|
var second = new Sink("second");
|
||||||
|
var third = new Sink("third");
|
||||||
|
bool failSecond = true;
|
||||||
|
var registrations = new AnimationHookRegistrationSet(
|
||||||
|
sink => calls.Add($"add:{((Sink)sink).Name}"),
|
||||||
|
sink =>
|
||||||
|
{
|
||||||
|
string name = ((Sink)sink).Name;
|
||||||
|
calls.Add($"remove:{name}");
|
||||||
|
if (name == "second" && failSecond)
|
||||||
|
throw new InvalidOperationException("synthetic removal failure");
|
||||||
|
});
|
||||||
|
registrations.Register(first);
|
||||||
|
registrations.Register(second);
|
||||||
|
registrations.Register(third);
|
||||||
|
|
||||||
|
Assert.Throws<AggregateException>(registrations.Dispose);
|
||||||
|
Assert.False(registrations.IsCleanupComplete);
|
||||||
|
failSecond = false;
|
||||||
|
registrations.Dispose();
|
||||||
|
registrations.Dispose();
|
||||||
|
|
||||||
|
Assert.True(registrations.IsCleanupComplete);
|
||||||
|
Assert.Equal(
|
||||||
|
[
|
||||||
|
"add:first", "add:second", "add:third",
|
||||||
|
"remove:third", "remove:second", "remove:first",
|
||||||
|
"remove:second",
|
||||||
|
],
|
||||||
|
calls);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void DuplicateRegistrationIsIdempotentAndClosedSetRejectsNewEdges()
|
||||||
|
{
|
||||||
|
int adds = 0;
|
||||||
|
int removes = 0;
|
||||||
|
var sink = new Sink("only");
|
||||||
|
var registrations = new AnimationHookRegistrationSet(
|
||||||
|
_ => adds++,
|
||||||
|
_ => removes++);
|
||||||
|
|
||||||
|
registrations.Register(sink);
|
||||||
|
registrations.Register(sink);
|
||||||
|
registrations.Dispose();
|
||||||
|
|
||||||
|
Assert.Equal(1, adds);
|
||||||
|
Assert.Equal(1, removes);
|
||||||
|
Assert.Throws<ObjectDisposedException>(() =>
|
||||||
|
registrations.Register(new Sink("late")));
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class Sink(string name) : IAnimationHookSink
|
||||||
|
{
|
||||||
|
public string Name { get; } = name;
|
||||||
|
public void OnHook(
|
||||||
|
uint entityId,
|
||||||
|
Vector3 entityWorldPosition,
|
||||||
|
AnimationHook hook)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,408 @@
|
||||||
|
using System.Reflection;
|
||||||
|
using System.Runtime.CompilerServices;
|
||||||
|
using AcDream.App.Audio;
|
||||||
|
using AcDream.App.Composition;
|
||||||
|
using AcDream.App.Physics;
|
||||||
|
using AcDream.App.Rendering;
|
||||||
|
using AcDream.App.Rendering.Vfx;
|
||||||
|
using AcDream.App.Spells;
|
||||||
|
using AcDream.Content;
|
||||||
|
using AcDream.Content.Vfx;
|
||||||
|
using AcDream.Core.Audio;
|
||||||
|
using AcDream.Core.Lighting;
|
||||||
|
using AcDream.Core.Physics;
|
||||||
|
using AcDream.Core.Rendering;
|
||||||
|
using AcDream.Core.Spells;
|
||||||
|
using AcDream.Core.Vfx;
|
||||||
|
using DatReaderWriter.DBObjs;
|
||||||
|
using Silk.NET.Input;
|
||||||
|
using Silk.NET.OpenAL;
|
||||||
|
using Silk.NET.OpenGL;
|
||||||
|
|
||||||
|
namespace AcDream.App.Tests.Composition;
|
||||||
|
|
||||||
|
public sealed class ContentEffectsAudioCompositionTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public void ProductionPhasePublishesExactOwnersAndRegistersHooksInRetailOrder()
|
||||||
|
{
|
||||||
|
using var fixture = new Fixture();
|
||||||
|
|
||||||
|
ContentEffectsAudioResult result = fixture.Phase().Compose(
|
||||||
|
fixture.Platform,
|
||||||
|
fixture.Host);
|
||||||
|
|
||||||
|
Assert.Equal(
|
||||||
|
Enum.GetValues<ContentEffectsAudioCompositionPoint>(),
|
||||||
|
fixture.Points);
|
||||||
|
Assert.Same(fixture.Publication.Dats, result.Dats);
|
||||||
|
Assert.Same(fixture.Publication.Magic, result.MagicCatalog);
|
||||||
|
Assert.Same(fixture.Publication.Animations, result.AnimationLoader);
|
||||||
|
Assert.Same(fixture.Publication.Particles, result.ParticleSystem);
|
||||||
|
Assert.Same(fixture.Publication.ScriptRunner, result.ScriptRunner);
|
||||||
|
Assert.Same(fixture.Publication.Audio, result.Audio);
|
||||||
|
Assert.Equal(
|
||||||
|
[
|
||||||
|
result.ParticleSink,
|
||||||
|
result.LightingSink,
|
||||||
|
result.TranslucencySink,
|
||||||
|
result.Audio!.HookSink!,
|
||||||
|
],
|
||||||
|
fixture.Router.Sinks);
|
||||||
|
Assert.Equal(4, result.HookRegistrations.ActiveCount);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void NoAudioAcquiresNothingFromTheOptionalFactoryPrefix()
|
||||||
|
{
|
||||||
|
using var fixture = new Fixture(noAudio: true);
|
||||||
|
|
||||||
|
ContentEffectsAudioResult result = fixture.Phase().Compose(
|
||||||
|
fixture.Platform,
|
||||||
|
fixture.Host);
|
||||||
|
|
||||||
|
Assert.Null(result.Audio);
|
||||||
|
Assert.Null(fixture.Publication.Audio);
|
||||||
|
Assert.Equal(0, fixture.Factory.AudioFactoryCalls);
|
||||||
|
Assert.DoesNotContain(
|
||||||
|
ContentEffectsAudioCompositionPoint.SoundCacheCreated,
|
||||||
|
fixture.Points);
|
||||||
|
Assert.Equal(3, fixture.Router.Sinks.Count);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData((int)ContentEffectsAudioCompositionPoint.SoundCacheCreated)]
|
||||||
|
[InlineData((int)ContentEffectsAudioCompositionPoint.AudioEngineCreated)]
|
||||||
|
[InlineData((int)ContentEffectsAudioCompositionPoint.EntitySoundTablesCreated)]
|
||||||
|
[InlineData((int)ContentEffectsAudioCompositionPoint.AudioSinkCreated)]
|
||||||
|
public void OptionalAudioPrefixFailureDisablesAudioOnlyAfterRollbackConverges(
|
||||||
|
int failurePointValue)
|
||||||
|
{
|
||||||
|
var failurePoint =
|
||||||
|
(ContentEffectsAudioCompositionPoint)failurePointValue;
|
||||||
|
using var fixture = new Fixture(failurePoint: failurePoint);
|
||||||
|
|
||||||
|
ContentEffectsAudioResult result = fixture.Phase().Compose(
|
||||||
|
fixture.Platform,
|
||||||
|
fixture.Host);
|
||||||
|
|
||||||
|
Assert.Null(result.Audio);
|
||||||
|
Assert.Null(fixture.Publication.Audio);
|
||||||
|
Assert.Equal(3, fixture.Router.Sinks.Count);
|
||||||
|
if (fixture.Factory.LastAudioEngine is { } engine)
|
||||||
|
Assert.True(engine.IsDisposalComplete);
|
||||||
|
Assert.Contains(fixture.Logs, message =>
|
||||||
|
message.Contains("audio: init failed", StringComparison.Ordinal));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void FailureAfterAtomicAudioPublicationPropagatesToLifetimeOwner()
|
||||||
|
{
|
||||||
|
using var fixture = new Fixture(
|
||||||
|
failurePoint: ContentEffectsAudioCompositionPoint.AudioPublished);
|
||||||
|
|
||||||
|
Assert.Throws<InvalidOperationException>(() => fixture.Phase().Compose(
|
||||||
|
fixture.Platform,
|
||||||
|
fixture.Host));
|
||||||
|
|
||||||
|
Assert.NotNull(fixture.Publication.Audio);
|
||||||
|
Assert.False(fixture.Publication.Audio!.Engine.IsDisposalComplete);
|
||||||
|
Assert.Equal(3, fixture.Router.Sinks.Count);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[MemberData(nameof(RequiredFailurePoints))]
|
||||||
|
public void RequiredBoundaryFailureStopsAtTheExactPointAndNeverRunsASuffix(
|
||||||
|
int failurePointValue)
|
||||||
|
{
|
||||||
|
var failurePoint =
|
||||||
|
(ContentEffectsAudioCompositionPoint)failurePointValue;
|
||||||
|
using var fixture = new Fixture(failurePoint: failurePoint);
|
||||||
|
|
||||||
|
Assert.Throws<InvalidOperationException>(() => fixture.Phase().Compose(
|
||||||
|
fixture.Platform,
|
||||||
|
fixture.Host));
|
||||||
|
|
||||||
|
Assert.Equal(
|
||||||
|
Enum.GetValues<ContentEffectsAudioCompositionPoint>()
|
||||||
|
.TakeWhile(point => point <= failurePoint),
|
||||||
|
fixture.Points);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static TheoryData<int> RequiredFailurePoints()
|
||||||
|
{
|
||||||
|
var data = new TheoryData<int>();
|
||||||
|
foreach (ContentEffectsAudioCompositionPoint point in
|
||||||
|
Enum.GetValues<ContentEffectsAudioCompositionPoint>())
|
||||||
|
{
|
||||||
|
if (point is >= ContentEffectsAudioCompositionPoint.SoundCacheCreated
|
||||||
|
and <= ContentEffectsAudioCompositionPoint.AudioSinkCreated)
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
data.Add((int)point);
|
||||||
|
}
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void GameWindowUsesTheProductionPhaseAndNoLongerConstructsItsBodyInline()
|
||||||
|
{
|
||||||
|
string source = File.ReadAllText(Path.Combine(
|
||||||
|
FindRepoRoot(),
|
||||||
|
"src",
|
||||||
|
"AcDream.App",
|
||||||
|
"Rendering",
|
||||||
|
"GameWindow.cs"));
|
||||||
|
|
||||||
|
Assert.Contains("new ContentEffectsAudioCompositionPhase(", source,
|
||||||
|
StringComparison.Ordinal);
|
||||||
|
Assert.DoesNotContain(
|
||||||
|
"_dats = RuntimeDatCollectionFactory.OpenReadOnly(_datDir);",
|
||||||
|
source,
|
||||||
|
StringComparison.Ordinal);
|
||||||
|
Assert.DoesNotContain("_hookRouter.Register(_particleSink)", source,
|
||||||
|
StringComparison.Ordinal);
|
||||||
|
Assert.DoesNotContain("new AcDream.App.Audio.OpenAlAudioEngine()", source,
|
||||||
|
StringComparison.Ordinal);
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class Fixture : IDisposable
|
||||||
|
{
|
||||||
|
private readonly ContentEffectsAudioCompositionPoint? _failurePoint;
|
||||||
|
|
||||||
|
public Fixture(
|
||||||
|
bool noAudio = false,
|
||||||
|
ContentEffectsAudioCompositionPoint? failurePoint = null)
|
||||||
|
{
|
||||||
|
_failurePoint = failurePoint;
|
||||||
|
Router = new AnimationHookRouter();
|
||||||
|
Poses = new EntityEffectPoseRegistry();
|
||||||
|
Factory = new Factory();
|
||||||
|
Publication = new Publication();
|
||||||
|
Platform = new GameWindowPlatformResult<GL, IInputContext>(null!, null!);
|
||||||
|
Host = (HostInputCameraResult)RuntimeHelpers.GetUninitializedObject(
|
||||||
|
typeof(HostInputCameraResult));
|
||||||
|
Dependencies = new ContentEffectsAudioDependencies(
|
||||||
|
"test-dat",
|
||||||
|
new PhysicsDataCache(),
|
||||||
|
false,
|
||||||
|
new Spellbook(),
|
||||||
|
Router,
|
||||||
|
Poses,
|
||||||
|
new DeferredEntityEffectAdvanceSource(),
|
||||||
|
new LightManager(),
|
||||||
|
new TranslucencyFadeManager(),
|
||||||
|
noAudio,
|
||||||
|
Logs.Add,
|
||||||
|
Logs.Add);
|
||||||
|
}
|
||||||
|
|
||||||
|
public List<ContentEffectsAudioCompositionPoint> Points { get; } = [];
|
||||||
|
public List<string> Logs { get; } = [];
|
||||||
|
public AnimationHookRouter Router { get; }
|
||||||
|
public EntityEffectPoseRegistry Poses { get; }
|
||||||
|
public Factory Factory { get; }
|
||||||
|
public Publication Publication { get; }
|
||||||
|
public ContentEffectsAudioDependencies Dependencies { get; }
|
||||||
|
public GameWindowPlatformResult<GL, IInputContext> Platform { get; }
|
||||||
|
public HostInputCameraResult Host { get; }
|
||||||
|
|
||||||
|
public ContentEffectsAudioCompositionPhase Phase() => new(
|
||||||
|
Dependencies,
|
||||||
|
Publication,
|
||||||
|
Factory,
|
||||||
|
point =>
|
||||||
|
{
|
||||||
|
Points.Add(point);
|
||||||
|
if (_failurePoint == point)
|
||||||
|
throw new InvalidOperationException($"fault at {point}");
|
||||||
|
});
|
||||||
|
|
||||||
|
public void Dispose() => Publication.Dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class Publication :
|
||||||
|
IGameWindowContentEffectsAudioPublication,
|
||||||
|
IDisposable
|
||||||
|
{
|
||||||
|
public IDatReaderWriter? Dats { get; private set; }
|
||||||
|
public MagicCatalog? Magic { get; private set; }
|
||||||
|
public IAnimationLoader? Animations { get; private set; }
|
||||||
|
public LiveEntityCollisionBuilder? Collision { get; private set; }
|
||||||
|
public EmitterDescRegistry? Emitters { get; private set; }
|
||||||
|
public ParticleSystem? Particles { get; private set; }
|
||||||
|
public ParticleHookSink? ParticleSink { get; private set; }
|
||||||
|
public AnimationHookFrameQueue? HookFrames { get; private set; }
|
||||||
|
public RetailPhysicsScriptLoader? ScriptLoader { get; private set; }
|
||||||
|
public PhysicsScriptRunner? ScriptRunner { get; private set; }
|
||||||
|
public LightingHookSink? LightingSink { get; private set; }
|
||||||
|
public TranslucencyHookSink? TranslucencySink { get; private set; }
|
||||||
|
public AnimationHookRegistrationSet? Registrations { get; private set; }
|
||||||
|
public ContentAudioGraph? Audio { get; private set; }
|
||||||
|
|
||||||
|
public void PublishDatCollection(IDatReaderWriter value) => Dats = Once(Dats, value);
|
||||||
|
public void PublishMagicCatalog(MagicCatalog value) => Magic = Once(Magic, value);
|
||||||
|
public void PublishAnimationLoader(IAnimationLoader value) =>
|
||||||
|
Animations = Once(Animations, value);
|
||||||
|
public void PublishLiveEntityCollisionBuilder(LiveEntityCollisionBuilder value) =>
|
||||||
|
Collision = Once(Collision, value);
|
||||||
|
public void PublishEmitterRegistry(EmitterDescRegistry value) =>
|
||||||
|
Emitters = Once(Emitters, value);
|
||||||
|
public void PublishParticleSystem(ParticleSystem value) =>
|
||||||
|
Particles = Once(Particles, value);
|
||||||
|
public void PublishParticleSink(ParticleHookSink value) =>
|
||||||
|
ParticleSink = Once(ParticleSink, value);
|
||||||
|
public void PublishAnimationHookFrames(AnimationHookFrameQueue value) =>
|
||||||
|
HookFrames = Once(HookFrames, value);
|
||||||
|
public void PublishPhysicsScriptLoader(RetailPhysicsScriptLoader value) =>
|
||||||
|
ScriptLoader = Once(ScriptLoader, value);
|
||||||
|
public void PublishPhysicsScriptRunner(PhysicsScriptRunner value) =>
|
||||||
|
ScriptRunner = Once(ScriptRunner, value);
|
||||||
|
public void PublishLightingSink(LightingHookSink value) =>
|
||||||
|
LightingSink = Once(LightingSink, value);
|
||||||
|
public void PublishTranslucencySink(TranslucencyHookSink value) =>
|
||||||
|
TranslucencySink = Once(TranslucencySink, value);
|
||||||
|
public void PublishHookRegistrations(AnimationHookRegistrationSet value) =>
|
||||||
|
Registrations = Once(Registrations, value);
|
||||||
|
public void PublishAudio(ContentAudioGraph value) => Audio = Once(Audio, value);
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
Registrations?.Dispose();
|
||||||
|
Registrations = null;
|
||||||
|
Audio?.Engine.Dispose();
|
||||||
|
Audio = null;
|
||||||
|
Dats?.Dispose();
|
||||||
|
Dats = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static T Once<T>(T? current, T value) where T : class
|
||||||
|
{
|
||||||
|
if (current is not null)
|
||||||
|
throw new InvalidOperationException("duplicate publication");
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class Factory : IContentEffectsAudioCompositionFactory
|
||||||
|
{
|
||||||
|
private readonly IDatReaderWriter _dats =
|
||||||
|
DispatchProxy.Create<IDatReaderWriter, NullProxy>();
|
||||||
|
private readonly MagicCatalog _magic =
|
||||||
|
(MagicCatalog)RuntimeHelpers.GetUninitializedObject(typeof(MagicCatalog));
|
||||||
|
private readonly IAnimationLoader _animations = new NullAnimationLoader();
|
||||||
|
|
||||||
|
public int AudioFactoryCalls { get; private set; }
|
||||||
|
public OpenAlAudioEngine? LastAudioEngine { get; private set; }
|
||||||
|
|
||||||
|
public IDatReaderWriter OpenDatCollection(string datDirectory) => _dats;
|
||||||
|
public MagicCatalog LoadMagicCatalog(IDatReaderWriter dats) => _magic;
|
||||||
|
public void InstallSpellMetadata(Spellbook spellBook, MagicCatalog catalog) { }
|
||||||
|
public int GetSpellCount(MagicCatalog catalog) => 0;
|
||||||
|
public IAnimationLoader CreateAnimationLoader(IDatReaderWriter dats) => _animations;
|
||||||
|
|
||||||
|
public LiveEntityCollisionBuilder CreateCollisionBuilder(
|
||||||
|
PhysicsDataCache physicsData,
|
||||||
|
IDatReaderWriter dats,
|
||||||
|
IAnimationLoader animationLoader,
|
||||||
|
bool dumpMotionEnabled) =>
|
||||||
|
(LiveEntityCollisionBuilder)RuntimeHelpers.GetUninitializedObject(
|
||||||
|
typeof(LiveEntityCollisionBuilder));
|
||||||
|
|
||||||
|
public EmitterDescRegistry CreateEmitterRegistry(IDatReaderWriter dats) => new();
|
||||||
|
public ParticleSystem CreateParticleSystem(EmitterDescRegistry emitters) => new(emitters);
|
||||||
|
public ParticleHookSink CreateParticleSink(
|
||||||
|
ParticleSystem particles,
|
||||||
|
EntityEffectPoseRegistry poses) => new(particles, poses);
|
||||||
|
public AnimationHookFrameQueue CreateAnimationHookFrames(
|
||||||
|
AnimationHookRouter router,
|
||||||
|
EntityEffectPoseRegistry poses) => new(router, poses);
|
||||||
|
public AnimationHookRegistrationSet CreateHookRegistrations(
|
||||||
|
AnimationHookRouter router) => new(router);
|
||||||
|
public RetailPhysicsScriptLoader CreatePhysicsScriptLoader(IDatReaderWriter dats) =>
|
||||||
|
(RetailPhysicsScriptLoader)RuntimeHelpers.GetUninitializedObject(
|
||||||
|
typeof(RetailPhysicsScriptLoader));
|
||||||
|
public PhysicsScriptRunner CreatePhysicsScriptRunner(
|
||||||
|
RetailPhysicsScriptLoader loader,
|
||||||
|
AnimationHookRouter router,
|
||||||
|
IEntityEffectAdvanceSource advanceSource) =>
|
||||||
|
new(_ => null, router, canAdvanceOwner: advanceSource.CanAdvanceOwner);
|
||||||
|
public LightingHookSink CreateLightingSink(
|
||||||
|
LightManager lighting,
|
||||||
|
EntityEffectPoseRegistry poses) => new(lighting, poses);
|
||||||
|
public TranslucencyHookSink CreateTranslucencySink(
|
||||||
|
TranslucencyFadeManager fades) => new(fades);
|
||||||
|
public DatSoundCache CreateSoundCache(IDatReaderWriter dats) => new(dats);
|
||||||
|
|
||||||
|
public OpenAlAudioEngine CreateAudioEngine()
|
||||||
|
{
|
||||||
|
AudioFactoryCalls++;
|
||||||
|
LastAudioEngine = new OpenAlAudioEngine(
|
||||||
|
new AudioApiFactory(new FakeAudioApi()));
|
||||||
|
return LastAudioEngine;
|
||||||
|
}
|
||||||
|
|
||||||
|
public DictionaryEntitySoundTable CreateEntitySoundTables() => new();
|
||||||
|
public AudioHookSink CreateAudioSink(
|
||||||
|
OpenAlAudioEngine engine,
|
||||||
|
DatSoundCache cache,
|
||||||
|
DictionaryEntitySoundTable entitySoundTables) =>
|
||||||
|
new(engine, cache, entitySoundTables);
|
||||||
|
}
|
||||||
|
|
||||||
|
public class NullProxy : DispatchProxy
|
||||||
|
{
|
||||||
|
protected override object? Invoke(MethodInfo? targetMethod, object?[]? args)
|
||||||
|
{
|
||||||
|
if (targetMethod?.ReturnType == typeof(void))
|
||||||
|
return null;
|
||||||
|
Type returnType = targetMethod?.ReturnType ?? typeof(void);
|
||||||
|
return returnType.IsValueType
|
||||||
|
? Activator.CreateInstance(returnType)
|
||||||
|
: null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class NullAnimationLoader : IAnimationLoader
|
||||||
|
{
|
||||||
|
public Animation? LoadAnimation(uint id) => null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class AudioApiFactory(IOpenAlResourceApi api)
|
||||||
|
: IOpenAlResourceApiFactory
|
||||||
|
{
|
||||||
|
public IOpenAlResourceApi Create() => api;
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class FakeAudioApi : IOpenAlResourceApi
|
||||||
|
{
|
||||||
|
private uint _nextSource = 1;
|
||||||
|
public AL? AudioApi => null;
|
||||||
|
public ALContext? ContextApi => null;
|
||||||
|
public nint OpenDevice() => 1;
|
||||||
|
public nint CreateContext(nint device) => 2;
|
||||||
|
public bool MakeContextCurrent(nint context) => true;
|
||||||
|
public uint GenerateSource() => _nextSource++;
|
||||||
|
public void Configure3DSource(uint source) { }
|
||||||
|
public void ConfigureUiSource(uint source) { }
|
||||||
|
public void SelectRetailDistanceModel() { }
|
||||||
|
public void StopSource(uint source) { }
|
||||||
|
public void DeleteSource(uint source) { }
|
||||||
|
public void DeleteBuffer(uint buffer) { }
|
||||||
|
public void DestroyContext(nint context) { }
|
||||||
|
public void CloseDevice(nint device) { }
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string FindRepoRoot()
|
||||||
|
{
|
||||||
|
DirectoryInfo? directory = new(AppContext.BaseDirectory);
|
||||||
|
while (directory is not null)
|
||||||
|
{
|
||||||
|
if (File.Exists(Path.Combine(directory.FullName, "AcDream.slnx")))
|
||||||
|
return directory.FullName;
|
||||||
|
directory = directory.Parent;
|
||||||
|
}
|
||||||
|
throw new DirectoryNotFoundException("Could not find AcDream.slnx.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -54,7 +54,8 @@ public sealed class GameWindowSlice8BoundaryTests
|
||||||
"GameWindowPlatformResult<GL, IInputContext> platform = AcquirePlatform();",
|
"GameWindowPlatformResult<GL, IInputContext> platform = AcquirePlatform();",
|
||||||
"new HostInputCameraCompositionPhase(",
|
"new HostInputCameraCompositionPhase(",
|
||||||
"this).Compose(platform);",
|
"this).Compose(platform);",
|
||||||
"_dats = RuntimeDatCollectionFactory.OpenReadOnly(_datDir);",
|
"new ContentEffectsAudioCompositionPhase(",
|
||||||
|
"this).Compose(platform, hostInputCamera);",
|
||||||
"_runtimeSettings.ApplyStartup(",
|
"_runtimeSettings.ApplyStartup(",
|
||||||
"new RuntimeSettingsStartupTargets(",
|
"new RuntimeSettingsStartupTargets(",
|
||||||
"_uiHost = _retailUiLease.AcquireHost(",
|
"_uiHost = _retailUiLease.AcquireHost(",
|
||||||
|
|
@ -342,6 +343,7 @@ public sealed class GameWindowSlice8BoundaryTests
|
||||||
"new ResourceShutdownStage(\"frame borrowers\"",
|
"new ResourceShutdownStage(\"frame borrowers\"",
|
||||||
"new ResourceShutdownStage(\"session dependents\"",
|
"new ResourceShutdownStage(\"session dependents\"",
|
||||||
"new ResourceShutdownStage(\"live entities\"",
|
"new ResourceShutdownStage(\"live entities\"",
|
||||||
|
"new ResourceShutdownStage(\"effect dispatch edges\"",
|
||||||
"new ResourceShutdownStage(\"live entity dependents\"",
|
"new ResourceShutdownStage(\"live entity dependents\"",
|
||||||
"new ResourceShutdownStage(\"submitted GPU work\"",
|
"new ResourceShutdownStage(\"submitted GPU work\"",
|
||||||
"new ResourceShutdownStage(\"render frontends\"",
|
"new ResourceShutdownStage(\"render frontends\"",
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,43 @@
|
||||||
|
using AcDream.App.Rendering.Vfx;
|
||||||
|
|
||||||
|
namespace AcDream.App.Tests.Rendering.Vfx;
|
||||||
|
|
||||||
|
public sealed class EntityEffectAdvanceSourceTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public void DefaultsOpenThenForwardsOneExactOwnerAndUnbindsOnlyThatOwner()
|
||||||
|
{
|
||||||
|
var source = new DeferredEntityEffectAdvanceSource();
|
||||||
|
var first = new Target(false);
|
||||||
|
var other = new Target(true);
|
||||||
|
|
||||||
|
Assert.True(source.CanAdvanceOwner(42));
|
||||||
|
source.Bind(first);
|
||||||
|
Assert.False(source.CanAdvanceOwner(42));
|
||||||
|
source.Unbind(other);
|
||||||
|
Assert.False(source.CanAdvanceOwner(42));
|
||||||
|
source.Unbind(first);
|
||||||
|
Assert.True(source.CanAdvanceOwner(42));
|
||||||
|
source.Bind(other);
|
||||||
|
Assert.True(source.CanAdvanceOwner(42));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void RejectsASecondOwnerAndDeactivationIsTerminalAndOpenForShutdown()
|
||||||
|
{
|
||||||
|
var source = new DeferredEntityEffectAdvanceSource();
|
||||||
|
var first = new Target(false);
|
||||||
|
source.Bind(first);
|
||||||
|
|
||||||
|
Assert.Throws<InvalidOperationException>(() => source.Bind(new Target(true)));
|
||||||
|
source.Deactivate();
|
||||||
|
|
||||||
|
Assert.True(source.CanAdvanceOwner(1));
|
||||||
|
Assert.Throws<ObjectDisposedException>(() => source.Bind(first));
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class Target(bool result) : IEntityEffectAdvanceSource
|
||||||
|
{
|
||||||
|
public bool CanAdvanceOwner(uint ownerLocalId) => result;
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue