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>
This commit is contained in:
Erik 2026-08-08 21:58:50 +02:00
parent c69b3bde04
commit e42b99482e
13 changed files with 1123 additions and 106 deletions

View file

@ -7,8 +7,11 @@ using Silk.NET.OpenAL;
namespace AcDream.App.Audio;
/// <summary>
/// OpenAL-backed audio engine (Phase E.2) — faithful to retail's
/// 16-voice pool and inverse-square falloff behaviour (r05 §5.3).
/// OpenAL-backed audio engine. Spatialization is NOT OpenAL's: retail creates
/// every gameplay buffer 2D (<c>m_3D = 0</c>) and computes a gain and a stereo
/// pan on the CPU per voice, so <see cref="RetailSoundMixer"/> owns that math
/// and AL is reduced to a voice bank. Every source is source-relative with
/// <c>AL_ROLLOFF_FACTOR = 0</c>.
///
/// <para>
/// Architecture:
@ -19,15 +22,19 @@ namespace AcDream.App.Audio;
/// PulseAudio / CoreAudio — whichever OpenAL-Soft picks).
/// </description></item>
/// <item><description>
/// Fixed 16-source pool for 3D positional sounds. When all 16 are
/// busy, new Play3D calls evict the slot whose currently-playing
/// sound has lower effective gain than the incoming sound
/// (matches retail <c>FUN_00550ad0</c> first-free-then-evict-quieter
/// algorithm at <c>chunk_00550000.c:527</c>).
/// Fixed 16-source pool for world sounds, allocated by
/// <see cref="RetailVoicePool"/>: a ring scan for a free or finished slot,
/// then eviction of the first slot whose DAT-authored priority is strictly
/// lower, else the sound is dropped. Retail's allocator is
/// <c>SoundManager::PlaySoundInternal</c> @ <c>0x0054FEC0</c> and it never
/// consults gain. (This comment previously cited <c>FUN_00550ad0</c> and
/// described gain-based eviction; that address is inside an
/// <c>IntrusiveHashTable</c> constructor and the behaviour was ours, not
/// retail's — both corrected in Campaign A slice A2, register row AP-28.)
/// </description></item>
/// <item><description>
/// Separate UI source pool (4 sources) for flat 2D UI clicks /
/// wooshes — not subject to the 3D eviction game.
/// wooshes — not subject to the world pool's eviction game.
/// </description></item>
/// <item><description>
/// PCM buffer cache keyed by Wave dat id so the same footstep isn't
@ -72,13 +79,14 @@ public sealed unsafe class OpenAlAudioEngine : IAudioEngine, IWorldAudioQuiescen
private const int PoolSize3D = 16; // retail 16-slot voice pool
private const int PoolSizeUi = 4;
// Slot state per 3D source; mirrors retail's g_poolVols array (the
// EFFECTIVE gain at play-start time, used for eviction comparisons).
// Slot state per 3D source, mirroring retail's `SoundPlayingData` —
// {buffer, priority, start_time}. There is no gain field: retail's
// allocator compares priority only, and its start_time is written but never
// read, so neither a gain nor a timestamp is carried here.
private sealed class Slot3D
{
public uint SourceId;
public uint OwnerId;
public float PlayingGain; // gain at play time (for eviction compare)
public bool InUse;
// The DAT-authored priority, a float in [0,1] — NOT an 0..7 int. 4,100
// of the shipped entries carry a sub-1.0 priority that an int cast
@ -92,6 +100,30 @@ public sealed unsafe class OpenAlAudioEngine : IAudioEngine, IWorldAudioQuiescen
private readonly uint[] _poolUi = new uint[PoolSizeUi];
// ── Listener (retail's SmartBox::viewer: origin + compass heading) ───────
private Vector3 _listenerPosition;
private float _listenerHeadingDegrees;
/// <summary>
/// Half-width of the stereo pan arc, in degrees — OpenAL Soft's own
/// front-left/front-right speaker angle for a stereo device, so a normalised
/// stereo position of ±1 lands exactly on a speaker.
///
/// <para>
/// Positions are NOT retail's pan scaled linearly onto this arc.
/// <see cref="RetailSoundMixer.StereoPositionFromPan"/> inverts the
/// constant-power pan law first, so retail's ±15 dB inter-channel difference
/// maps to ±0.775 of the arc and both channels stay live; a linear mapping
/// would put full deflection on the speaker angle itself, giving effectively
/// infinite separation where retail gives 15 dB. The pan's shape is retail's
/// throughout (sine of the compass bearing, dead centre inside 5 m, no
/// front/back and no elevation, frozen for the voice's life); only the pan
/// LAW is approximated, since OpenAL exposes no per-channel gain for a mono
/// source. Registered as AP-173.
/// </para>
/// </summary>
private const float MaxPanAzimuthDegrees = 30f;
// ── Buffer cache (Wave dat id → AL buffer) ───────────────────────────────
// Budget rationale: decoded PCM waves run ~100-500 KB each (same sizing
// as DatSoundCache's payload LRU, which this cache re-uploads from). 48
@ -175,7 +207,7 @@ public sealed unsafe class OpenAlAudioEngine : IAudioEngine, IWorldAudioQuiescen
}
// Global distance model = inverse-square clamped (classic retail feel).
api.SelectRetailDistanceModel();
api.DisableAlDistanceAttenuation();
_available = true;
}
@ -225,25 +257,34 @@ public sealed unsafe class OpenAlAudioEngine : IAudioEngine, IWorldAudioQuiescen
// ── IAudioEngine ─────────────────────────────────────────────────────────
public void SetListener(
float posX, float posY, float posZ,
float forwardX, float forwardY, float forwardZ,
float upX, float upY, float upZ)
/// <summary>
/// Records the listener pose retail's mixer reads: origin (for distance)
/// and compass heading (for pan). No AL listener orientation is published —
/// every voice is source-relative and its pan is computed on the CPU, so
/// AL's own panner must not also rotate the field.
/// </summary>
public void SetListener(float posX, float posY, float posZ, float headingDegrees)
{
if (!_available || _al is null) return;
_al.SetListenerProperty(ListenerVector3.Position, posX, posY, posZ);
// AL expects a 6-float orientation (fwd then up).
Span<float> ori = stackalloc float[6]
{
forwardX, forwardY, forwardZ,
upX, upY, upZ
};
fixed (float* p = ori)
_al.SetListenerProperty(ListenerFloatArray.Orientation, p);
_al.SetListenerProperty(ListenerFloat.Gain, MasterVolume);
_listenerPosition = new Vector3(posX, posY, posZ);
_listenerHeadingDegrees = headingDegrees;
}
/// <summary>
/// The master multiply retail's <c>GetAttenuation</c> applies — the effect
/// knob for world/UI sounds, folded with acdream's extra master slider.
///
/// <para>
/// It is folded in HERE, before the mixer, rather than published as AL's
/// listener gain, because retail's audibility decisions are made against the
/// post-master value: the 50 dB no-allocate floor, the audible radius, and
/// the whole-decibel quantisation all move with the knob. Applying it
/// downstream as a listener gain would compute the cutoff against a louder
/// signal than the user hears, and would allocate voices at master 0 where
/// retail's <c>g &lt;= 0</c> gate drops them.
/// </para>
/// </summary>
private float EffectMaster => MasterVolume * SfxVolume;
/// <summary>
/// Not exposed on IAudioEngine but used by the hook sink — play a raw
/// WaveData blob at a 3D position with full priority/volume controls.
@ -259,50 +300,91 @@ public sealed unsafe class OpenAlAudioEngine : IAudioEngine, IWorldAudioQuiescen
{
if (_worldAudioSuspended || !_available || _al is null) return false;
float effectiveGain = volume * SfxVolume;
if (effectiveGain < 0.001f) return false; // silent; skip
// Retail computes gain and pan BEFORE touching the voice pool, and a
// sound that attenuates past -50 dB is never started at all — so it
// consumes no slot and evicts nothing. At volume × master == 1 that
// silence radius is about 94 metres.
RetailVoiceMix mix = RetailSoundMixer.Mix(
_listenerPosition,
_listenerHeadingDegrees,
position,
volume,
EffectMaster);
if (!mix.Play) return false;
uint buffer = EnsureBuffer(waveId, wave);
if (buffer == 0) return false;
// Pick a slot: first free, else evict quieter one, else drop.
int slotIdx = -1;
for (int i = 0; i < PoolSize3D; i++)
{
int idx = (_pool3DCursor + i) & (PoolSize3D - 1);
var s = _pool3D[idx];
if (!s.InUse || !IsStillPlaying(s.SourceId)) { slotIdx = idx; break; }
}
if (slotIdx < 0)
{
for (int i = 0; i < PoolSize3D; i++)
{
int idx = (_pool3DCursor + i) & (PoolSize3D - 1);
if (_pool3D[idx].PlayingGain < effectiveGain) { slotIdx = idx; break; }
}
}
if (slotIdx < 0) return false; // no slot quieter than us — drop
int slotIdx = AcquireWorldSlot(priority);
if (slotIdx < 0) return false; // nothing lower-priority — drop
float gain = RetailSoundMixer.LinearGain(mix.Decibels);
var slot = _pool3D[slotIdx];
_al.SourceStop(slot.SourceId);
_al.SetSourceProperty(slot.SourceId, SourceInteger.Buffer, 0); // detach old
_al.SetSourceProperty(slot.SourceId, SourceInteger.Buffer, (int)buffer);
_al.SetSourceProperty(slot.SourceId, SourceFloat.Gain, effectiveGain);
_al.SetSourceProperty(slot.SourceId, SourceFloat.Gain, gain);
// No pitch: retail never calls SetFrequency on a sound buffer, so
// there is no per-play pitch variation to reproduce.
_al.SetSourceProperty(slot.SourceId, SourceVector3.Position, position.X, position.Y, position.Z);
_al.SetSourceProperty(slot.SourceId, SourceBoolean.SourceRelative, false);
ApplyPan(slot.SourceId, mix.Pan);
_al.SetSourceProperty(slot.SourceId, SourceBoolean.Looping, false);
_al.SourcePlay(slot.SourceId);
slot.PlayingGain = effectiveGain;
slot.InUse = true;
slot.OwnerId = ownerId;
slot.Priority = priority;
_pool3DCursor = (slotIdx + 1) & (PoolSize3D - 1);
_pool3DCursor = RetailVoicePool.AdvanceCursor(slotIdx, PoolSize3D);
return true;
}
/// <summary>
/// Projects the live pool into <see cref="RetailVoicePool"/>'s slot view and
/// takes its answer. The allocation policy itself lives in Core so it can be
/// tested without an AL device; this method only supplies the one piece of
/// state AL owns — whether each slot's voice is still playing.
/// </summary>
private int AcquireWorldSlot(float priority)
{
Span<VoiceSlotState> slots = stackalloc VoiceSlotState[PoolSize3D];
for (int i = 0; i < PoolSize3D; i++)
{
Slot3D s = _pool3D[i];
slots[i] = new VoiceSlotState(
Occupied: s.InUse,
StillPlaying: s.InUse && IsStillPlaying(s.SourceId),
Priority: s.Priority);
}
return RetailVoicePool.Acquire(slots, _pool3DCursor, priority);
}
/// <summary>
/// Publishes retail's whole-decibel pan as a source-relative azimuth. The
/// source sits on a unit arc in front of the listener so a pan of 0 is dead
/// ahead (centred) and the deflection is purely left/right — retail
/// distinguishes neither front from back nor elevation. Distance plays no
/// part: rolloff is 0 and the CPU-computed gain is authoritative.
///
/// <para>
/// The azimuth comes from <see cref="RetailSoundMixer.StereoPositionFromPan"/>,
/// which inverts the constant-power pan law so the resulting inter-channel
/// difference is retail's ±15 dB rather than the full separation a linear
/// mapping onto the speaker angle would produce. See AP-172.
/// </para>
/// </summary>
private void ApplyPan(uint sourceId, int pan)
{
float position = RetailSoundMixer.StereoPositionFromPan(pan);
float azimuth = position * MaxPanAzimuthDegrees * (MathF.PI / 180f);
_al!.SetSourceProperty(sourceId, SourceBoolean.SourceRelative, true);
_al.SetSourceProperty(
sourceId,
SourceVector3.Position,
MathF.Sin(azimuth),
0f,
-MathF.Cos(azimuth));
}
/// <summary>
/// Stops every world-space voice while preserving the independent UI
/// source pool. Retail suppresses ambient/object audio while cell loading
@ -349,11 +431,18 @@ public sealed unsafe class OpenAlAudioEngine : IAudioEngine, IWorldAudioQuiescen
}
if (slotIdx < 0) slotIdx = 0; // always replace slot 0 as a last resort
// Retail's interface sounds go through PlaySoundFromCenter: pan 0, and
// GetAttenuation at distance 0 (so the flat branch), scaled by
// effect_sound_volume — NOT by interface_sound_volume, which retail
// registers as a preference and then never reads.
if (!RetailSoundMixer.TryGetAttenuation(0f, volume, EffectMaster, out int decibels))
return false;
uint src = _poolUi[slotIdx];
_al.SourceStop(src);
_al.SetSourceProperty(src, SourceInteger.Buffer, 0);
_al.SetSourceProperty(src, SourceInteger.Buffer, (int)buffer);
_al.SetSourceProperty(src, SourceFloat.Gain, Math.Clamp(volume, 0f, 1f) * SfxVolume);
_al.SetSourceProperty(src, SourceFloat.Gain, RetailSoundMixer.LinearGain(decibels));
_al.SourcePlay(src);
return true;
}
@ -519,7 +608,6 @@ public sealed unsafe class OpenAlAudioEngine : IAudioEngine, IWorldAudioQuiescen
}
slot.OwnerId = 0;
slot.PlayingGain = 0f;
slot.Priority = 0f;
slot.InUse = false;
}

