acdream/src/AcDream.Core/Audio/RetailSoundMixer.cs
Erik e42b99482e feat(audio): Campaign A slice A2 — retail's 2D pan+gain mixer replaces AL 3D
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>
2026-08-08 21:58:50 +02:00

263 lines
11 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

using System;
using System.Numerics;
namespace AcDream.Core.Audio;
/// <summary>
/// One voice's mixing decision, as retail computes it at emission time.
/// </summary>
/// <param name="Play">
/// False when retail would not start the voice at all — the attenuation fell
/// below <see cref="RetailSoundMixer.VolMinDecibels"/>. Retail does not start a
/// quiet voice; it starts nothing, so no pool slot is consumed either.
/// </param>
/// <param name="Decibels">
/// Integer decibels, <c>ceil(20·log10 g)</c>, floored at
/// <see cref="RetailSoundMixer.VolMinDecibels"/>.
/// </param>
/// <param name="Pan">
/// Retail's DirectSound pan in whole decibels, <c>[-15, +15]</c>; negative is
/// left, positive right, 0 dead centre.
/// </param>
public readonly record struct RetailVoiceMix(bool Play, int Decibels, int Pan);
/// <summary>
/// Retail's sound mixing math — the whole of its spatialization.
///
/// <para>
/// <b>Retail is not a 3D audio engine.</b> Every gameplay sound buffer is
/// created with <c>m_3D = 0</c>; 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.
/// </para>
///
/// <para>
/// Sources: <c>SoundManager::GetAttenuation</c> @ <c>0x00550020</c> and
/// <c>SoundManager::PlaySoundInternal(SoundBufRef*, const Position*, float, int)</c>
/// @ <c>0x00550170</c>, 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 <c>0f</c>, 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:
/// <c>docs/research/2026-08-08-audio-retail-soundmanager-core.md</c> §1.
/// </para>
/// </summary>
public static class RetailSoundMixer
{
/// <summary>Distance below which gain is flat at the authored volume, metres.</summary>
public const float VolMinDistance = 5.0f;
/// <summary>
/// <see cref="VolMinDistance"/> squared — retail's numerator, so the curve
/// is continuous at the knee.
/// </summary>
public const float VolMinDistanceSq = 25.0f;
/// <summary>
/// Retail's audibility floor. A voice whose computed decibels fall below
/// this is never started.
/// </summary>
public const int VolMinDecibels = -50;
/// <summary>Retail's pan scale, applied to the sine of the reversed bearing.</summary>
public const float PanScale = -15.0f;
/// <summary>
/// Pan is forced to dead centre when <c>(int)distance</c> is below this.
/// Retail truncates the distance before comparing, so this is an integer
/// metre test, not a float one.
/// </summary>
public const int PanDeadzoneMetres = 5;
/// <summary>Retail's voice count (<c>playing_sounds_[0x10]</c>).</summary>
public const int VoiceCount = 16;
/// <summary>
/// Retail's own single-precision degrees-to-radians literal, as it appears
/// in the pan computation.
/// </summary>
private const float DegreesToRadians = 0.0174532924f;
/// <summary>
/// <c>SoundManager::GetAttenuation</c>. Returns whole decibels and whether
/// retail would start the voice.
///
/// <para>
/// <paramref name="masterVolume"/> is retail's ONE master multiply —
/// <c>effect_sound_volume</c> for ordinary sounds, <c>ambient_sound_volume</c>
/// when <c>ambient != 0</c>. Callers on the paths where retail ALSO
/// pre-multiplies by the same knob (its volume-squared quirk) must
/// pre-multiply <paramref name="volume"/> themselves; this function applies
/// the knob exactly once.
/// </para>
/// </summary>
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;
}
/// <summary>
/// Retail's compass heading convention (<c>Position::heading</c> @
/// <c>0x005A9520</c>): degrees clockwise from +Y (north), +X (east) = 90°.
///
/// <para>
/// Delegates to <see cref="Physics.Motion.MoveToMath.PositionHeading"/> —
/// 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 <c>57.29577951308232</c>; the single-precision
/// narrowing there is at most ~1.5e-6 degrees, far inside the integer
/// truncation the pan applies afterwards.
/// </para>
/// </summary>
public static float CompassHeadingDegrees(Vector3 from, Vector3 to) =>
Physics.Motion.MoveToMath.PositionHeading(from, to);
/// <summary>
/// Retail's heading-difference normalisation, verbatim: <c>fmod(delta, 360)</c>
/// then <c>if (!(delta &lt;= 180)) delta -= 360</c>.
///
/// <para>
/// The output range is retail's <c>(-360, 180]</c>, NOT <c>(-180, 180]</c> —
/// retail does not fold negatives back up, so an input of 270 stays 270.
/// That is harmless because the only consumer is <c>sin</c>, which has
/// period 360, and reproducing it exactly keeps this function comparable to
/// the decode.
/// </para>
/// </summary>
public static float NormalizeSignedDegrees(float degrees)
{
float delta = degrees % 360.0f;
if (!(delta <= 180.0f)) delta -= 360.0f;
return delta;
}
/// <summary>
/// Retail's pan, in whole decibels. <paramref name="bearingSourceToListener"/>
/// is <c>Position::heading(soundPos, listenerPos)</c> — the REVERSED
/// bearing; combined with the negative <see cref="PanScale"/> that yields
/// the correct handedness (a source due east of a north-facing listener
/// pans right).
///
/// <para>
/// 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.
/// </para>
/// </summary>
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);
}
/// <summary>
/// The whole per-play decision for a world sound: distance gain plus pan,
/// composed the way <c>PlaySoundInternal</c> composes them.
/// </summary>
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);
}
/// <summary>
/// Linear amplitude for whole decibels: <c>10^(db/20)</c>. Retail hands the
/// decibel value straight to <c>IDirectSoundBuffer::SetVolume</c>, which is
/// hundredths of a decibel; OpenAL wants linear gain, so the conversion
/// happens here rather than changing the quantisation.
/// </summary>
public static float LinearGain(int decibels) =>
MathF.Pow(10.0f, decibels / 20.0f);
/// <summary>
/// Converts retail's pan (whole decibels of inter-channel difference, the
/// quantity <c>IDirectSoundBuffer::SetPan</c> expresses directly) into a
/// normalised stereo position in <c>[-1, 1]</c> for a constant-power panner
/// such as OpenAL's.
///
/// <para>
/// A constant-power panpot at position <c>p</c> over a speaker pair puts
/// <c>cos((p+1)·π/4)</c> in the left channel and <c>sin((p+1)·π/4)</c> in
/// the right, so the inter-channel difference is
/// <c>20·log10(tan((p+1)·π/4))</c>. Inverting that for a target difference
/// gives <c>p = (4/π)·atan(10^(pan/20)) 1</c>, 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.
/// </para>
/// </summary>
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);
}
/// <summary>
/// The maximum distance at which a sound of this volume is audible at all,
/// in metres — the radius where <c>ceil(20·log10(25·vol·master/d²))</c>
/// last reaches <see cref="VolMinDecibels"/>. About 94.2 m at
/// <c>vol·master == 1</c>, 66.6 m at 0.5, 29.8 m at 0.1. Diagnostic and
/// test use; the live path gets the same answer from
/// <see cref="TryGetAttenuation"/>.
/// </summary>
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);
}
}