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:
Erik 2026-07-22 15:36:05 +02:00
parent 1d51e35c14
commit 60a1698ce7
12 changed files with 1901 additions and 152 deletions

View file

@ -0,0 +1,335 @@
using AcDream.App.Rendering;
using Silk.NET.OpenAL;
namespace AcDream.App.Audio;
/// <summary>
/// Narrow native-resource surface used by the OpenAL construction transaction.
/// Runtime playback still calls Silk's typed <see cref="AL"/> API directly.
/// </summary>
internal interface IOpenAlResourceApi
{
AL? AudioApi { get; }
ALContext? ContextApi { get; }
nint OpenDevice();
nint CreateContext(nint device);
bool MakeContextCurrent(nint context);
uint GenerateSource();
void Configure3DSource(uint source);
void ConfigureUiSource(uint source);
void SelectRetailDistanceModel();
void StopSource(uint source);
void DeleteSource(uint source);
void DeleteBuffer(uint buffer);
void DestroyContext(nint context);
void CloseDevice(nint device);
}
internal interface IOpenAlResourceApiFactory
{
IOpenAlResourceApi Create();
}
internal sealed class SilkOpenAlResourceApiFactory : IOpenAlResourceApiFactory
{
public IOpenAlResourceApi Create() => new SilkOpenAlResourceApi();
}
internal sealed unsafe class SilkOpenAlResourceApi : IOpenAlResourceApi
{
public SilkOpenAlResourceApi()
{
ContextApi = ALContext.GetApi(soft: true);
AudioApi = AL.GetApi(soft: true);
}
public AL AudioApi { get; }
public ALContext ContextApi { get; }
public nint OpenDevice() => (nint)ContextApi.OpenDevice(string.Empty);
public nint CreateContext(nint device) =>
(nint)ContextApi.CreateContext((Device*)device, null);
public bool MakeContextCurrent(nint context) =>
ContextApi.MakeContextCurrent((Context*)context);
public uint GenerateSource() => AudioApi.GenSource();
public void Configure3DSource(uint source)
{
AudioApi.SetSourceProperty(source, SourceFloat.Gain, 1f);
AudioApi.SetSourceProperty(source, SourceFloat.MaxDistance, 1000f);
AudioApi.SetSourceProperty(source, SourceFloat.RolloffFactor, 1f);
AudioApi.SetSourceProperty(source, SourceFloat.ReferenceDistance, 2f);
AudioApi.SetSourceProperty(source, SourceBoolean.Looping, false);
}
public void ConfigureUiSource(uint source)
{
AudioApi.SetSourceProperty(source, SourceBoolean.SourceRelative, true);
AudioApi.SetSourceProperty(source, SourceFloat.Gain, 1f);
AudioApi.SetSourceProperty(source, SourceBoolean.Looping, false);
}
public void SelectRetailDistanceModel() =>
AudioApi.DistanceModel(DistanceModel.InverseDistanceClamped);
public void StopSource(uint source) => AudioApi.SourceStop(source);
public void DeleteSource(uint source) => AudioApi.DeleteSource(source);
public void DeleteBuffer(uint buffer) => AudioApi.DeleteBuffer(buffer);
public void DestroyContext(nint context) =>
ContextApi.DestroyContext((Context*)context);
public void CloseDevice(nint device) =>
ContextApi.CloseDevice((Device*)device);
}
/// <summary>
/// Owns every native handle immediately after acquisition. Cleanup is
/// all-attempted, reverse dependency ordered, retryable, and never replays a
/// successful release.
/// </summary>
internal sealed class OpenAlResourceLifetime : IRetryableResourceCleanup
{
private sealed class SourceState(uint id)
{
public uint Id { get; } = id;
public bool Released { get; set; }
}
private sealed class BufferState(uint id)
{
public uint Id { get; } = id;
public bool Released { get; set; }
}
private readonly IOpenAlResourceApi _api;
private readonly List<SourceState> _sources = [];
private readonly List<BufferState> _buffers = [];
private nint _device;
private nint _context;
private bool _contextCurrent;
private bool _cleanupActive;
public OpenAlResourceLifetime(IOpenAlResourceApi api)
{
_api = api ?? throw new ArgumentNullException(nameof(api));
}
public nint Device => _device;
public nint Context => _context;
public bool IsCleanupComplete =>
_sources.All(static source => source.Released)
&& _buffers.All(static buffer => buffer.Released)
&& _context == 0
&& _device == 0;
public bool TryOpenDevice()
{
if (_device != 0)
throw new InvalidOperationException("The OpenAL device is already open.");
_device = _api.OpenDevice();
return _device != 0;
}
public bool TryCreateContext()
{
if (_device == 0)
throw new InvalidOperationException("An OpenAL device is required before its context.");
if (_context != 0)
throw new InvalidOperationException("The OpenAL context already exists.");
_context = _api.CreateContext(_device);
return _context != 0;
}
public bool TryMakeCurrent()
{
if (_context == 0)
throw new InvalidOperationException("An OpenAL context is required before activation.");
_contextCurrent = _api.MakeContextCurrent(_context);
return _contextCurrent;
}
public uint Create3DSource()
{
uint source = _api.GenerateSource();
_sources.Add(new SourceState(source));
_api.Configure3DSource(source);
return source;
}
public uint CreateUiSource()
{
uint source = _api.GenerateSource();
_sources.Add(new SourceState(source));
_api.ConfigureUiSource(source);
return source;
}
public void OwnBuffer(uint buffer)
{
if (buffer == 0)
throw new ArgumentOutOfRangeException(nameof(buffer));
if (_buffers.Any(existing => existing.Id == buffer && !existing.Released))
throw new InvalidOperationException($"OpenAL buffer {buffer} is already owned.");
_buffers.Add(new BufferState(buffer));
}
public void ReleaseBuffer(uint buffer)
{
BufferState state = _buffers.LastOrDefault(candidate =>
candidate.Id == buffer && !candidate.Released)
?? throw new InvalidOperationException($"OpenAL buffer {buffer} is not owned.");
_api.DeleteBuffer(buffer);
state.Released = true;
}
public void RetryCleanup()
{
if (_cleanupActive || IsCleanupComplete)
return;
_cleanupActive = true;
List<Exception>? failures = null;
try
{
// Sources borrow buffers; retire all sources first. Each deletion
// is still attempted even when SourceStop reports a driver error.
for (int i = _sources.Count - 1; i >= 0; i--)
{
SourceState source = _sources[i];
if (source.Released)
continue;
Exception? stopFailure = null;
try
{
_api.StopSource(source.Id);
}
catch (Exception failure)
{
stopFailure = failure;
}
try
{
_api.DeleteSource(source.Id);
source.Released = true;
}
catch (Exception failure)
{
(failures ??= []).Add(new AggregateException(
$"OpenAL source {source.Id} could not be released.",
stopFailure is null ? [failure] : [stopFailure, failure]));
}
}
for (int i = _buffers.Count - 1; i >= 0; i--)
{
BufferState buffer = _buffers[i];
if (buffer.Released)
continue;
try
{
_api.DeleteBuffer(buffer.Id);
buffer.Released = true;
}
catch (Exception failure)
{
(failures ??= []).Add(new InvalidOperationException(
$"OpenAL buffer {buffer.Id} could not be released.",
failure));
}
}
bool childrenReleased =
_sources.All(static source => source.Released)
&& _buffers.All(static buffer => buffer.Released);
if (childrenReleased && _context != 0)
{
if (_contextCurrent)
{
try
{
if (!_api.MakeContextCurrent(0))
throw new InvalidOperationException(
"OpenAL rejected clearing the current context.");
_contextCurrent = false;
}
catch (Exception failure)
{
(failures ??= []).Add(new InvalidOperationException(
"The current OpenAL context could not be cleared.",
failure));
}
}
if (!_contextCurrent)
{
try
{
_api.DestroyContext(_context);
_context = 0;
}
catch (Exception failure)
{
(failures ??= []).Add(new InvalidOperationException(
"The OpenAL context could not be destroyed.",
failure));
}
}
}
if (_context == 0 && _device != 0)
{
try
{
_api.CloseDevice(_device);
_device = 0;
}
catch (Exception failure)
{
(failures ??= []).Add(new InvalidOperationException(
"The OpenAL device could not be closed.",
failure));
}
}
}
finally
{
_cleanupActive = false;
}
if (failures is not null)
throw new AggregateException(
"OpenAL native-resource cleanup remains incomplete.",
failures);
}
}
internal sealed class OpenAlInitializationException : AggregateException,
IRetryableResourceCleanup
{
private readonly OpenAlResourceLifetime _lifetime;
public OpenAlInitializationException(
Exception initializationFailure,
OpenAlResourceLifetime lifetime,
AggregateException cleanupFailure)
: base(
"OpenAL initialization failed and native-resource cleanup remains incomplete.",
[initializationFailure, .. cleanupFailure.InnerExceptions])
{
_lifetime = lifetime ?? throw new ArgumentNullException(nameof(lifetime));
}
public bool IsCleanupComplete => _lifetime.IsCleanupComplete;
public void RetryCleanup() => _lifetime.RetryCleanup();
}