fix(audio): Campaign A slice A1 — retail's sound probability gate (#355)

The SoundTable probability field is a Bernoulli play/skip gate applied at
the play site (SoundManager::PlayProbability @0x005500E0), not a selection
weight — and variant selection (SoundManager::GetSound @0x00550680) is a
uniform index over (n-1) that ignores probability entirely. SoundCookbook
did the opposite: a cumulative-distribution walk weighted BY probability,
short-circuiting single-entry lists before rolling at all.

A dat census says 4,183 of 4,184 entries are single-entry and 686 of those
carry probability < 1.0, so the gate was categorically absent: Speak1 idle
chatter authored at 0.05 fired every trigger (~20x too often), wound/attack/
swoosh variants never dropped, and six 0.0001 entries always played.

Split into retail's two steps (PickVariant + PlayProbability, composed by
Select) over a new ISoundRandom modelling both retail roll ranges: the
variant roll clamped below 1.0 (0x00797D48) and the gate's 1/32767 grid,
which is why 0.0001 resolves to ~1.2e-4. PickVariant reproduces retail's
(n-1) off-by-one verbatim per the port-faithfully rule — the last variant
of a multi-entry sound is unreachable, costing exactly one wave
(0x0A00051E) in the shipped dats.

Also removes invented mechanism this review disproved: the dead Core
SoundEntry/ISoundCache scaffold (PitchMin/PitchMax, Loop, Is3D — retail
never calls SetFrequency, never sets the loop flag, and creates every
gameplay buffer 2D), the engine's pitch plumbing, the int 0..7 priority
cast (the dat field is a float in [0,1]; 4,100 entries collapsed to 0),
and the clamp-at-the-field on volume (an unbounded gain retail clamps only
after the distance divide).

Tests rewritten as conformance against the disassembled values, replacing
a self-referential suite that pinned the wrong model.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Erik 2026-08-08 21:28:16 +02:00
parent ffa5087527
commit c69b3bde04
8 changed files with 445 additions and 161 deletions

View file

@ -50,18 +50,18 @@ public sealed class AudioHookSink : IAnimationHookSink
private readonly OpenAlAudioEngine _engine;
private readonly DatSoundCache _cache;
private readonly IEntitySoundTable _entitySoundTables;
private readonly Random _rng;
private readonly ISoundRandom _rng;
public AudioHookSink(
OpenAlAudioEngine engine,
DatSoundCache cache,
IEntitySoundTable entitySoundTables,
Random? rng = null)
ISoundRandom? rng = null)
{
_engine = engine ?? throw new ArgumentNullException(nameof(engine));
_cache = cache ?? throw new ArgumentNullException(nameof(cache));
_entitySoundTables = entitySoundTables ?? throw new ArgumentNullException(nameof(entitySoundTables));
_rng = rng ?? Random.Shared;
_rng = rng ?? new SoundRandom();
}
public void OnHook(uint entityId, Vector3 entityWorldPosition, AnimationHook hook)
@ -71,7 +71,10 @@ public sealed class AudioHookSink : IAnimationHookSink
switch (hook)
{
case SoundHook s:
Play(entityId, entityWorldPosition, (uint)s.Id, volume: 1f, priority: 4, pitch: 1f);
// A bare wave with no authored volume/priority and no
// probability to gate on: retail's `PlaySoundA(DataID, obj,
// prio, prob, vol)` path is handed 1.0/1.0 by this hook.
Play(entityId, entityWorldPosition, (uint)s.Id, volume: 1f, priority: 1f);
break;
case SoundTableHook st:
@ -83,11 +86,14 @@ public sealed class AudioHookSink : IAnimationHookSink
// priority overrides baked into the hook itself (NOT a
// SoundTable lookup — that's SoundTableHook). Retail uses
// this for the rare "explicit wave + explicit volume" case.
// Volume is NOT clamped here: the dat field is an unbounded
// gain (shipped values reach 10.0) and retail clamps only
// after the distance divide, so clamping at the field would
// cut a loud sound's audible range. A2 owns that clamp.
Play(entityId, entityWorldPosition,
waveId: (uint)stw.SoundId,
volume: Math.Clamp(stw.Volume > 0 ? stw.Volume : 1f, 0f, 1f),
priority: stw.Priority,
pitch: 1f);
volume: stw.Volume > 0 ? stw.Volume : 1f,
priority: stw.Priority);
break;
// All the visual-only hooks (Scale, Luminous, Diffuse, …)
@ -97,7 +103,7 @@ public sealed class AudioHookSink : IAnimationHookSink
private void PlayFromSoundTable(
uint entityId, Vector3 worldPos, DRWSound sound,
float volumeMult = 1f, float pitchMult = 1f)
float volumeMult = 1f)
{
uint tableId = _entitySoundTables.GetSoundTableId(entityId);
if (tableId == 0) return;
@ -105,19 +111,25 @@ public sealed class AudioHookSink : IAnimationHookSink
SoundTable? table = _cache.GetSoundTable(tableId);
if (table is null) return;
var entry = SoundCookbook.Roll(table, sound, _rng);
// Retail's two steps: uniform variant pick, then the entry's own
// probability as a play/skip gate. A null here means retail would
// have stayed silent on this trigger.
var entry = SoundCookbook.Select(table, sound, _rng);
if (entry is null) return;
// Unlike the wire path (which uses the message's volume and ignores
// the table's), the animation-hook path plays at the AUTHORED entry
// volume — see the asymmetry in
// docs/research/2026-08-08-audio-retail-server-sounds.md.
Play(
entityId, worldPos,
waveId: (uint)entry.Id,
volume: Math.Clamp(entry.Volume * volumeMult, 0f, 1f),
priority: entry.Priority,
pitch: Math.Max(0.5f, Math.Min(2.0f, pitchMult)));
volume: entry.Volume * volumeMult,
priority: entry.Priority);
}
private void Play(uint entityId, Vector3 worldPos, uint waveId,
float volume, float priority, float pitch)
float volume, float priority)
{
if (waveId == 0) return;
WaveData? wave = _cache.GetWave(waveId);
@ -128,8 +140,7 @@ public sealed class AudioHookSink : IAnimationHookSink
wave,
worldPos,
volume,
priority,
pitch);
priority);
}
}

