Retail is not a 3D audio engine. Every gameplay buffer is created with m_3D = 0 and the DirectSound 3D listener the client sets up is dead code; spatialization is two CPU scalars per voice, frozen at emission. This slice ports that math and demotes OpenAL to a voice bank. RetailSoundMixer (new, Core) carries the byte-decoded curve from SoundManager::GetAttenuation @0x00550020: g = dist < 5 ? vol : 25*vol/d2, clamped to 1 BEFORE the single master multiply, db = ceil(20*log10 g), with a hard -50 dB floor at which retail does not start the voice at all (audible radius ~94.2 m at unity). Pan is PlaySoundInternal @0x00550170's (int)(-15*sin(delta-bearing)) in whole decibels, truncating toward zero, forced to dead centre when (int)distance < 5, with no front/back and no elevation cue. Every AL source is now source-relative with rolloff 0 and the global distance model is None: AL's InverseDistanceClamped was first-power (2/d), quieter than retail up close and far louder at range with no cutoff whatsoever. That was the largest audible divergence in the subsystem (AP-28, retired here). RetailVoicePool (new, Core) ports the allocator at 0x0054FEC0: ring scan for a free or finished slot, then evict the first slot whose DAT priority is strictly lower, else drop. Eviction compared GAIN before, so a loud unimportant sound could silence a quiet important one. It lives in Core because the engine's play path talks to native AL handles and could not be tested; the pool now has 12 conformance tests. The listener keeps using the camera position, which the decode shows is retail-faithful (SmartBox::set_viewer @0x00452D36 hands the same collided camera Position to SoundManager) — only the heading extraction changes, since retail reads one compass bearing and never a forward/up basis. An earlier draft of the plan called this a defect; corrected in the plan so it is not fixed backwards. Opus review found and this commit fixes: a linear pan-to-azimuth mapping that saturated to full separation at 30 degrees (OpenAL Soft's own speaker angle) where retail gives 15 dB — now inverts the constant-power pan law, so full deflection reaches 0.776 of the arc and both channels stay live; the stale FUN_00550ad0 / gain-eviction class header, which contradicted the register row this commit writes; missing discriminating tests for clamp order and pan truncation; dead PlayingGain state whose comment invented a retail symbol; and a third in-tree copy of Position::heading, now delegating to MoveToMath.PositionHeading. MasterVolume folds into the mixer's one multiply instead of AL listener gain, so the cutoff, radius and dB quantisation move with the slider. Register: AP-28 retired; AP-173 (pan law), AP-174 (volume taxonomy), TS-64 (two unimplemented sound prefs), TS-65 (volume-squared quirk, applied on the ambient path only) filed. Research note corrected twice where its summary contradicted its own decode (30 m dB, floor vs trunc). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
342 lines
11 KiB
C#
342 lines
11 KiB
C#
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 DisableAlDistanceAttenuation();
|
|
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();
|
|
|
|
// World voices are source-relative with rolloff 0: `RetailSoundMixer`
|
|
// computes retail's gain and pan on the CPU and they are authoritative, so
|
|
// AL must not attenuate by distance on top of that. Retail's own curve is
|
|
// inverse-SQUARE from a 5 m reference with a hard -50 dB cutoff, which AL's
|
|
// inverse model (first power only) cannot express anyway.
|
|
public void Configure3DSource(uint source)
|
|
{
|
|
AudioApi.SetSourceProperty(source, SourceFloat.Gain, 1f);
|
|
AudioApi.SetSourceProperty(source, SourceFloat.RolloffFactor, 0f);
|
|
AudioApi.SetSourceProperty(source, SourceBoolean.SourceRelative, true);
|
|
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);
|
|
}
|
|
|
|
// AL's distance models are all bypassed: rolloff 0 on every source means
|
|
// none of them contribute, and `RetailSoundMixer` owns the curve. Selecting
|
|
// None documents that rather than leaving a model that looks load-bearing.
|
|
public void DisableAlDistanceAttenuation() =>
|
|
AudioApi.DistanceModel(DistanceModel.None);
|
|
|
|
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();
|
|
}
|