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