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>
155 lines
5.1 KiB
C#
155 lines
5.1 KiB
C#
using System;
|
|
using System.Linq;
|
|
using AcDream.Core.Audio;
|
|
using Xunit;
|
|
|
|
namespace AcDream.Core.Tests.Audio;
|
|
|
|
/// <summary>
|
|
/// Conformance tests for retail's voice allocator,
|
|
/// <c>SoundManager::PlaySoundInternal(SoundBufRef*, int, int)</c> @
|
|
/// <c>0x0054FEC0</c>, decoded in
|
|
/// <c>docs/research/2026-08-08-audio-retail-soundmanager-core.md</c> §1.
|
|
///
|
|
/// <para>
|
|
/// The behaviour under test is the second-largest change in the audio campaign:
|
|
/// before it, acdream evicted by GAIN, so a loud unimportant sound could silence
|
|
/// a quiet important one.
|
|
/// </para>
|
|
/// </summary>
|
|
public sealed class RetailVoicePoolTests
|
|
{
|
|
private static VoiceSlotState Free() => new(Occupied: false, StillPlaying: false, Priority: 0f);
|
|
|
|
private static VoiceSlotState Finished(float priority) =>
|
|
new(Occupied: true, StillPlaying: false, Priority: priority);
|
|
|
|
private static VoiceSlotState Busy(float priority) =>
|
|
new(Occupied: true, StillPlaying: true, Priority: priority);
|
|
|
|
private static VoiceSlotState[] AllBusy(float priority, int count = 16)
|
|
{
|
|
var slots = new VoiceSlotState[count];
|
|
Array.Fill(slots, Busy(priority));
|
|
return slots;
|
|
}
|
|
|
|
[Fact]
|
|
public void EmptyPool_DropsTheSound()
|
|
{
|
|
Assert.Equal(RetailVoicePool.NoSlot, RetailVoicePool.Acquire(Array.Empty<VoiceSlotState>(), 0, 1f));
|
|
}
|
|
|
|
[Fact]
|
|
public void FirstPass_PrefersAFreeSlot_ScanningFromTheCursor()
|
|
{
|
|
var slots = AllBusy(1f);
|
|
slots[9] = Free();
|
|
Assert.Equal(9, RetailVoicePool.Acquire(slots, cursor: 0, priority: 0f));
|
|
}
|
|
|
|
[Fact]
|
|
public void FirstPass_ReclaimsAFinishedVoice_EvenAtHigherPriority()
|
|
{
|
|
// A finished voice is as reclaimable as an empty slot, whatever priority
|
|
// it was claimed with — the first pass never compares priority.
|
|
var slots = AllBusy(1f);
|
|
slots[4] = Finished(1f);
|
|
Assert.Equal(4, RetailVoicePool.Acquire(slots, cursor: 0, priority: 0.1f));
|
|
}
|
|
|
|
[Fact]
|
|
public void FirstPass_WrapsAroundTheRing()
|
|
{
|
|
var slots = AllBusy(1f);
|
|
slots[2] = Free();
|
|
// Starting at 5, the scan must wrap past 15 to reach slot 2.
|
|
Assert.Equal(2, RetailVoicePool.Acquire(slots, cursor: 5, priority: 0f));
|
|
}
|
|
|
|
[Fact]
|
|
public void FirstPass_TakesTheNearestFreeSlotInRingOrder()
|
|
{
|
|
var slots = AllBusy(1f);
|
|
slots[1] = Free();
|
|
slots[12] = Free();
|
|
Assert.Equal(12, RetailVoicePool.Acquire(slots, cursor: 10, priority: 0f));
|
|
}
|
|
|
|
[Fact]
|
|
public void SecondPass_EvictsStrictlyLowerPriority()
|
|
{
|
|
var slots = AllBusy(0.5f);
|
|
slots[7] = Busy(0.2f);
|
|
Assert.Equal(7, RetailVoicePool.Acquire(slots, cursor: 0, priority: 0.3f));
|
|
}
|
|
|
|
[Fact]
|
|
public void SecondPass_EqualPriorityNeverEvicts()
|
|
{
|
|
// Retail's compare is `slot.priority < new.priority`. A pool full of
|
|
// equal-priority voices drops the newcomer.
|
|
var slots = AllBusy(0.5f);
|
|
Assert.Equal(RetailVoicePool.NoSlot, RetailVoicePool.Acquire(slots, cursor: 0, priority: 0.5f));
|
|
}
|
|
|
|
[Fact]
|
|
public void SecondPass_HigherPriorityPoolDropsTheNewSound()
|
|
{
|
|
var slots = AllBusy(0.9f);
|
|
Assert.Equal(RetailVoicePool.NoSlot, RetailVoicePool.Acquire(slots, cursor: 0, priority: 0.4f));
|
|
}
|
|
|
|
[Fact]
|
|
public void SecondPass_TakesTheFirstLowerSlotInRingOrder_NotTheLowest()
|
|
{
|
|
// Retail stops at the FIRST slot below the incoming priority; it does not
|
|
// search for the quietest or least important one.
|
|
var slots = AllBusy(0.9f);
|
|
slots[3] = Busy(0.1f);
|
|
slots[6] = Busy(0.5f);
|
|
Assert.Equal(6, RetailVoicePool.Acquire(slots, cursor: 6, priority: 0.6f));
|
|
}
|
|
|
|
[Fact]
|
|
public void Eviction_IgnoresGain_ByConstruction()
|
|
{
|
|
// There is no gain in VoiceSlotState at all — the type cannot express the
|
|
// old behaviour. This test documents that as an intentional property.
|
|
var slots = AllBusy(0.8f);
|
|
Assert.Equal(
|
|
RetailVoicePool.NoSlot,
|
|
RetailVoicePool.Acquire(slots, cursor: 0, priority: 0.8f));
|
|
Assert.DoesNotContain(
|
|
"Gain",
|
|
string.Join(",", typeof(VoiceSlotState).GetProperties().Select(p => p.Name)));
|
|
}
|
|
|
|
[Theory]
|
|
[InlineData(0, 1)]
|
|
[InlineData(15, 0)]
|
|
[InlineData(9, 10)]
|
|
public void Cursor_AdvancesPastTheClaimedSlot_AndWraps(int claimed, int expected)
|
|
{
|
|
Assert.Equal(expected, RetailVoicePool.AdvanceCursor(claimed, 16));
|
|
}
|
|
|
|
[Fact]
|
|
public void RingOrder_IsStableAcrossRepeatedClaims()
|
|
{
|
|
// Round-robin over a pool whose voices finish immediately: successive
|
|
// claims must walk the ring rather than reusing one slot.
|
|
var slots = new VoiceSlotState[4];
|
|
Array.Fill(slots, Free());
|
|
|
|
int cursor = 0;
|
|
var claimed = new int[4];
|
|
for (int i = 0; i < 4; i++)
|
|
{
|
|
claimed[i] = RetailVoicePool.Acquire(slots, cursor, 1f);
|
|
cursor = RetailVoicePool.AdvanceCursor(claimed[i], slots.Length);
|
|
}
|
|
|
|
Assert.Equal(new[] { 0, 1, 2, 3 }, claimed);
|
|
}
|
|
}
|