acdream/tests/AcDream.App.Tests/Audio/OpenAlResourceLifetimeTests.cs
Erik e42b99482e feat(audio): Campaign A slice A2 — retail's 2D pan+gain mixer replaces AL 3D
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>
2026-08-08 21:58:50 +02:00

183 lines
5.8 KiB
C#

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);
}
[Fact]
public void UnavailableEngineWorldQuiescenceRemainsASafeNoOp()
{
var api = new RecordingApi { ContextResult = 0 };
var engine = new OpenAlAudioEngine(new Factory(api));
engine.SuspendWorldAudio();
engine.StopAllForOwner(0x50000001u);
engine.ResumeWorldAudio();
Assert.False(engine.IsAvailable);
Assert.True(engine.IsDisposalComplete);
Assert.Empty(api.GeneratedSources);
}
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 DisableAlDistanceAttenuation() { }
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");
}
}
}