View file

@ -18,7 +18,7 @@ internal interface IOpenAlResourceApi
uint GenerateSource();
void Configure3DSource(uint source);
void ConfigureUiSource(uint source);
void SelectRetailDistanceModel();
void DisableAlDistanceAttenuation();
void StopSource(uint source);
void DeleteSource(uint source);
void DeleteBuffer(uint buffer);
@ -57,12 +57,16 @@ internal sealed unsafe class SilkOpenAlResourceApi : IOpenAlResourceApi
public uint GenerateSource() => AudioApi.GenSource();
// World voices are source-relative with rolloff 0: `RetailSoundMixer`
// computes retail's gain and pan on the CPU and they are authoritative, so
// AL must not attenuate by distance on top of that. Retail's own curve is
// inverse-SQUARE from a 5 m reference with a hard -50 dB cutoff, which AL's
// inverse model (first power only) cannot express anyway.
public void Configure3DSource(uint source)
{
AudioApi.SetSourceProperty(source, SourceFloat.Gain, 1f);
AudioApi.SetSourceProperty(source, SourceFloat.MaxDistance, 1000f);
AudioApi.SetSourceProperty(source, SourceFloat.RolloffFactor, 1f);
AudioApi.SetSourceProperty(source, SourceFloat.ReferenceDistance, 2f);
AudioApi.SetSourceProperty(source, SourceFloat.RolloffFactor, 0f);
AudioApi.SetSourceProperty(source, SourceBoolean.SourceRelative, true);
AudioApi.SetSourceProperty(source, SourceBoolean.Looping, false);
}
@ -73,8 +77,11 @@ internal sealed unsafe class SilkOpenAlResourceApi : IOpenAlResourceApi
AudioApi.SetSourceProperty(source, SourceBoolean.Looping, false);
}
public void SelectRetailDistanceModel() =>
AudioApi.DistanceModel(DistanceModel.InverseDistanceClamped);
// AL's distance models are all bypassed: rolloff 0 on every source means
// none of them contribute, and `RetailSoundMixer` owns the curve. Selecting
// None documents that rather than leaving a model that looks load-bearing.
public void DisableAlDistanceAttenuation() =>
AudioApi.DistanceModel(DistanceModel.None);
public void StopSource(uint source) => AudioApi.SourceStop(source);

