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