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>
382 lines
15 KiB
C#
382 lines
15 KiB
C#
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;
|
||
}
|
||
}
|
||
}
|