View file

@ -7,6 +7,7 @@ using AcDream.App.Rendering.Wb;
using AcDream.App.Settings;
using AcDream.App.Streaming;
using AcDream.App.World;
using AcDream.Core.Audio;
using AcDream.Core.Lighting;
using AcDream.Core.Physics;
using AcDream.Core.Rendering;
@ -381,14 +382,19 @@ internal sealed class RuntimeWorldFrameSettingsPreview : IWorldFrameSettingsPrev
if (_audio is not { IsAvailable: true })
return;
// Retail's audio listener IS the camera viewer, refreshed per rendered
// frame (`SmartBox::set_viewer` @ 0x00452D36 hands the same collided
// camera Position to SoundManager, the sky, and the camera setup), so
// the position source here is already faithful. What retail reads off it
// is only the origin and `Frame::get_heading` — a compass bearing, not a
// basis: pan comes from that heading alone, which is why retail has no
// front/back or elevation cue to reproduce.
Matrix4x4 inverse = camera.InverseView;
var forward = new Vector3(-inverse.M31, -inverse.M32, -inverse.M33);
var up = new Vector3(inverse.M21, inverse.M22, inverse.M23);
Vector3 position = camera.Position;
_audio.SetListener(
position.X, position.Y, position.Z,
forward.X, forward.Y, forward.Z,
up.X, up.Y, up.Z);
RetailSoundMixer.CompassHeadingDegrees(Vector3.Zero, forward));
}
}