View file

@ -80,7 +80,11 @@ public sealed unsafe class OpenAlAudioEngine : IAudioEngine, IWorldAudioQuiescen
public uint OwnerId;
public float PlayingGain; // gain at play time (for eviction compare)
public bool InUse;
public uint PriorityBase; // raw priority from SoundEntry.Priority
// 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
// collapsed to 0, which flattened the eviction ordering this field
// exists for. A2 makes eviction compare it.
public float Priority;
}
private readonly Slot3D[] _pool3D = CreateWorldSlots();
private int _pool3DCursor; // round-robin start
@ -251,8 +255,7 @@ public sealed unsafe class OpenAlAudioEngine : IAudioEngine, IWorldAudioQuiescen
WaveData wave,
Vector3 position,
float volume,
float priority,
float pitch = 1.0f)
float priority)
{
if (_worldAudioSuspended || !_available || _al is null) return false;
@ -285,7 +288,8 @@ public sealed unsafe class OpenAlAudioEngine : IAudioEngine, IWorldAudioQuiescen
_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.Pitch, pitch);
// 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);
_al.SetSourceProperty(slot.SourceId, SourceBoolean.Looping, false);
@ -294,7 +298,7 @@ public sealed unsafe class OpenAlAudioEngine : IAudioEngine, IWorldAudioQuiescen
slot.PlayingGain = effectiveGain;
slot.InUse = true;
slot.OwnerId = ownerId;
slot.PriorityBase = (uint)Math.Clamp((int)priority, 0, 7);
slot.Priority = priority;
_pool3DCursor = (slotIdx + 1) & (PoolSize3D - 1);
return true;
}
@ -330,7 +334,7 @@ public sealed unsafe class OpenAlAudioEngine : IAudioEngine, IWorldAudioQuiescen
/// Play a raw WaveData blob as a 2D UI sound (no falloff, ignores
/// listener position).
/// </summary>
public bool PlayUiWave(uint waveId, WaveData wave, float volume = 1f, float pitch = 1f)
public bool PlayUiWave(uint waveId, WaveData wave, float volume = 1f)
{
if (!_available || _al is null) return false;
@ -350,7 +354,6 @@ public sealed unsafe class OpenAlAudioEngine : IAudioEngine, IWorldAudioQuiescen
_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.Pitch, pitch);
_al.SourcePlay(src);
return true;
}
@ -517,7 +520,7 @@ public sealed unsafe class OpenAlAudioEngine : IAudioEngine, IWorldAudioQuiescen
slot.OwnerId = 0;
slot.PlayingGain = 0f;
slot.PriorityBase = 0;
slot.Priority = 0f;
slot.InUse = false;
}