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

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