View file

@ -39,32 +39,12 @@ public sealed class WaveData
public TimeSpan Duration { get; init; }
}
/// <summary>
/// Falloff math (r05 §2). Retail is CPU-side inverse-square, NOT
/// DirectSound3DBuffer. No doppler, no cone, no HRTF.
/// </summary>
public static class AudioFalloff
{
/// <summary>
/// Attenuation factor based on distance. Retail uses pure inverse-square
/// above a minimum-distance threshold.
/// </summary>
public static float AttenuationAt(float distanceMeters, float minDistance = 1.0f)
{
if (distanceMeters < minDistance) return 1.0f;
float att = (minDistance * minDistance) / (distanceMeters * distanceMeters);
return Math.Clamp(att, 0f, 1f);
}
/// <summary>
/// Stereo pan from listener-relative X coord. ±1.0 fully panned.
/// </summary>
public static float PanFromRelative(float relativeX, float panRange = 20f)
{
if (panRange <= 0) return 0f;
return Math.Clamp(relativeX / panRange, -1f, 1f);
}
}
// An `AudioFalloff` helper lived here until 2026-08-08 (Campaign A slice A2).
// It had the right shape but a 1-metre reference distance where retail's is 5,
// a `PanFromRelative` that was linear in relative X over an invented 20-metre
// range where retail pans by the SINE of a compass bearing, and no audibility
// cutoff. Nothing ever called either method. Both are superseded by
// `RetailSoundMixer`, which carries the byte-decoded retail math.
/// <summary>
/// Interface the platform audio engine (AcDream.App layer) implements.
@ -78,10 +58,17 @@ public interface IAudioEngine : IDisposable
float MusicVolume { get; set; }
float AmbientVolume{ get; set; }
/// <summary>Update listener pose (called per frame from player position).</summary>
void SetListener(float posX, float posY, float posZ,
float forwardX, float forwardY, float forwardZ,
float upX, float upY, float upZ);
/// <summary>
/// Update the listener pose. Retail's listener is <c>SmartBox::viewer</c> —
/// the COLLIDED third-person camera Position, refreshed once per rendered
/// frame (<c>SmartBox::update_viewer</c> @ <c>0x00453CE0</c>), falling back
/// to the player's own position when the camera sweep fails. Only two
/// things are ever read out of it: the origin, for distance, and
/// <c>Frame::get_heading</c>, for pan. There is no up vector, no velocity,
/// and therefore no doppler and no elevation cue — which is why this takes
/// a single compass heading rather than a forward/up basis.
/// </summary>
void SetListener(float posX, float posY, float posZ, float headingDegrees);
/// <summary>Play a 2D UI sound (no falloff).</summary>
void PlayUi(SoundId id);

