From 60a1698ce7047f0091914acd1a92b853244357e1 Mon Sep 17 00:00:00 2001 From: Erik Date: Wed, 22 Jul 2026 15:36:05 +0200 Subject: [PATCH] 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. --- src/AcDream.App/Audio/OpenAlAudioEngine.cs | 132 ++--- .../Audio/OpenAlResourceLifetime.cs | 335 +++++++++++++ .../AnimationHookRegistrationSet.cs | 105 ++++ .../ContentEffectsAudioComposition.cs | 466 ++++++++++++++++++ src/AcDream.App/Rendering/GameWindow.cs | 249 ++++++---- .../Vfx/EntityEffectAdvanceSource.cs | 63 +++ .../Rendering/Vfx/EntityEffectController.cs | 3 +- .../Audio/OpenAlResourceLifetimeTests.cs | 168 +++++++ .../AnimationHookRegistrationSetTests.cs | 77 +++ .../ContentEffectsAudioCompositionTests.cs | 408 +++++++++++++++ .../GameWindowSlice8BoundaryTests.cs | 4 +- .../Vfx/EntityEffectAdvanceSourceTests.cs | 43 ++ 12 files changed, 1901 insertions(+), 152 deletions(-) create mode 100644 src/AcDream.App/Audio/OpenAlResourceLifetime.cs create mode 100644 src/AcDream.App/Composition/AnimationHookRegistrationSet.cs create mode 100644 src/AcDream.App/Composition/ContentEffectsAudioComposition.cs create mode 100644 src/AcDream.App/Rendering/Vfx/EntityEffectAdvanceSource.cs create mode 100644 tests/AcDream.App.Tests/Audio/OpenAlResourceLifetimeTests.cs create mode 100644 tests/AcDream.App.Tests/Composition/AnimationHookRegistrationSetTests.cs create mode 100644 tests/AcDream.App.Tests/Composition/ContentEffectsAudioCompositionTests.cs create mode 100644 tests/AcDream.App.Tests/Rendering/Vfx/EntityEffectAdvanceSourceTests.cs 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; + } +}