using System;
using System.Numerics;
using AcDream.Core.Audio;
using Xunit;
namespace AcDream.Core.Tests.Audio;
///
/// Conformance tests for retail's mixing math — SoundManager::GetAttenuation
/// @ 0x00550020 and SoundManager::PlaySoundInternal(pos) @ 0x00550170,
/// both byte-decoded in
/// docs/research/2026-08-08-audio-retail-soundmanager-core.md §1.
///
///
/// Golden decibels are recomputed here from the decoded formula
/// (ceil(20·log10(min(1, 25·vol/d²)·master))) 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.
///
///
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();
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;
}
}
}