diff --git a/src/AcDream.App/Audio/OpenAlAudioEngine.cs b/src/AcDream.App/Audio/OpenAlAudioEngine.cs
index 2b4232ed..a89dff4d 100644
--- a/src/AcDream.App/Audio/OpenAlAudioEngine.cs
+++ b/src/AcDream.App/Audio/OpenAlAudioEngine.cs
@@ -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;
}
diff --git a/src/AcDream.App/Audio/OpenAlResourceLifetime.cs b/src/AcDream.App/Audio/OpenAlResourceLifetime.cs
new file mode 100644
index 00000000..e6a4b579
--- /dev/null
+++ b/src/AcDream.App/Audio/OpenAlResourceLifetime.cs
@@ -0,0 +1,335 @@
+using AcDream.App.Rendering;
+using Silk.NET.OpenAL;
+
+namespace AcDream.App.Audio;
+
+///
+/// Narrow native-resource surface used by the OpenAL construction transaction.
+/// Runtime playback still calls Silk's typed API directly.
+///
+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);
+}
+
+///
+/// Owns every native handle immediately after acquisition. Cleanup is
+/// all-attempted, reverse dependency ordered, retryable, and never replays a
+/// successful release.
+///
+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 _sources = [];
+ private readonly List _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? 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();
+}
diff --git a/src/AcDream.App/Composition/AnimationHookRegistrationSet.cs b/src/AcDream.App/Composition/AnimationHookRegistrationSet.cs
new file mode 100644
index 00000000..86de8d9e
--- /dev/null
+++ b/src/AcDream.App/Composition/AnimationHookRegistrationSet.cs
@@ -0,0 +1,105 @@
+using AcDream.App.Rendering;
+using AcDream.Core.Physics;
+
+namespace AcDream.App.Composition;
+
+///
+/// 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.
+///
+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 _register;
+ private readonly Action _unregister;
+ private readonly List _entries = [];
+ private bool _disposing;
+ private bool _closed;
+
+ public AnimationHookRegistrationSet(AnimationHookRouter router)
+ : this(
+ (router ?? throw new ArgumentNullException(nameof(router))).Register,
+ router.Unregister)
+ {
+ }
+
+ internal AnimationHookRegistrationSet(
+ Action register,
+ Action 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? 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);
+ }
+}
diff --git a/src/AcDream.App/Composition/ContentEffectsAudioComposition.cs b/src/AcDream.App/Composition/ContentEffectsAudioComposition.cs
new file mode 100644
index 00000000..85fb9e38
--- /dev/null
+++ b/src/AcDream.App/Composition/ContentEffectsAudioComposition.cs
@@ -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 Log,
+ Action 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(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,
+}
+
+///
+/// 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.
+///
+internal sealed class ContentEffectsAudioCompositionPhase :
+ IContentEffectsAudioCompositionPhase<
+ GameWindowPlatformResult,
+ HostInputCameraResult,
+ ContentEffectsAudioResult>
+{
+ private readonly ContentEffectsAudioDependencies _dependencies;
+ private readonly IGameWindowContentEffectsAudioPublication _publication;
+ private readonly IContentEffectsAudioCompositionFactory _factory;
+ private readonly Action? _faultInjection;
+
+ public ContentEffectsAudioCompositionPhase(
+ ContentEffectsAudioDependencies dependencies,
+ IGameWindowContentEffectsAudioPublication publication,
+ IContentEffectsAudioCompositionFactory? factory = null,
+ Action? 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 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
+ 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);
+}
diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs
index 33b27fb7..b7f0ba56 100644
--- a/src/AcDream.App/Rendering/GameWindow.cs
+++ b/src/AcDream.App/Rendering/GameWindow.cs
@@ -15,7 +15,8 @@ namespace AcDream.App.Rendering;
public sealed class GameWindow :
IDisposable,
IGameWindowPlatformPublication,
- 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(
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(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(0x13000000u);
+ var region = contentEffectsAudio.Dats.Get(
+ 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.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",
[
diff --git a/src/AcDream.App/Rendering/Vfx/EntityEffectAdvanceSource.cs b/src/AcDream.App/Rendering/Vfx/EntityEffectAdvanceSource.cs
new file mode 100644
index 00000000..a838ccc8
--- /dev/null
+++ b/src/AcDream.App/Rendering/Vfx/EntityEffectAdvanceSource.cs
@@ -0,0 +1,63 @@
+namespace AcDream.App.Rendering.Vfx;
+
+internal interface IEntityEffectAdvanceSource
+{
+ bool CanAdvanceOwner(uint ownerLocalId);
+}
+
+///
+/// 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.
+///
+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;
+ }
+ }
+}
diff --git a/src/AcDream.App/Rendering/Vfx/EntityEffectController.cs b/src/AcDream.App/Rendering/Vfx/EntityEffectController.cs
index a7be0842..982a66e9 100644
--- a/src/AcDream.App/Rendering/Vfx/EntityEffectController.cs
+++ b/src/AcDream.App/Rendering/Vfx/EntityEffectController.cs
@@ -22,7 +22,8 @@ namespace AcDream.App.Rendering.Vfx;
/// (0x00513260) and both play_default_script overloads
/// (0x005132B0, 0x00513300).
///
-public sealed class EntityEffectController : IAnimationHookSink
+public sealed class EntityEffectController : IAnimationHookSink,
+ IEntityEffectAdvanceSource
{
private readonly LiveEntityRuntime _liveEntities;
private readonly PhysicsScriptRunner _runner;
diff --git a/tests/AcDream.App.Tests/Audio/OpenAlResourceLifetimeTests.cs b/tests/AcDream.App.Tests/Audio/OpenAlResourceLifetimeTests.cs
new file mode 100644
index 00000000..f2c4e115
--- /dev/null
+++ b/tests/AcDream.App.Tests/Audio/OpenAlResourceLifetimeTests.cs
@@ -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(
+ () => 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 GeneratedSources { get; } = [];
+ public List Configured3D { get; } = [];
+ public List ConfiguredUi { get; } = [];
+ public List 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");
+ }
+ }
+}
diff --git a/tests/AcDream.App.Tests/Composition/AnimationHookRegistrationSetTests.cs b/tests/AcDream.App.Tests/Composition/AnimationHookRegistrationSetTests.cs
new file mode 100644
index 00000000..3c1f08f6
--- /dev/null
+++ b/tests/AcDream.App.Tests/Composition/AnimationHookRegistrationSetTests.cs
@@ -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();
+ 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(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(() =>
+ 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)
+ {
+ }
+ }
+}
diff --git a/tests/AcDream.App.Tests/Composition/ContentEffectsAudioCompositionTests.cs b/tests/AcDream.App.Tests/Composition/ContentEffectsAudioCompositionTests.cs
new file mode 100644
index 00000000..bfa350d7
--- /dev/null
+++ b/tests/AcDream.App.Tests/Composition/ContentEffectsAudioCompositionTests.cs
@@ -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(),
+ 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(() => 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(() => fixture.Phase().Compose(
+ fixture.Platform,
+ fixture.Host));
+
+ Assert.Equal(
+ Enum.GetValues()
+ .TakeWhile(point => point <= failurePoint),
+ fixture.Points);
+ }
+
+ public static TheoryData RequiredFailurePoints()
+ {
+ var data = new TheoryData();
+ foreach (ContentEffectsAudioCompositionPoint point in
+ Enum.GetValues())
+ {
+ 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(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 Points { get; } = [];
+ public List Logs { get; } = [];
+ public AnimationHookRouter Router { get; }
+ public EntityEffectPoseRegistry Poses { get; }
+ public Factory Factory { get; }
+ public Publication Publication { get; }
+ public ContentEffectsAudioDependencies Dependencies { get; }
+ public GameWindowPlatformResult 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? 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();
+ 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.");
+ }
+}
diff --git a/tests/AcDream.App.Tests/Rendering/GameWindowSlice8BoundaryTests.cs b/tests/AcDream.App.Tests/Rendering/GameWindowSlice8BoundaryTests.cs
index b1ac6495..2c2dd936 100644
--- a/tests/AcDream.App.Tests/Rendering/GameWindowSlice8BoundaryTests.cs
+++ b/tests/AcDream.App.Tests/Rendering/GameWindowSlice8BoundaryTests.cs
@@ -54,7 +54,8 @@ public sealed class GameWindowSlice8BoundaryTests
"GameWindowPlatformResult 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\"",
diff --git a/tests/AcDream.App.Tests/Rendering/Vfx/EntityEffectAdvanceSourceTests.cs b/tests/AcDream.App.Tests/Rendering/Vfx/EntityEffectAdvanceSourceTests.cs
new file mode 100644
index 00000000..c924d2cc
--- /dev/null
+++ b/tests/AcDream.App.Tests/Rendering/Vfx/EntityEffectAdvanceSourceTests.cs
@@ -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(() => source.Bind(new Target(true)));
+ source.Deactivate();
+
+ Assert.True(source.CanAdvanceOwner(1));
+ Assert.Throws(() => source.Bind(first));
+ }
+
+ private sealed class Target(bool result) : IEntityEffectAdvanceSource
+ {
+ public bool CanAdvanceOwner(uint ownerLocalId) => result;
+ }
+}