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:
parent
ffa5087527
commit
c69b3bde04
8 changed files with 445 additions and 161 deletions
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue