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:
parent
c69b3bde04
commit
e42b99482e
13 changed files with 1123 additions and 106 deletions
|
|
@ -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 <= 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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue