using System;
using System.Numerics;
namespace AcDream.Core.Audio;
///
/// One voice's mixing decision, as retail computes it at emission time.
///
///
/// False when retail would not start the voice at all — the attenuation fell
/// below . Retail does not start a
/// quiet voice; it starts nothing, so no pool slot is consumed either.
///
///
/// Integer decibels, ceil(20·log10 g), floored at
/// .
///
///
/// Retail's DirectSound pan in whole decibels, [-15, +15]; negative is
/// left, positive right, 0 dead centre.
///
public readonly record struct RetailVoiceMix(bool Play, int Decibels, int Pan);
///
/// Retail's sound mixing math — the whole of its spatialization.
///
///
/// Retail is not a 3D audio engine. Every gameplay sound buffer is
/// created with m_3D = 0; the DirectSound 3D listener the client sets up
/// (rolloff 0.01, front (−1,0,0), top (0,1,0)) is dead code that no buffer ever
/// consults. Spatialization is exactly two CPU-computed scalars per voice,
/// frozen at emission: a gain in whole decibels from distance, and a stereo pan
/// in whole decibels from bearing.
///
///
///
/// Sources: SoundManager::GetAttenuation @ 0x00550020 and
/// SoundManager::PlaySoundInternal(SoundBufRef*, const Position*, float, int)
/// @ 0x00550170, both byte-decoded from the PDB-paired binary — Binary
/// Ninja elides the x87 memory constants in the first (its pseudo-C multiplies
/// by a literal 0f, which would port as silence at every distance) and
/// misattributes a reused stack slot in the second (reporting the 5-metre
/// deadzone as an angle test rather than a distance test). Full decode:
/// docs/research/2026-08-08-audio-retail-soundmanager-core.md §1.
///
///
public static class RetailSoundMixer
{
/// Distance below which gain is flat at the authored volume, metres.
public const float VolMinDistance = 5.0f;
///
/// squared — retail's numerator, so the curve
/// is continuous at the knee.
///
public const float VolMinDistanceSq = 25.0f;
///
/// Retail's audibility floor. A voice whose computed decibels fall below
/// this is never started.
///
public const int VolMinDecibels = -50;
/// Retail's pan scale, applied to the sine of the reversed bearing.
public const float PanScale = -15.0f;
///
/// Pan is forced to dead centre when (int)distance is below this.
/// Retail truncates the distance before comparing, so this is an integer
/// metre test, not a float one.
///
public const int PanDeadzoneMetres = 5;
/// Retail's voice count (playing_sounds_[0x10]).
public const int VoiceCount = 16;
///
/// Retail's own single-precision degrees-to-radians literal, as it appears
/// in the pan computation.
///
private const float DegreesToRadians = 0.0174532924f;
///
/// SoundManager::GetAttenuation. Returns whole decibels and whether
/// retail would start the voice.
///
///
/// is retail's ONE master multiply —
/// effect_sound_volume for ordinary sounds, ambient_sound_volume
/// when ambient != 0. Callers on the paths where retail ALSO
/// pre-multiplies by the same knob (its volume-squared quirk) must
/// pre-multiply themselves; this function applies
/// the knob exactly once.
///
///
public static bool TryGetAttenuation(
float distanceMetres,
float volume,
float masterVolume,
out int decibels)
{
float g = distanceMetres < VolMinDistance
? volume
: (VolMinDistanceSq * volume) / (distanceMetres * distanceMetres);
if (g > 1.0f) g = 1.0f;
g *= masterVolume;
if (g <= 0.0f || float.IsNaN(g))
{
decibels = VolMinDecibels;
return false;
}
decibels = (int)MathF.Ceiling(20.0f * MathF.Log10(g));
if (decibels >= VolMinDecibels)
return true;
decibels = VolMinDecibels;
return false;
}
///
/// Retail's compass heading convention (Position::heading @
/// 0x005A9520): degrees clockwise from +Y (north), +X (east) = 90°.
///
///
/// Delegates to —
/// the pinned port of the same retail function, with golden-cardinal
/// coverage — rather than carrying a third copy of the formula. Retail's own
/// constant is the double 57.29577951308232; the single-precision
/// narrowing there is at most ~1.5e-6 degrees, far inside the integer
/// truncation the pan applies afterwards.
///
///
public static float CompassHeadingDegrees(Vector3 from, Vector3 to) =>
Physics.Motion.MoveToMath.PositionHeading(from, to);
///
/// Retail's heading-difference normalisation, verbatim: fmod(delta, 360)
/// then if (!(delta <= 180)) delta -= 360.
///
///
/// The output range is retail's (-360, 180], NOT (-180, 180] —
/// retail does not fold negatives back up, so an input of −270 stays −270.
/// That is harmless because the only consumer is sin, which has
/// period 360, and reproducing it exactly keeps this function comparable to
/// the decode.
///
///
public static float NormalizeSignedDegrees(float degrees)
{
float delta = degrees % 360.0f;
if (!(delta <= 180.0f)) delta -= 360.0f;
return delta;
}
///
/// Retail's pan, in whole decibels.
/// is Position::heading(soundPos, listenerPos) — the REVERSED
/// bearing; combined with the negative that yields
/// the correct handedness (a source due east of a north-facing listener
/// pans right).
///
///
/// There is no front/back and no elevation cue: a source dead ahead and one
/// directly behind both pan to 0, and Z reaches the mix only through
/// distance.
///
///
public static int GetPan(
float bearingSourceToListener,
float listenerHeadingDegrees,
float distanceMetres,
bool panningEnabled = true)
{
if (!panningEnabled)
return 0;
// Retail truncates distance to an int before the deadzone compare.
if (Math.Abs((int)distanceMetres) < PanDeadzoneMetres)
return 0;
float delta = NormalizeSignedDegrees(bearingSourceToListener - listenerHeadingDegrees);
int pan = (int)(MathF.Sin(delta * DegreesToRadians) * PanScale);
return Math.Clamp(pan, (int)PanScale, (int)-PanScale);
}
///
/// The whole per-play decision for a world sound: distance gain plus pan,
/// composed the way PlaySoundInternal composes them.
///
public static RetailVoiceMix Mix(
Vector3 listenerPosition,
float listenerHeadingDegrees,
Vector3 sourcePosition,
float volume,
float masterVolume,
bool panningEnabled = true)
{
float distance = Vector3.Distance(listenerPosition, sourcePosition);
// Pan is computed from the reversed bearing: source -> listener.
float bearing = CompassHeadingDegrees(sourcePosition, listenerPosition);
int pan = GetPan(bearing, listenerHeadingDegrees, distance, panningEnabled);
bool play = TryGetAttenuation(distance, volume, masterVolume, out int decibels);
return new RetailVoiceMix(play, decibels, pan);
}
///
/// Linear amplitude for whole decibels: 10^(db/20). Retail hands the
/// decibel value straight to IDirectSoundBuffer::SetVolume, which is
/// hundredths of a decibel; OpenAL wants linear gain, so the conversion
/// happens here rather than changing the quantisation.
///
public static float LinearGain(int decibels) =>
MathF.Pow(10.0f, decibels / 20.0f);
///
/// Converts retail's pan (whole decibels of inter-channel difference, the
/// quantity IDirectSoundBuffer::SetPan expresses directly) into a
/// normalised stereo position in [-1, 1] for a constant-power panner
/// such as OpenAL's.
///
///
/// A constant-power panpot at position p over a speaker pair puts
/// cos((p+1)·π/4) in the left channel and sin((p+1)·π/4) in
/// the right, so the inter-channel difference is
/// 20·log10(tan((p+1)·π/4)). Inverting that for a target difference
/// gives p = (4/π)·atan(10^(pan/20)) − 1, which reaches only ±0.775
/// at retail's ±15 dB — both channels stay live, exactly as DirectSound's
/// one-channel attenuation keeps them. Mapping pan linearly onto the
/// panner's full range instead would saturate to infinite separation at the
/// edges, which retail never does.
///
///
public static float StereoPositionFromPan(int pan)
{
float difference = MathF.Pow(10.0f, pan / 20.0f);
float position = (4.0f / MathF.PI) * MathF.Atan(difference) - 1.0f;
return Math.Clamp(position, -1.0f, 1.0f);
}
///
/// The maximum distance at which a sound of this volume is audible at all,
/// in metres — the radius where ceil(20·log10(25·vol·master/d²))
/// last reaches . About 94.2 m at
/// vol·master == 1, 66.6 m at 0.5, 29.8 m at 0.1. Diagnostic and
/// test use; the live path gets the same answer from
/// .
///
public static float AudibleRadius(float volume, float masterVolume)
{
float scale = volume * masterVolume;
if (scale <= 0f) return 0f;
// Audible while db >= -50, i.e. ceil(20·log10 g) >= -50, i.e.
// 20·log10 g > -51 (ceil of anything above -51 is at least -50).
float minGain = MathF.Pow(10.0f, -51.0f / 20.0f);
return MathF.Sqrt(VolMinDistanceSq * scale / minGain);
}
}