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
|
||||
{
|
||||
// ── Backends ─────────────────────────────────────────────────────────────
|
||||
private readonly ALContext? _alc;
|
||||
private readonly AL? _al;
|
||||
private readonly Device* _device;
|
||||
private readonly Context* _context;
|
||||
private readonly bool _available;
|
||||
private AL? _al;
|
||||
private OpenAlResourceLifetime? _resources;
|
||||
private bool _available;
|
||||
private bool _disposed;
|
||||
|
||||
// ── Pools ────────────────────────────────────────────────────────────────
|
||||
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 OpenAlAudioEngine()
|
||||
: this(new SilkOpenAlResourceApiFactory())
|
||||
{
|
||||
}
|
||||
|
||||
internal OpenAlAudioEngine(IOpenAlResourceApiFactory apiFactory)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(apiFactory);
|
||||
IOpenAlResourceApi api;
|
||||
try
|
||||
{
|
||||
_alc = ALContext.GetApi(soft: true);
|
||||
_al = AL.GetApi(soft: true);
|
||||
_device = _alc.OpenDevice(string.Empty);
|
||||
if (_device == null)
|
||||
api = apiFactory.Create();
|
||||
}
|
||||
catch
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_al = api.AudioApi;
|
||||
_resources = new OpenAlResourceLifetime(api);
|
||||
try
|
||||
{
|
||||
if (!_resources.TryOpenDevice())
|
||||
{
|
||||
_available = false;
|
||||
return;
|
||||
}
|
||||
_context = _alc.CreateContext(_device, null);
|
||||
if (_context == null)
|
||||
if (!_resources.TryCreateContext())
|
||||
{
|
||||
_alc.CloseDevice(_device);
|
||||
_device = null;
|
||||
_available = false;
|
||||
DisableAfterInitializationFailure(
|
||||
new InvalidOperationException("OpenAL could not create a context."));
|
||||
return;
|
||||
}
|
||||
if (!_alc.MakeContextCurrent(_context))
|
||||
if (!_resources.TryMakeCurrent())
|
||||
{
|
||||
_alc.DestroyContext(_context);
|
||||
_context = null;
|
||||
_alc.CloseDevice(_device);
|
||||
_device = null;
|
||||
_available = false;
|
||||
DisableAfterInitializationFailure(
|
||||
new InvalidOperationException("OpenAL could not activate its context."));
|
||||
return;
|
||||
}
|
||||
|
||||
// Initialise 3D source pool.
|
||||
for (int i = 0; i < PoolSize3D; i++)
|
||||
{
|
||||
uint src = _al.GenSource();
|
||||
_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);
|
||||
uint src = _resources.Create3DSource();
|
||||
_pool3D[i] = new Slot3D { SourceId = src, InUse = false };
|
||||
}
|
||||
|
||||
|
|
@ -136,61 +139,57 @@ public sealed unsafe class OpenAlAudioEngine : IAudioEngine
|
|||
// ignore 3D position.
|
||||
for (int i = 0; i < PoolSizeUi; i++)
|
||||
{
|
||||
uint src = _al.GenSource();
|
||||
_al.SetSourceProperty(src, SourceBoolean.SourceRelative, true);
|
||||
_al.SetSourceProperty(src, SourceFloat.Gain, 1f);
|
||||
_al.SetSourceProperty(src, SourceBoolean.Looping, false);
|
||||
uint src = _resources.CreateUiSource();
|
||||
_poolUi[i] = src;
|
||||
}
|
||||
|
||||
// Global distance model = inverse-square clamped (classic retail feel).
|
||||
_al.DistanceModel(DistanceModel.InverseDistanceClamped);
|
||||
api.SelectRetailDistanceModel();
|
||||
|
||||
_available = true;
|
||||
}
|
||||
catch
|
||||
catch (OpenAlInitializationException)
|
||||
{
|
||||
// OpenAL driver unavailable (headless CI, missing libopenal).
|
||||
_available = false;
|
||||
throw;
|
||||
}
|
||||
catch (Exception failure)
|
||||
{
|
||||
DisableAfterInitializationFailure(failure);
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
{
|
||||
for (int i = 0; i < PoolSize3D; i++)
|
||||
{
|
||||
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);
|
||||
_resources.RetryCleanup();
|
||||
}
|
||||
catch { /* shutdown — ignore */ }
|
||||
catch (AggregateException cleanupFailure)
|
||||
{
|
||||
throw new OpenAlInitializationException(
|
||||
failure,
|
||||
_resources,
|
||||
cleanupFailure);
|
||||
}
|
||||
|
||||
_al = null;
|
||||
}
|
||||
|
||||
// ── IAudioEngine ─────────────────────────────────────────────────────────
|
||||
|
|
@ -339,10 +338,11 @@ public sealed unsafe class OpenAlAudioEngine : IAudioEngine
|
|||
if (_bufferByWaveId.TryGetValue(waveId, out var existing)) return existing;
|
||||
|
||||
uint buf = _al.GenBuffer();
|
||||
_resources!.OwnBuffer(buf);
|
||||
BufferFormat fmt = PickFormat(wave);
|
||||
if (fmt == 0)
|
||||
{
|
||||
_al.DeleteBuffer(buf);
|
||||
_resources.ReleaseBuffer(buf);
|
||||
_bufferByWaveId[waveId] = 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 :
|
||||
IDisposable,
|
||||
IGameWindowPlatformPublication<GL, IInputContext>,
|
||||
IGameWindowHostInputCameraPublication
|
||||
IGameWindowHostInputCameraPublication,
|
||||
IGameWindowContentEffectsAudioPublication
|
||||
{
|
||||
private static double ClientTimerNow() =>
|
||||
System.Diagnostics.Stopwatch.GetTimestamp()
|
||||
|
|
@ -251,6 +252,10 @@ public sealed class GameWindow :
|
|||
// register sinks at startup. The router is always non-null so the
|
||||
// per-entity tick loop can just call it unconditionally.
|
||||
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
|
||||
// failed to init; all three are set together.
|
||||
|
|
@ -693,6 +698,92 @@ public sealed class GameWindow :
|
|||
AcDream.App.Input.CameraPointerInputController value) =>
|
||||
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>(
|
||||
ref T? destination,
|
||||
T value,
|
||||
|
|
@ -748,6 +839,23 @@ public sealed class GameWindow :
|
|||
WorldRenderDiagnostics 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.Enable(EnableCap.DepthTest);
|
||||
|
||||
|
|
@ -798,80 +906,6 @@ public sealed class GameWindow :
|
|||
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
|
||||
// pre-window snapshot once after camera/audio exist and before any
|
||||
// optional frontend or world/render factory consumes it.
|
||||
|
|
@ -1091,7 +1125,8 @@ public sealed class GameWindow :
|
|||
uint centerLandblockId = 0xA9B4FFFFu;
|
||||
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;
|
||||
if (heightTable is null || heightTable.Length < 256)
|
||||
throw new InvalidOperationException("Region.LandDefs.LandHeightTable missing or truncated");
|
||||
|
|
@ -1138,7 +1173,7 @@ public sealed class GameWindow :
|
|||
var terrainAtlas = _renderResourceLifetime.AcquireTerrainAtlas(
|
||||
() => AcDream.App.Rendering.TerrainAtlas.Build(
|
||||
_gl,
|
||||
_dats,
|
||||
contentEffectsAudio.Dats,
|
||||
_bindlessSupport));
|
||||
// A.5 T22.5: apply anisotropic level from quality preset. Build()
|
||||
// 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"));
|
||||
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
|
||||
// the sky pass can pick wrap mode per submesh without mutating
|
||||
// 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;
|
||||
_wbMeshAdapter = new AcDream.App.Rendering.Wb.WbMeshAdapter(
|
||||
_gl,
|
||||
_dats,
|
||||
contentEffectsAudio.Dats,
|
||||
wbLogger,
|
||||
_gpuFrameFlights!);
|
||||
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);
|
||||
});
|
||||
_entityEffects = entityEffects;
|
||||
_entityEffectAdvance.Bind(entityEffects);
|
||||
entityEffects.DiagnosticSink = message =>
|
||||
Console.Error.WriteLine($"vfx: {message}");
|
||||
var partArrayLifecycle =
|
||||
|
|
@ -1855,7 +1895,7 @@ public sealed class GameWindow :
|
|||
remoteTeleportPresentation.Begin);
|
||||
_equippedChildRenderer.ProjectionPoseReady += guid =>
|
||||
_liveEntityLights.OnAttachedPoseReady(guid);
|
||||
_hookRouter.Register(entityEffects);
|
||||
_hookRegistrations!.Register(entityEffects);
|
||||
|
||||
_wbDrawDispatcher = new AcDream.App.Rendering.Wb.WbDrawDispatcher(
|
||||
_gl, _meshShader!, _textureCache!, _wbMeshAdapter!, _wbEntitySpawnAdapter, _bindlessSupport!,
|
||||
|
|
@ -2032,7 +2072,11 @@ public sealed class GameWindow :
|
|||
Path.Combine(shadersDir, "sky.vert"),
|
||||
Path.Combine(shadersDir, "sky.frag")));
|
||||
_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
|
||||
// spawned into the shared ParticleSystem. Mode-1/no-degrade GfxObjs
|
||||
|
|
@ -2041,9 +2085,9 @@ public sealed class GameWindow :
|
|||
_particleRenderer = new ParticleRenderer(
|
||||
_gl,
|
||||
shadersDir,
|
||||
_particleSystem,
|
||||
contentEffectsAudio.ParticleSystem,
|
||||
_textureCache,
|
||||
_dats,
|
||||
contentEffectsAudio.Dats,
|
||||
_wbMeshAdapter,
|
||||
_retailAlphaQueue);
|
||||
|
||||
|
|
@ -3540,6 +3584,28 @@ public sealed class GameWindow :
|
|||
[
|
||||
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("live lights", () =>
|
||||
|
|
@ -3559,7 +3625,22 @@ public sealed class GameWindow :
|
|||
_animationHookFrames?.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",
|
||||
[
|
||||
|
|
|
|||
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>0x005132B0</c>, <c>0x00513300</c>).
|
||||
/// </remarks>
|
||||
public sealed class EntityEffectController : IAnimationHookSink
|
||||
public sealed class EntityEffectController : IAnimationHookSink,
|
||||
IEntityEffectAdvanceSource
|
||||
{
|
||||
private readonly LiveEntityRuntime _liveEntities;
|
||||
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();",
|
||||
"new HostInputCameraCompositionPhase(",
|
||||
"this).Compose(platform);",
|
||||
"_dats = RuntimeDatCollectionFactory.OpenReadOnly(_datDir);",
|
||||
"new ContentEffectsAudioCompositionPhase(",
|
||||
"this).Compose(platform, hostInputCamera);",
|
||||
"_runtimeSettings.ApplyStartup(",
|
||||
"new RuntimeSettingsStartupTargets(",
|
||||
"_uiHost = _retailUiLease.AcquireHost(",
|
||||
|
|
@ -342,6 +343,7 @@ public sealed class GameWindowSlice8BoundaryTests
|
|||
"new ResourceShutdownStage(\"frame borrowers\"",
|
||||
"new ResourceShutdownStage(\"session dependents\"",
|
||||
"new ResourceShutdownStage(\"live entities\"",
|
||||
"new ResourceShutdownStage(\"effect dispatch edges\"",
|
||||
"new ResourceShutdownStage(\"live entity dependents\"",
|
||||
"new ResourceShutdownStage(\"submitted GPU work\"",
|
||||
"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