View file

@ -0,0 +1,263 @@
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);
}
}

View file

@ -0,0 +1,102 @@
using System;
namespace AcDream.Core.Audio;
/// <summary>
/// One voice slot's state, as retail's <c>SoundPlayingData</c> carries it:
/// whether a buffer is installed, and the DAT-authored priority it was claimed
/// with. Retail also stores a <c>start_time</c>, which it writes and never
/// reads — age enters allocation only through the ring cursor — so there is no
/// timestamp here.
/// </summary>
/// <param name="Occupied">
/// A buffer is installed in this slot (retail: <c>buffer != null</c> with a live
/// <c>m_pBuf</c>).
/// </param>
/// <param name="StillPlaying">
/// The installed buffer is still reporting <c>DSBSTATUS_PLAYING</c>. A finished
/// voice is reclaimed by the first pass exactly like an empty slot.
/// </param>
/// <param name="Priority">The priority the slot's current sound was claimed with.</param>
public readonly record struct VoiceSlotState(bool Occupied, bool StillPlaying, float Priority);
/// <summary>
/// Retail's voice allocator — <c>SoundManager::PlaySoundInternal(SoundBufRef*,
/// int pan, int volDb)</c> @ <c>0x0054FEC0</c>, byte-decoded.
///
/// <para>
/// Two passes, both walking the 16 slots in ring order from a persistent cursor:
/// </para>
/// <list type="number">
/// <item><description>
/// Claim the first slot that is empty, has a broken buffer, or is no longer
/// playing.
/// </description></item>
/// <item><description>
/// Otherwise claim the first slot whose priority is <b>strictly less</b> than
/// the incoming sound's — so equal priority never evicts, and gain is never
/// consulted at all. Before Campaign A slice A2 acdream compared GAIN here,
/// which let a loud unimportant sound evict a quiet important one.
/// </description></item>
/// </list>
/// <para>
/// If neither pass finds a slot the new sound is silently dropped.
/// </para>
///
/// <para>
/// This lives in Core, separate from the OpenAL engine, because it is pure
/// index logic over slot state: the engine's own play path talks to native AL
/// handles and cannot be reached by a test, and this is the second-largest
/// behavioural change in the audio campaign.
/// </para>
/// </summary>
public static class RetailVoicePool
{
/// <summary>Sentinel for "no slot available — drop the sound".</summary>
public const int NoSlot = -1;
/// <summary>
/// Pick the slot retail would claim for a sound of <paramref name="priority"/>,
/// or <see cref="NoSlot"/>.
/// </summary>
/// <param name="slots">
/// The pool, in slot order. Length is retail's
/// <see cref="RetailSoundMixer.VoiceCount"/> in production but any length
/// works so tests can use small pools.
/// </param>
/// <param name="cursor">
/// Retail's <c>curr_playing_buffer_</c> — where the ring scan starts.
/// </param>
public static int Acquire(ReadOnlySpan<VoiceSlotState> slots, int cursor, float priority)
{
if (slots.Length == 0) return NoSlot;
for (int i = 0; i < slots.Length; i++)
{
int idx = Ring(cursor, i, slots.Length);
VoiceSlotState slot = slots[idx];
if (!slot.Occupied || !slot.StillPlaying) return idx;
}
for (int i = 0; i < slots.Length; i++)
{
int idx = Ring(cursor, i, slots.Length);
if (slots[idx].Priority < priority) return idx;
}
return NoSlot;
}
/// <summary>
/// Retail's post-claim cursor advance: <c>curr_playing_buffer_ = (slot + 1)
/// mod 16</c>. Only a successful claim moves it.
/// </summary>
public static int AdvanceCursor(int claimedSlot, int slotCount) =>
slotCount <= 0 ? 0 : (claimedSlot + 1) % slotCount;
private static int Ring(int cursor, int offset, int slotCount)
{
int idx = (cursor + offset) % slotCount;
return idx < 0 ? idx + slotCount : idx;
}
}