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."); } }