refactor(app): compose content effects and audio startup
Move Phase-2 startup behind typed composition/publication boundaries, replace the GameWindow-capturing PhysicsScript gate with a focused deferred source, and own animation-hook registrations reversibly. Make OpenAL construction and teardown transactional so every device, context, source, and buffer prefix is retryable without replay.
This commit is contained in:
parent
1d51e35c14
commit
60a1698ce7
12 changed files with 1901 additions and 152 deletions
168
tests/AcDream.App.Tests/Audio/OpenAlResourceLifetimeTests.cs
Normal file
168
tests/AcDream.App.Tests/Audio/OpenAlResourceLifetimeTests.cs
Normal file
|
|
@ -0,0 +1,168 @@
|
|||
using AcDream.App.Audio;
|
||||
using Silk.NET.OpenAL;
|
||||
|
||||
namespace AcDream.App.Tests.Audio;
|
||||
|
||||
public sealed class OpenAlResourceLifetimeTests
|
||||
{
|
||||
[Fact]
|
||||
public void SuccessfulEngineConstructionOwnsAndReleasesEveryNativePrefixOnce()
|
||||
{
|
||||
var api = new RecordingApi();
|
||||
var engine = new OpenAlAudioEngine(new Factory(api));
|
||||
|
||||
Assert.True(engine.IsAvailable);
|
||||
Assert.Equal(20, api.GeneratedSources.Count);
|
||||
Assert.Equal(16, api.Configured3D.Count);
|
||||
Assert.Equal(4, api.ConfiguredUi.Count);
|
||||
|
||||
engine.Dispose();
|
||||
engine.Dispose();
|
||||
|
||||
Assert.True(engine.IsDisposalComplete);
|
||||
Assert.Equal(20, api.DeletedSources.Count);
|
||||
Assert.Equal(
|
||||
Enumerable.Range(1, 20).Reverse().Select(value => (uint)value),
|
||||
api.DeletedSources);
|
||||
Assert.Equal(1, api.ClearCurrentCalls);
|
||||
Assert.Equal(1, api.DestroyContextCalls);
|
||||
Assert.Equal(1, api.CloseDeviceCalls);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ConfigurationFailureRollsBackTheExactGeneratedSourcePrefix()
|
||||
{
|
||||
var api = new RecordingApi
|
||||
{
|
||||
ConfigureFailureSource = 3,
|
||||
};
|
||||
|
||||
var engine = new OpenAlAudioEngine(new Factory(api));
|
||||
|
||||
Assert.False(engine.IsAvailable);
|
||||
Assert.True(engine.IsDisposalComplete);
|
||||
Assert.Equal([3u, 2u, 1u], api.DeletedSources);
|
||||
Assert.Equal(1, api.DestroyContextCalls);
|
||||
Assert.Equal(1, api.CloseDeviceCalls);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IncompleteInitializationCleanupRemainsRetryableWithoutReplay()
|
||||
{
|
||||
var api = new RecordingApi
|
||||
{
|
||||
ConfigureFailureSource = 3,
|
||||
DeleteFailureSource = 2,
|
||||
};
|
||||
|
||||
OpenAlInitializationException failure = Assert.Throws<OpenAlInitializationException>(
|
||||
() => new OpenAlAudioEngine(new Factory(api)));
|
||||
|
||||
Assert.False(failure.IsCleanupComplete);
|
||||
Assert.Equal([3u, 1u], api.DeletedSources);
|
||||
Assert.Equal(0, api.DestroyContextCalls);
|
||||
Assert.Equal(0, api.CloseDeviceCalls);
|
||||
|
||||
api.DeleteFailureSource = null;
|
||||
failure.RetryCleanup();
|
||||
failure.RetryCleanup();
|
||||
|
||||
Assert.True(failure.IsCleanupComplete);
|
||||
Assert.Equal([3u, 1u, 2u], api.DeletedSources);
|
||||
Assert.Equal(1, api.DeletedSources.Count(source => source == 3u));
|
||||
Assert.Equal(1, api.DeletedSources.Count(source => source == 1u));
|
||||
Assert.Equal(1, api.DestroyContextCalls);
|
||||
Assert.Equal(1, api.CloseDeviceCalls);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ContextCreationFailureClosesTheDeviceBeforeReturningUnavailable()
|
||||
{
|
||||
var api = new RecordingApi { ContextResult = 0 };
|
||||
|
||||
var engine = new OpenAlAudioEngine(new Factory(api));
|
||||
|
||||
Assert.False(engine.IsAvailable);
|
||||
Assert.True(engine.IsDisposalComplete);
|
||||
Assert.Empty(api.GeneratedSources);
|
||||
Assert.Equal(0, api.DestroyContextCalls);
|
||||
Assert.Equal(1, api.CloseDeviceCalls);
|
||||
}
|
||||
|
||||
private sealed class Factory(IOpenAlResourceApi api) : IOpenAlResourceApiFactory
|
||||
{
|
||||
public IOpenAlResourceApi Create() => api;
|
||||
}
|
||||
|
||||
private sealed class RecordingApi : IOpenAlResourceApi
|
||||
{
|
||||
private uint _nextSource = 1;
|
||||
|
||||
public AL? AudioApi => null;
|
||||
public ALContext? ContextApi => null;
|
||||
public nint DeviceResult { get; set; } = 101;
|
||||
public nint ContextResult { get; set; } = 202;
|
||||
public uint? ConfigureFailureSource { get; set; }
|
||||
public uint? DeleteFailureSource { get; set; }
|
||||
public List<uint> GeneratedSources { get; } = [];
|
||||
public List<uint> Configured3D { get; } = [];
|
||||
public List<uint> ConfiguredUi { get; } = [];
|
||||
public List<uint> DeletedSources { get; } = [];
|
||||
public int ClearCurrentCalls { get; private set; }
|
||||
public int DestroyContextCalls { get; private set; }
|
||||
public int CloseDeviceCalls { get; private set; }
|
||||
|
||||
public nint OpenDevice() => DeviceResult;
|
||||
|
||||
public nint CreateContext(nint device) => ContextResult;
|
||||
|
||||
public bool MakeContextCurrent(nint context)
|
||||
{
|
||||
if (context == 0)
|
||||
ClearCurrentCalls++;
|
||||
return true;
|
||||
}
|
||||
|
||||
public uint GenerateSource()
|
||||
{
|
||||
uint source = _nextSource++;
|
||||
GeneratedSources.Add(source);
|
||||
return source;
|
||||
}
|
||||
|
||||
public void Configure3DSource(uint source)
|
||||
{
|
||||
Configured3D.Add(source);
|
||||
ThrowIfConfiguredFailure(source);
|
||||
}
|
||||
|
||||
public void ConfigureUiSource(uint source)
|
||||
{
|
||||
ConfiguredUi.Add(source);
|
||||
ThrowIfConfiguredFailure(source);
|
||||
}
|
||||
|
||||
public void SelectRetailDistanceModel() { }
|
||||
|
||||
public void StopSource(uint source) { }
|
||||
|
||||
public void DeleteSource(uint source)
|
||||
{
|
||||
if (DeleteFailureSource == source)
|
||||
throw new InvalidOperationException("synthetic delete failure");
|
||||
DeletedSources.Add(source);
|
||||
}
|
||||
|
||||
public void DeleteBuffer(uint buffer) { }
|
||||
|
||||
public void DestroyContext(nint context) => DestroyContextCalls++;
|
||||
|
||||
public void CloseDevice(nint device) => CloseDeviceCalls++;
|
||||
|
||||
private void ThrowIfConfiguredFailure(uint source)
|
||||
{
|
||||
if (ConfigureFailureSource == source)
|
||||
throw new InvalidOperationException("synthetic configure failure");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,77 @@
|
|||
using System.Numerics;
|
||||
using AcDream.App.Composition;
|
||||
using AcDream.Core.Physics;
|
||||
using DatReaderWriter.Types;
|
||||
|
||||
namespace AcDream.App.Tests.Composition;
|
||||
|
||||
public sealed class AnimationHookRegistrationSetTests
|
||||
{
|
||||
[Fact]
|
||||
public void CleanupUnregistersInReverseAndNeverReplaysSuccessfulEdges()
|
||||
{
|
||||
var calls = new List<string>();
|
||||
var first = new Sink("first");
|
||||
var second = new Sink("second");
|
||||
var third = new Sink("third");
|
||||
bool failSecond = true;
|
||||
var registrations = new AnimationHookRegistrationSet(
|
||||
sink => calls.Add($"add:{((Sink)sink).Name}"),
|
||||
sink =>
|
||||
{
|
||||
string name = ((Sink)sink).Name;
|
||||
calls.Add($"remove:{name}");
|
||||
if (name == "second" && failSecond)
|
||||
throw new InvalidOperationException("synthetic removal failure");
|
||||
});
|
||||
registrations.Register(first);
|
||||
registrations.Register(second);
|
||||
registrations.Register(third);
|
||||
|
||||
Assert.Throws<AggregateException>(registrations.Dispose);
|
||||
Assert.False(registrations.IsCleanupComplete);
|
||||
failSecond = false;
|
||||
registrations.Dispose();
|
||||
registrations.Dispose();
|
||||
|
||||
Assert.True(registrations.IsCleanupComplete);
|
||||
Assert.Equal(
|
||||
[
|
||||
"add:first", "add:second", "add:third",
|
||||
"remove:third", "remove:second", "remove:first",
|
||||
"remove:second",
|
||||
],
|
||||
calls);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DuplicateRegistrationIsIdempotentAndClosedSetRejectsNewEdges()
|
||||
{
|
||||
int adds = 0;
|
||||
int removes = 0;
|
||||
var sink = new Sink("only");
|
||||
var registrations = new AnimationHookRegistrationSet(
|
||||
_ => adds++,
|
||||
_ => removes++);
|
||||
|
||||
registrations.Register(sink);
|
||||
registrations.Register(sink);
|
||||
registrations.Dispose();
|
||||
|
||||
Assert.Equal(1, adds);
|
||||
Assert.Equal(1, removes);
|
||||
Assert.Throws<ObjectDisposedException>(() =>
|
||||
registrations.Register(new Sink("late")));
|
||||
}
|
||||
|
||||
private sealed class Sink(string name) : IAnimationHookSink
|
||||
{
|
||||
public string Name { get; } = name;
|
||||
public void OnHook(
|
||||
uint entityId,
|
||||
Vector3 entityWorldPosition,
|
||||
AnimationHook hook)
|
||||
{
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,408 @@
|
|||
using System.Reflection;
|
||||
using System.Runtime.CompilerServices;
|
||||
using AcDream.App.Audio;
|
||||
using AcDream.App.Composition;
|
||||
using AcDream.App.Physics;
|
||||
using AcDream.App.Rendering;
|
||||
using AcDream.App.Rendering.Vfx;
|
||||
using AcDream.App.Spells;
|
||||
using AcDream.Content;
|
||||
using AcDream.Content.Vfx;
|
||||
using AcDream.Core.Audio;
|
||||
using AcDream.Core.Lighting;
|
||||
using AcDream.Core.Physics;
|
||||
using AcDream.Core.Rendering;
|
||||
using AcDream.Core.Spells;
|
||||
using AcDream.Core.Vfx;
|
||||
using DatReaderWriter.DBObjs;
|
||||
using Silk.NET.Input;
|
||||
using Silk.NET.OpenAL;
|
||||
using Silk.NET.OpenGL;
|
||||
|
||||
namespace AcDream.App.Tests.Composition;
|
||||
|
||||
public sealed class ContentEffectsAudioCompositionTests
|
||||
{
|
||||
[Fact]
|
||||
public void ProductionPhasePublishesExactOwnersAndRegistersHooksInRetailOrder()
|
||||
{
|
||||
using var fixture = new Fixture();
|
||||
|
||||
ContentEffectsAudioResult result = fixture.Phase().Compose(
|
||||
fixture.Platform,
|
||||
fixture.Host);
|
||||
|
||||
Assert.Equal(
|
||||
Enum.GetValues<ContentEffectsAudioCompositionPoint>(),
|
||||
fixture.Points);
|
||||
Assert.Same(fixture.Publication.Dats, result.Dats);
|
||||
Assert.Same(fixture.Publication.Magic, result.MagicCatalog);
|
||||
Assert.Same(fixture.Publication.Animations, result.AnimationLoader);
|
||||
Assert.Same(fixture.Publication.Particles, result.ParticleSystem);
|
||||
Assert.Same(fixture.Publication.ScriptRunner, result.ScriptRunner);
|
||||
Assert.Same(fixture.Publication.Audio, result.Audio);
|
||||
Assert.Equal(
|
||||
[
|
||||
result.ParticleSink,
|
||||
result.LightingSink,
|
||||
result.TranslucencySink,
|
||||
result.Audio!.HookSink!,
|
||||
],
|
||||
fixture.Router.Sinks);
|
||||
Assert.Equal(4, result.HookRegistrations.ActiveCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NoAudioAcquiresNothingFromTheOptionalFactoryPrefix()
|
||||
{
|
||||
using var fixture = new Fixture(noAudio: true);
|
||||
|
||||
ContentEffectsAudioResult result = fixture.Phase().Compose(
|
||||
fixture.Platform,
|
||||
fixture.Host);
|
||||
|
||||
Assert.Null(result.Audio);
|
||||
Assert.Null(fixture.Publication.Audio);
|
||||
Assert.Equal(0, fixture.Factory.AudioFactoryCalls);
|
||||
Assert.DoesNotContain(
|
||||
ContentEffectsAudioCompositionPoint.SoundCacheCreated,
|
||||
fixture.Points);
|
||||
Assert.Equal(3, fixture.Router.Sinks.Count);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData((int)ContentEffectsAudioCompositionPoint.SoundCacheCreated)]
|
||||
[InlineData((int)ContentEffectsAudioCompositionPoint.AudioEngineCreated)]
|
||||
[InlineData((int)ContentEffectsAudioCompositionPoint.EntitySoundTablesCreated)]
|
||||
[InlineData((int)ContentEffectsAudioCompositionPoint.AudioSinkCreated)]
|
||||
public void OptionalAudioPrefixFailureDisablesAudioOnlyAfterRollbackConverges(
|
||||
int failurePointValue)
|
||||
{
|
||||
var failurePoint =
|
||||
(ContentEffectsAudioCompositionPoint)failurePointValue;
|
||||
using var fixture = new Fixture(failurePoint: failurePoint);
|
||||
|
||||
ContentEffectsAudioResult result = fixture.Phase().Compose(
|
||||
fixture.Platform,
|
||||
fixture.Host);
|
||||
|
||||
Assert.Null(result.Audio);
|
||||
Assert.Null(fixture.Publication.Audio);
|
||||
Assert.Equal(3, fixture.Router.Sinks.Count);
|
||||
if (fixture.Factory.LastAudioEngine is { } engine)
|
||||
Assert.True(engine.IsDisposalComplete);
|
||||
Assert.Contains(fixture.Logs, message =>
|
||||
message.Contains("audio: init failed", StringComparison.Ordinal));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FailureAfterAtomicAudioPublicationPropagatesToLifetimeOwner()
|
||||
{
|
||||
using var fixture = new Fixture(
|
||||
failurePoint: ContentEffectsAudioCompositionPoint.AudioPublished);
|
||||
|
||||
Assert.Throws<InvalidOperationException>(() => fixture.Phase().Compose(
|
||||
fixture.Platform,
|
||||
fixture.Host));
|
||||
|
||||
Assert.NotNull(fixture.Publication.Audio);
|
||||
Assert.False(fixture.Publication.Audio!.Engine.IsDisposalComplete);
|
||||
Assert.Equal(3, fixture.Router.Sinks.Count);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[MemberData(nameof(RequiredFailurePoints))]
|
||||
public void RequiredBoundaryFailureStopsAtTheExactPointAndNeverRunsASuffix(
|
||||
int failurePointValue)
|
||||
{
|
||||
var failurePoint =
|
||||
(ContentEffectsAudioCompositionPoint)failurePointValue;
|
||||
using var fixture = new Fixture(failurePoint: failurePoint);
|
||||
|
||||
Assert.Throws<InvalidOperationException>(() => fixture.Phase().Compose(
|
||||
fixture.Platform,
|
||||
fixture.Host));
|
||||
|
||||
Assert.Equal(
|
||||
Enum.GetValues<ContentEffectsAudioCompositionPoint>()
|
||||
.TakeWhile(point => point <= failurePoint),
|
||||
fixture.Points);
|
||||
}
|
||||
|
||||
public static TheoryData<int> RequiredFailurePoints()
|
||||
{
|
||||
var data = new TheoryData<int>();
|
||||
foreach (ContentEffectsAudioCompositionPoint point in
|
||||
Enum.GetValues<ContentEffectsAudioCompositionPoint>())
|
||||
{
|
||||
if (point is >= ContentEffectsAudioCompositionPoint.SoundCacheCreated
|
||||
and <= ContentEffectsAudioCompositionPoint.AudioSinkCreated)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
data.Add((int)point);
|
||||
}
|
||||
return data;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GameWindowUsesTheProductionPhaseAndNoLongerConstructsItsBodyInline()
|
||||
{
|
||||
string source = File.ReadAllText(Path.Combine(
|
||||
FindRepoRoot(),
|
||||
"src",
|
||||
"AcDream.App",
|
||||
"Rendering",
|
||||
"GameWindow.cs"));
|
||||
|
||||
Assert.Contains("new ContentEffectsAudioCompositionPhase(", source,
|
||||
StringComparison.Ordinal);
|
||||
Assert.DoesNotContain(
|
||||
"_dats = RuntimeDatCollectionFactory.OpenReadOnly(_datDir);",
|
||||
source,
|
||||
StringComparison.Ordinal);
|
||||
Assert.DoesNotContain("_hookRouter.Register(_particleSink)", source,
|
||||
StringComparison.Ordinal);
|
||||
Assert.DoesNotContain("new AcDream.App.Audio.OpenAlAudioEngine()", source,
|
||||
StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
private sealed class Fixture : IDisposable
|
||||
{
|
||||
private readonly ContentEffectsAudioCompositionPoint? _failurePoint;
|
||||
|
||||
public Fixture(
|
||||
bool noAudio = false,
|
||||
ContentEffectsAudioCompositionPoint? failurePoint = null)
|
||||
{
|
||||
_failurePoint = failurePoint;
|
||||
Router = new AnimationHookRouter();
|
||||
Poses = new EntityEffectPoseRegistry();
|
||||
Factory = new Factory();
|
||||
Publication = new Publication();
|
||||
Platform = new GameWindowPlatformResult<GL, IInputContext>(null!, null!);
|
||||
Host = (HostInputCameraResult)RuntimeHelpers.GetUninitializedObject(
|
||||
typeof(HostInputCameraResult));
|
||||
Dependencies = new ContentEffectsAudioDependencies(
|
||||
"test-dat",
|
||||
new PhysicsDataCache(),
|
||||
false,
|
||||
new Spellbook(),
|
||||
Router,
|
||||
Poses,
|
||||
new DeferredEntityEffectAdvanceSource(),
|
||||
new LightManager(),
|
||||
new TranslucencyFadeManager(),
|
||||
noAudio,
|
||||
Logs.Add,
|
||||
Logs.Add);
|
||||
}
|
||||
|
||||
public List<ContentEffectsAudioCompositionPoint> Points { get; } = [];
|
||||
public List<string> Logs { get; } = [];
|
||||
public AnimationHookRouter Router { get; }
|
||||
public EntityEffectPoseRegistry Poses { get; }
|
||||
public Factory Factory { get; }
|
||||
public Publication Publication { get; }
|
||||
public ContentEffectsAudioDependencies Dependencies { get; }
|
||||
public GameWindowPlatformResult<GL, IInputContext> Platform { get; }
|
||||
public HostInputCameraResult Host { get; }
|
||||
|
||||
public ContentEffectsAudioCompositionPhase Phase() => new(
|
||||
Dependencies,
|
||||
Publication,
|
||||
Factory,
|
||||
point =>
|
||||
{
|
||||
Points.Add(point);
|
||||
if (_failurePoint == point)
|
||||
throw new InvalidOperationException($"fault at {point}");
|
||||
});
|
||||
|
||||
public void Dispose() => Publication.Dispose();
|
||||
}
|
||||
|
||||
private sealed class Publication :
|
||||
IGameWindowContentEffectsAudioPublication,
|
||||
IDisposable
|
||||
{
|
||||
public IDatReaderWriter? Dats { get; private set; }
|
||||
public MagicCatalog? Magic { get; private set; }
|
||||
public IAnimationLoader? Animations { get; private set; }
|
||||
public LiveEntityCollisionBuilder? Collision { get; private set; }
|
||||
public EmitterDescRegistry? Emitters { get; private set; }
|
||||
public ParticleSystem? Particles { get; private set; }
|
||||
public ParticleHookSink? ParticleSink { get; private set; }
|
||||
public AnimationHookFrameQueue? HookFrames { get; private set; }
|
||||
public RetailPhysicsScriptLoader? ScriptLoader { get; private set; }
|
||||
public PhysicsScriptRunner? ScriptRunner { get; private set; }
|
||||
public LightingHookSink? LightingSink { get; private set; }
|
||||
public TranslucencyHookSink? TranslucencySink { get; private set; }
|
||||
public AnimationHookRegistrationSet? Registrations { get; private set; }
|
||||
public ContentAudioGraph? Audio { get; private set; }
|
||||
|
||||
public void PublishDatCollection(IDatReaderWriter value) => Dats = Once(Dats, value);
|
||||
public void PublishMagicCatalog(MagicCatalog value) => Magic = Once(Magic, value);
|
||||
public void PublishAnimationLoader(IAnimationLoader value) =>
|
||||
Animations = Once(Animations, value);
|
||||
public void PublishLiveEntityCollisionBuilder(LiveEntityCollisionBuilder value) =>
|
||||
Collision = Once(Collision, value);
|
||||
public void PublishEmitterRegistry(EmitterDescRegistry value) =>
|
||||
Emitters = Once(Emitters, value);
|
||||
public void PublishParticleSystem(ParticleSystem value) =>
|
||||
Particles = Once(Particles, value);
|
||||
public void PublishParticleSink(ParticleHookSink value) =>
|
||||
ParticleSink = Once(ParticleSink, value);
|
||||
public void PublishAnimationHookFrames(AnimationHookFrameQueue value) =>
|
||||
HookFrames = Once(HookFrames, value);
|
||||
public void PublishPhysicsScriptLoader(RetailPhysicsScriptLoader value) =>
|
||||
ScriptLoader = Once(ScriptLoader, value);
|
||||
public void PublishPhysicsScriptRunner(PhysicsScriptRunner value) =>
|
||||
ScriptRunner = Once(ScriptRunner, value);
|
||||
public void PublishLightingSink(LightingHookSink value) =>
|
||||
LightingSink = Once(LightingSink, value);
|
||||
public void PublishTranslucencySink(TranslucencyHookSink value) =>
|
||||
TranslucencySink = Once(TranslucencySink, value);
|
||||
public void PublishHookRegistrations(AnimationHookRegistrationSet value) =>
|
||||
Registrations = Once(Registrations, value);
|
||||
public void PublishAudio(ContentAudioGraph value) => Audio = Once(Audio, value);
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
Registrations?.Dispose();
|
||||
Registrations = null;
|
||||
Audio?.Engine.Dispose();
|
||||
Audio = null;
|
||||
Dats?.Dispose();
|
||||
Dats = null;
|
||||
}
|
||||
|
||||
private static T Once<T>(T? current, T value) where T : class
|
||||
{
|
||||
if (current is not null)
|
||||
throw new InvalidOperationException("duplicate publication");
|
||||
return value;
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class Factory : IContentEffectsAudioCompositionFactory
|
||||
{
|
||||
private readonly IDatReaderWriter _dats =
|
||||
DispatchProxy.Create<IDatReaderWriter, NullProxy>();
|
||||
private readonly MagicCatalog _magic =
|
||||
(MagicCatalog)RuntimeHelpers.GetUninitializedObject(typeof(MagicCatalog));
|
||||
private readonly IAnimationLoader _animations = new NullAnimationLoader();
|
||||
|
||||
public int AudioFactoryCalls { get; private set; }
|
||||
public OpenAlAudioEngine? LastAudioEngine { get; private set; }
|
||||
|
||||
public IDatReaderWriter OpenDatCollection(string datDirectory) => _dats;
|
||||
public MagicCatalog LoadMagicCatalog(IDatReaderWriter dats) => _magic;
|
||||
public void InstallSpellMetadata(Spellbook spellBook, MagicCatalog catalog) { }
|
||||
public int GetSpellCount(MagicCatalog catalog) => 0;
|
||||
public IAnimationLoader CreateAnimationLoader(IDatReaderWriter dats) => _animations;
|
||||
|
||||
public LiveEntityCollisionBuilder CreateCollisionBuilder(
|
||||
PhysicsDataCache physicsData,
|
||||
IDatReaderWriter dats,
|
||||
IAnimationLoader animationLoader,
|
||||
bool dumpMotionEnabled) =>
|
||||
(LiveEntityCollisionBuilder)RuntimeHelpers.GetUninitializedObject(
|
||||
typeof(LiveEntityCollisionBuilder));
|
||||
|
||||
public EmitterDescRegistry CreateEmitterRegistry(IDatReaderWriter dats) => new();
|
||||
public ParticleSystem CreateParticleSystem(EmitterDescRegistry emitters) => new(emitters);
|
||||
public ParticleHookSink CreateParticleSink(
|
||||
ParticleSystem particles,
|
||||
EntityEffectPoseRegistry poses) => new(particles, poses);
|
||||
public AnimationHookFrameQueue CreateAnimationHookFrames(
|
||||
AnimationHookRouter router,
|
||||
EntityEffectPoseRegistry poses) => new(router, poses);
|
||||
public AnimationHookRegistrationSet CreateHookRegistrations(
|
||||
AnimationHookRouter router) => new(router);
|
||||
public RetailPhysicsScriptLoader CreatePhysicsScriptLoader(IDatReaderWriter dats) =>
|
||||
(RetailPhysicsScriptLoader)RuntimeHelpers.GetUninitializedObject(
|
||||
typeof(RetailPhysicsScriptLoader));
|
||||
public PhysicsScriptRunner CreatePhysicsScriptRunner(
|
||||
RetailPhysicsScriptLoader loader,
|
||||
AnimationHookRouter router,
|
||||
IEntityEffectAdvanceSource advanceSource) =>
|
||||
new(_ => null, router, canAdvanceOwner: advanceSource.CanAdvanceOwner);
|
||||
public LightingHookSink CreateLightingSink(
|
||||
LightManager lighting,
|
||||
EntityEffectPoseRegistry poses) => new(lighting, poses);
|
||||
public TranslucencyHookSink CreateTranslucencySink(
|
||||
TranslucencyFadeManager fades) => new(fades);
|
||||
public DatSoundCache CreateSoundCache(IDatReaderWriter dats) => new(dats);
|
||||
|
||||
public OpenAlAudioEngine CreateAudioEngine()
|
||||
{
|
||||
AudioFactoryCalls++;
|
||||
LastAudioEngine = new OpenAlAudioEngine(
|
||||
new AudioApiFactory(new FakeAudioApi()));
|
||||
return LastAudioEngine;
|
||||
}
|
||||
|
||||
public DictionaryEntitySoundTable CreateEntitySoundTables() => new();
|
||||
public AudioHookSink CreateAudioSink(
|
||||
OpenAlAudioEngine engine,
|
||||
DatSoundCache cache,
|
||||
DictionaryEntitySoundTable entitySoundTables) =>
|
||||
new(engine, cache, entitySoundTables);
|
||||
}
|
||||
|
||||
public class NullProxy : DispatchProxy
|
||||
{
|
||||
protected override object? Invoke(MethodInfo? targetMethod, object?[]? args)
|
||||
{
|
||||
if (targetMethod?.ReturnType == typeof(void))
|
||||
return null;
|
||||
Type returnType = targetMethod?.ReturnType ?? typeof(void);
|
||||
return returnType.IsValueType
|
||||
? Activator.CreateInstance(returnType)
|
||||
: null;
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class NullAnimationLoader : IAnimationLoader
|
||||
{
|
||||
public Animation? LoadAnimation(uint id) => null;
|
||||
}
|
||||
|
||||
private sealed class AudioApiFactory(IOpenAlResourceApi api)
|
||||
: IOpenAlResourceApiFactory
|
||||
{
|
||||
public IOpenAlResourceApi Create() => api;
|
||||
}
|
||||
|
||||
private sealed class FakeAudioApi : IOpenAlResourceApi
|
||||
{
|
||||
private uint _nextSource = 1;
|
||||
public AL? AudioApi => null;
|
||||
public ALContext? ContextApi => null;
|
||||
public nint OpenDevice() => 1;
|
||||
public nint CreateContext(nint device) => 2;
|
||||
public bool MakeContextCurrent(nint context) => true;
|
||||
public uint GenerateSource() => _nextSource++;
|
||||
public void Configure3DSource(uint source) { }
|
||||
public void ConfigureUiSource(uint source) { }
|
||||
public void SelectRetailDistanceModel() { }
|
||||
public void StopSource(uint source) { }
|
||||
public void DeleteSource(uint source) { }
|
||||
public void DeleteBuffer(uint buffer) { }
|
||||
public void DestroyContext(nint context) { }
|
||||
public void CloseDevice(nint device) { }
|
||||
}
|
||||
|
||||
private static string FindRepoRoot()
|
||||
{
|
||||
DirectoryInfo? directory = new(AppContext.BaseDirectory);
|
||||
while (directory is not null)
|
||||
{
|
||||
if (File.Exists(Path.Combine(directory.FullName, "AcDream.slnx")))
|
||||
return directory.FullName;
|
||||
directory = directory.Parent;
|
||||
}
|
||||
throw new DirectoryNotFoundException("Could not find AcDream.slnx.");
|
||||
}
|
||||
}
|
||||
|
|
@ -54,7 +54,8 @@ public sealed class GameWindowSlice8BoundaryTests
|
|||
"GameWindowPlatformResult<GL, IInputContext> platform = AcquirePlatform();",
|
||||
"new HostInputCameraCompositionPhase(",
|
||||
"this).Compose(platform);",
|
||||
"_dats = RuntimeDatCollectionFactory.OpenReadOnly(_datDir);",
|
||||
"new ContentEffectsAudioCompositionPhase(",
|
||||
"this).Compose(platform, hostInputCamera);",
|
||||
"_runtimeSettings.ApplyStartup(",
|
||||
"new RuntimeSettingsStartupTargets(",
|
||||
"_uiHost = _retailUiLease.AcquireHost(",
|
||||
|
|
@ -342,6 +343,7 @@ public sealed class GameWindowSlice8BoundaryTests
|
|||
"new ResourceShutdownStage(\"frame borrowers\"",
|
||||
"new ResourceShutdownStage(\"session dependents\"",
|
||||
"new ResourceShutdownStage(\"live entities\"",
|
||||
"new ResourceShutdownStage(\"effect dispatch edges\"",
|
||||
"new ResourceShutdownStage(\"live entity dependents\"",
|
||||
"new ResourceShutdownStage(\"submitted GPU work\"",
|
||||
"new ResourceShutdownStage(\"render frontends\"",
|
||||
|
|
|
|||
|
|
@ -0,0 +1,43 @@
|
|||
using AcDream.App.Rendering.Vfx;
|
||||
|
||||
namespace AcDream.App.Tests.Rendering.Vfx;
|
||||
|
||||
public sealed class EntityEffectAdvanceSourceTests
|
||||
{
|
||||
[Fact]
|
||||
public void DefaultsOpenThenForwardsOneExactOwnerAndUnbindsOnlyThatOwner()
|
||||
{
|
||||
var source = new DeferredEntityEffectAdvanceSource();
|
||||
var first = new Target(false);
|
||||
var other = new Target(true);
|
||||
|
||||
Assert.True(source.CanAdvanceOwner(42));
|
||||
source.Bind(first);
|
||||
Assert.False(source.CanAdvanceOwner(42));
|
||||
source.Unbind(other);
|
||||
Assert.False(source.CanAdvanceOwner(42));
|
||||
source.Unbind(first);
|
||||
Assert.True(source.CanAdvanceOwner(42));
|
||||
source.Bind(other);
|
||||
Assert.True(source.CanAdvanceOwner(42));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RejectsASecondOwnerAndDeactivationIsTerminalAndOpenForShutdown()
|
||||
{
|
||||
var source = new DeferredEntityEffectAdvanceSource();
|
||||
var first = new Target(false);
|
||||
source.Bind(first);
|
||||
|
||||
Assert.Throws<InvalidOperationException>(() => source.Bind(new Target(true)));
|
||||
source.Deactivate();
|
||||
|
||||
Assert.True(source.CanAdvanceOwner(1));
|
||||
Assert.Throws<ObjectDisposedException>(() => source.Bind(first));
|
||||
}
|
||||
|
||||
private sealed class Target(bool result) : IEntityEffectAdvanceSource
|
||||
{
|
||||
public bool CanAdvanceOwner(uint ownerLocalId) => result;
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue