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>
This commit is contained in:
Erik 2026-08-08 21:58:50 +02:00
parent c69b3bde04
commit e42b99482e
13 changed files with 1123 additions and 106 deletions

View file

@ -0,0 +1,382 @@
using System;
using System.Numerics;
using AcDream.Core.Audio;
using Xunit;
namespace AcDream.Core.Tests.Audio;
/// <summary>
/// Conformance tests for retail's mixing math — <c>SoundManager::GetAttenuation</c>
/// @ 0x00550020 and <c>SoundManager::PlaySoundInternal(pos)</c> @ 0x00550170,
/// both byte-decoded in
/// <c>docs/research/2026-08-08-audio-retail-soundmanager-core.md</c> §1.
///
/// <para>
/// Golden decibels are recomputed here from the decoded formula
/// (<c>ceil(20·log10(min(1, 25·vol/d²)·master))</c>) rather than copied from
/// the note's summary table, which has one transcription slip: it lists 30 m as
/// 35 dB where both its own gain column (0.0278) and the formula give 31.
/// </para>
/// </summary>
public sealed class RetailSoundMixerTests
{
// ── GetAttenuation ─────────────────────────────────────────────────────
[Theory]
// Inside the 5 m knee gain is flat at the authored volume.
[InlineData(0f, 0)]
[InlineData(2f, 0)]
[InlineData(4.99f, 0)]
// At and beyond the knee: 25/d², continuous at 5 m.
[InlineData(5f, 0)]
[InlineData(10f, -12)]
[InlineData(20f, -24)]
[InlineData(30f, -31)]
[InlineData(50f, -40)]
[InlineData(90f, -50)]
[InlineData(94f, -50)] // last audible metre
public void Attenuation_MatchesRetailCurve(float distance, int expectedDecibels)
{
Assert.True(RetailSoundMixer.TryGetAttenuation(distance, 1f, 1f, out int db));
Assert.Equal(expectedDecibels, db);
}
[Theory]
[InlineData(95f)]
[InlineData(120f)]
[InlineData(1000f)]
public void Attenuation_BeyondCutoff_DoesNotPlay(float distance)
{
Assert.False(RetailSoundMixer.TryGetAttenuation(distance, 1f, 1f, out int db));
Assert.Equal(RetailSoundMixer.VolMinDecibels, db);
}
[Fact]
public void Attenuation_IsInverseSquare_NotInverseFirstPower()
{
// Doubling distance past the knee must cost 4x gain (12 dB), not 2x
// (6 dB). This is the single largest pre-A2 divergence: OpenAL's
// InverseDistanceClamped is first-power only.
RetailSoundMixer.TryGetAttenuation(10f, 1f, 1f, out int near);
RetailSoundMixer.TryGetAttenuation(20f, 1f, 1f, out int far);
Assert.Equal(12, near - far);
}
[Fact]
public void Attenuation_ClampsAboveUnity()
{
// A volume above 1.0 (the dats reach 10.0) cannot make a close sound
// louder than 0 dB — but it DOES extend the audible radius, because
// retail clamps after the distance divide, not at the field.
Assert.True(RetailSoundMixer.TryGetAttenuation(1f, 10f, 1f, out int db));
Assert.Equal(0, db);
}
[Fact]
public void Attenuation_ClampsBeforeTheMasterMultiply_NotAfter()
{
// The single most easily-inverted line in the port. Retail clamps the
// distance term to unity and THEN applies the master knob:
// retail order : min(10, 1) = 1, x0.5 = 0.5 -> -6 dB
// inverted order : 10 x 0.5 = 5, min(5, 1) = 1 -> 0 dB
Assert.True(RetailSoundMixer.TryGetAttenuation(1f, 10f, 0.5f, out int db));
Assert.Equal(-6, db);
}
[Fact]
public void Attenuation_HighVolume_ExtendsAudibleRadius()
{
// 10x volume at 200 m: 25*10/40000 = 0.00625 → -44 dB, still audible,
// where a volume clamped to 1.0 at the field would have been silent.
Assert.False(RetailSoundMixer.TryGetAttenuation(200f, 1f, 1f, out _));
Assert.True(RetailSoundMixer.TryGetAttenuation(200f, 10f, 1f, out int loud));
Assert.Equal(-44, loud);
}
[Theory]
[InlineData(0f)]
[InlineData(-1f)]
public void Attenuation_NonPositiveMaster_DoesNotPlay(float master)
{
Assert.False(RetailSoundMixer.TryGetAttenuation(1f, 1f, master, out int db));
Assert.Equal(RetailSoundMixer.VolMinDecibels, db);
}
[Fact]
public void Attenuation_MasterIsAppliedExactlyOnce()
{
// GetAttenuation multiplies by one master knob. Halving it must cost
// ~6 dB, not ~12 (which is what a second, caller-side multiply gives —
// retail's volume-squared quirk on the PlaySoundA(DataID, obj) and
// ambient paths, which callers opt into by pre-multiplying).
RetailSoundMixer.TryGetAttenuation(10f, 1f, 1f, out int full);
RetailSoundMixer.TryGetAttenuation(10f, 1f, 0.5f, out int half);
Assert.Equal(-6, half - full);
}
[Theory]
// Solving ceil(20·log10(25·s/d²)) >= -50 for d.
[InlineData(1f, 94.2f)]
[InlineData(0.5f, 66.6f)]
[InlineData(0.1f, 29.8f)]
public void AudibleRadius_MatchesDecodedRadii(float scale, float expectedMetres)
{
Assert.Equal(expectedMetres, RetailSoundMixer.AudibleRadius(scale, 1f), 1);
}
[Fact]
public void AudibleRadius_AgreesWithTheLivePredicate()
{
// The radius helper and the play decision must not drift apart.
for (float volume = 0.1f; volume <= 3f; volume += 0.1f)
{
float radius = RetailSoundMixer.AudibleRadius(volume, 1f);
Assert.True(RetailSoundMixer.TryGetAttenuation(radius - 0.5f, volume, 1f, out _));
Assert.False(RetailSoundMixer.TryGetAttenuation(radius + 0.5f, volume, 1f, out _));
}
}
[Fact]
public void Decibels_AreWholeNumbers_QuantisedByCeil()
{
// Retail stair-steps in whole decibels rather than ramping smoothly.
var seen = new System.Collections.Generic.HashSet<int>();
for (float d = 5f; d < 94f; d += 0.05f)
{
RetailSoundMixer.TryGetAttenuation(d, 1f, 1f, out int db);
seen.Add(db);
}
// 0 dB down to -50 dB inclusive is at most 51 distinct steps.
Assert.InRange(seen.Count, 40, 51);
}
[Fact]
public void LinearGain_RoundTripsTheDecibelScale()
{
Assert.Equal(1f, RetailSoundMixer.LinearGain(0), 5);
Assert.Equal(0.5f, RetailSoundMixer.LinearGain(-6), 2);
Assert.Equal(0.25f, RetailSoundMixer.LinearGain(-12), 2);
Assert.Equal(0.00316f, RetailSoundMixer.LinearGain(-50), 5);
}
// ── Heading + pan ──────────────────────────────────────────────────────
[Theory]
// Retail's compass convention: 0 = +Y (north), 90 = +X (east).
[InlineData(0f, 1f, 0f)] // north
[InlineData(1f, 0f, 90f)] // east
[InlineData(0f, -1f, 180f)] // south
[InlineData(-1f, 0f, 270f)] // west
public void CompassHeading_UsesRetailConvention(float dx, float dy, float expected)
{
float heading = RetailSoundMixer.CompassHeadingDegrees(
Vector3.Zero, new Vector3(dx, dy, 0f));
Assert.Equal(expected, heading, 2);
}
[Theory]
[InlineData(0f, 0f)]
[InlineData(180f, 180f)] // inclusive upper bound
[InlineData(181f, -179f)]
[InlineData(270f, -90f)]
[InlineData(359f, -1f)]
[InlineData(-90f, -90f)]
public void NormalizeSigned_MapsIntoRetailsWindow(float input, float expected)
{
Assert.Equal(expected, RetailSoundMixer.NormalizeSignedDegrees(input), 3);
}
[Fact]
public void Pan_SourceDueEastOfNorthFacingListener_IsFullRight()
{
// The worked check from the decode: delta = -90 ⇒ pan = -15·sin(-90) = +15.
var mix = RetailSoundMixer.Mix(
Vector3.Zero, 0f, new Vector3(10f, 0f, 0f), 1f, 1f);
Assert.Equal(15, mix.Pan);
}
[Fact]
public void Pan_SourceDueWestOfNorthFacingListener_IsFullLeft()
{
var mix = RetailSoundMixer.Mix(
Vector3.Zero, 0f, new Vector3(-10f, 0f, 0f), 1f, 1f);
Assert.Equal(-15, mix.Pan);
}
[Fact]
public void Pan_HasNoFrontBackDistinction()
{
// Retail's cue is the sine of the bearing, so dead ahead and directly
// behind both centre. This is a faithfulness property, not a bug.
var ahead = RetailSoundMixer.Mix(
Vector3.Zero, 0f, new Vector3(0f, 10f, 0f), 1f, 1f);
var behind = RetailSoundMixer.Mix(
Vector3.Zero, 0f, new Vector3(0f, -10f, 0f), 1f, 1f);
Assert.Equal(0, ahead.Pan);
Assert.Equal(0, behind.Pan);
}
[Fact]
public void Pan_RotatesWithListenerHeading()
{
// Facing east, a source due east is now dead ahead ⇒ centred.
var mix = RetailSoundMixer.Mix(
Vector3.Zero, 90f, new Vector3(10f, 0f, 0f), 1f, 1f);
Assert.Equal(0, mix.Pan);
}
[Theory]
[InlineData(1f, 0)] // inside the deadzone
[InlineData(4.9f, 0)] // (int)4.9 == 4 < 5
[InlineData(5f, 15)] // (int)5 == 5, deadzone ends
public void Pan_DeadzoneIsAnIntegerMetreTest(float distance, int expectedPan)
{
var mix = RetailSoundMixer.Mix(
Vector3.Zero, 0f, new Vector3(distance, 0f, 0f), 1f, 1f);
Assert.Equal(expectedPan, mix.Pan);
}
[Fact]
public void Pan_ElevationNeverContributes()
{
// Z reaches the mix only through distance: two sources on the same
// horizontal bearing pan identically however far apart they are
// vertically, while their gains differ.
var level = RetailSoundMixer.Mix(
Vector3.Zero, 0f, new Vector3(10f, 0f, 0f), 1f, 1f);
var high = RetailSoundMixer.Mix(
Vector3.Zero, 0f, new Vector3(10f, 0f, 40f), 1f, 1f);
Assert.Equal(level.Pan, high.Pan);
Assert.NotEqual(level.Decibels, high.Decibels);
}
[Fact]
public void Pan_PurelyVerticalOffset_InheritsRetailsAtan2Degeneracy()
{
// A source directly overhead has dx == dy == 0, so retail's
// `fmod(450 - atan2(0, 0)·57.29578, 360)` yields 90° (due east) and the
// sound pans hard LEFT rather than centre. C's atan2(0,0) is 0, so this
// is retail's behaviour, not ours — pinned here so a future reader does
// not "fix" it into a centred pan. Unreachable for ordinary emitters,
// which are never exactly co-located horizontally; a source AT the
// listener is caught by the 5 m deadzone instead.
var mix = RetailSoundMixer.Mix(
Vector3.Zero, 0f, new Vector3(0f, 0f, 10f), 1f, 1f);
Assert.Equal(-15, mix.Pan);
Assert.Equal(-12, mix.Decibels);
}
[Fact]
public void Pan_DisabledByPreference_IsAlwaysCentre()
{
// retail: s_SoundFeatures == 1 forces pan 0.
var mix = RetailSoundMixer.Mix(
Vector3.Zero, 0f, new Vector3(10f, 0f, 0f), 1f, 1f, panningEnabled: false);
Assert.Equal(0, mix.Pan);
}
[Fact]
public void Pan_StaysWithinFifteenDecibels()
{
// Sweep every bearing: retail's pan saturates at ±15 dB, never full
// separation.
for (int deg = 0; deg < 360; deg++)
{
float rad = deg * MathF.PI / 180f;
var source = new Vector3(MathF.Sin(rad) * 20f, MathF.Cos(rad) * 20f, 0f);
var mix = RetailSoundMixer.Mix(Vector3.Zero, 0f, source, 1f, 1f);
Assert.InRange(mix.Pan, -15, 15);
}
}
[Fact]
public void Mix_BeyondCutoff_ReportsDoNotPlay()
{
var mix = RetailSoundMixer.Mix(
Vector3.Zero, 0f, new Vector3(0f, 200f, 0f), 1f, 1f);
Assert.False(mix.Play);
}
[Theory]
// Retail's `_ftol2` truncates toward zero. Bearing ±64.158° gives
// |15·sin Δ| ≈ 13.5, and the NEGATIVE row is the discriminating one:
// truncation gives 13 where floor would give 14. (On the positive side
// truncation and floor agree, which is why one row cannot pin this.)
[InlineData(64.158f, 13)]
[InlineData(-64.158f, -13)]
public void Pan_TruncatesTowardZero_NotFloor(float bearingDegrees, int expectedPan)
{
// Place the source at the given bearing FROM the listener, 20 m out.
float rad = bearingDegrees * MathF.PI / 180f;
var source = new Vector3(MathF.Sin(rad) * 20f, MathF.Cos(rad) * 20f, 0f);
var mix = RetailSoundMixer.Mix(Vector3.Zero, 0f, source, 1f, 1f);
Assert.Equal(expectedPan, mix.Pan);
}
[Fact]
public void NormalizeSigned_LeavesLargeNegativesAlone_AsRetailDoes()
{
// Retail's window is (-360, 180], not (-180, 180]: it never folds a
// negative back up. Pan-equivalent because only sin() consumes it.
Assert.Equal(-270f, RetailSoundMixer.NormalizeSignedDegrees(-270f), 3);
Assert.Equal(
MathF.Sin(90f * MathF.PI / 180f),
MathF.Sin(RetailSoundMixer.NormalizeSignedDegrees(-270f) * MathF.PI / 180f),
3);
}
// ── Pan law: retail's 15 dB, not full separation ────────────────────────
[Fact]
public void StereoPosition_CentreIsCentre()
{
Assert.Equal(0f, RetailSoundMixer.StereoPositionFromPan(0), 4);
}
[Theory]
[InlineData(15)]
[InlineData(-15)]
public void StereoPosition_FullPan_StaysInsideTheSpeakerAngle(int pan)
{
// The whole point of inverting the pan law: full retail deflection must
// NOT reach ±1 (the speaker angle), which would give effectively
// infinite channel separation where retail gives 15 dB.
// (4/pi)·atan(10^(15/20)) - 1 = (4/pi)·atan(5.6234) - 1 = 0.7757.
float position = RetailSoundMixer.StereoPositionFromPan(pan);
Assert.Equal(0.776f, MathF.Abs(position), 3);
Assert.True(MathF.Abs(position) < 1f);
}
[Theory]
[InlineData(0)]
[InlineData(3)]
[InlineData(7)]
[InlineData(11)]
[InlineData(15)]
[InlineData(-6)]
[InlineData(-15)]
public void StereoPosition_ReproducesTheRequestedDecibelDifference(int pan)
{
// Under a constant-power panpot, position p yields channel gains
// cos((p+1)pi/4) and sin((p+1)pi/4). Round-trip the difference.
float p = RetailSoundMixer.StereoPositionFromPan(pan);
float angle = (p + 1f) * MathF.PI / 4f;
float left = MathF.Cos(angle);
float right = MathF.Sin(angle);
float differenceDb = 20f * MathF.Log10(right / left);
Assert.Equal(pan, differenceDb, 2);
}
[Fact]
public void StereoPosition_IsMonotonicAcrossThePanRange()
{
float previous = RetailSoundMixer.StereoPositionFromPan(-15);
for (int pan = -14; pan <= 15; pan++)
{
float current = RetailSoundMixer.StereoPositionFromPan(pan);
Assert.True(current > previous, $"pan {pan} did not increase position");
previous = current;
}
}
}

View file

@ -0,0 +1,155 @@
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);
}
}