acdream/tests/AcDream.Core.Tests/Audio/RetailSoundMixerTests.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

382 lines
15 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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;
}
}
}