diff --git a/docs/ISSUES.md b/docs/ISSUES.md
index 0b16933b..a1f708b8 100644
--- a/docs/ISSUES.md
+++ b/docs/ISSUES.md
@@ -24,6 +24,47 @@ What does NOT go here:
- Every session: scan OPEN issues at start; promote/close anything we touched during the session before ending.
- Promoting to a Phase: mark as `DONE (promoted to Phase X)` + commit SHA where the Phase entry landed.
+## #355 — Sound probability was never applied: every gated cue played on every trigger
+
+**Status:** CLOSED 2026-08-08 (Campaign A slice A1) — user gate finding
+("we get incorrect ambient and stuff like that"), root-caused during the
+six-lane audio review.
+
+Retail's `SoundTable` entries carry a `probability` field that is a **Bernoulli
+play/skip gate** applied at the play site (`SoundManager::PlayProbability` @
+`0x005500E0`: `rand() * (1/32767) < probability`, else silence), entirely
+separate from variant selection (`SoundManager::GetSound` @ `0x00550680`:
+`idx = (int)(roll * (n - 1))`, which ignores probability).
+
+`SoundCookbook.Roll` instead treated probability as a cumulative selection
+weight AND short-circuited single-entry lists before rolling at all:
+
+```csharp
+if (entries.Count == 1) return entries[0]; // probability never consulted
+```
+
+An independent walk of the shipped dats found 4,183 of 4,184 entries are
+single-entry lists, and **686 of those carry probability < 1.0** — so the gate
+was categorically absent from the client. Loudest symptom: `Speak1` creature
+idle chatter (49 entries authored at 0.05) fired ~20× too often; wound / attack
+/ swoosh variants never dropped; six entries authored at 0.0001 played on every
+trigger. Affected 20 of 123 SoundTypes.
+
+Fixed by splitting the model into retail's two steps
+(`SoundCookbook.PickVariant` + `PlayProbability`, composed by `Select`) over a
+new `ISoundRandom` that reproduces both of retail's roll ranges — the variant
+roll clamped below 1.0 (`0x00797D48`) and the gate's 1/32767 grid, which is
+what makes a 0.0001 probability resolve to ~1.2e-4 rather than 1e-4.
+`PickVariant` deliberately reproduces retail's `(n-1)` off-by-one (the last
+variant of a multi-entry sound is unreachable; blast radius in the shipped dats
+is exactly one wave, `0x0A00051E`).
+
+Evidence: `docs/research/2026-08-08-audio-retail-dat-layer.md` §2 (census +
+disassembly, both elided by Binary Ninja — BN also renders `PlayProbability`'s
+branch **inverted**, so porting its rendering would have played sounds exactly
+when retail stays silent). Campaign:
+`docs/plans/2026-08-08-audio-parity-campaign.md`.
+
## #354 — Spell-bar drag reorder did not work: lifting a favorite canceled the drag before the drop could land
**Status:** CLOSED 2026-08-08 — user gate finding ("I should be able to
diff --git a/docs/plans/2026-08-08-audio-parity-campaign.md b/docs/plans/2026-08-08-audio-parity-campaign.md
index c99cc067..89448a4c 100644
--- a/docs/plans/2026-08-08-audio-parity-campaign.md
+++ b/docs/plans/2026-08-08-audio-parity-campaign.md
@@ -281,7 +281,7 @@ global kill switch.
| Slice | Status | Commit | Gates |
|---|---|---|---|
-| A1 | — | — | — |
+| A1 | **COMPLETE** 2026-08-08 | (this commit) | 42 Core audio tests; full Release suite 11,563 passed / 4 skipped / 0 failed. Closes #355. |
| A2 | — | — | — |
| A3 | — | — | — |
| A4 | — | — | — |
diff --git a/src/AcDream.App/Audio/AudioHookSink.cs b/src/AcDream.App/Audio/AudioHookSink.cs
index a687fafc..e0acc8a6 100644
--- a/src/AcDream.App/Audio/AudioHookSink.cs
+++ b/src/AcDream.App/Audio/AudioHookSink.cs
@@ -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);
}
}
diff --git a/src/AcDream.App/Audio/OpenAlAudioEngine.cs b/src/AcDream.App/Audio/OpenAlAudioEngine.cs
index 959415ea..322f8a60 100644
--- a/src/AcDream.App/Audio/OpenAlAudioEngine.cs
+++ b/src/AcDream.App/Audio/OpenAlAudioEngine.cs
@@ -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).
///
- 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;
}
diff --git a/src/AcDream.Core/Audio/AudioModel.cs b/src/AcDream.Core/Audio/AudioModel.cs
index a4072c2b..776f27cf 100644
--- a/src/AcDream.Core/Audio/AudioModel.cs
+++ b/src/AcDream.Core/Audio/AudioModel.cs
@@ -13,22 +13,17 @@ namespace AcDream.Core.Audio;
// 23-member subset was replaced by retail's full 205-entry SoundType
// catalog: src/AcDream.Core/Audio/SoundId.cs (same namespace).
-///
-/// Per-SoundId entry from the SoundTable dat (0x20000000..0x2000FFFF).
-/// One Sound can have multiple entries with probabilities — that's
-/// retail's variation mechanism (e.g. 3 different footstep clips).
-///
-public sealed class SoundEntry
-{
- public uint WaveId { get; init; } // → Wave dat (0x0A000000..0x0A00FFFF)
- public int Priority { get; init; } // eviction ordering (0..7)
- public float Probability{ get; init; } // for entries with multiple alternatives
- public float VolumeBase { get; init; } // 0..1 multiplier applied before falloff
- public float PitchMin { get; init; }
- public float PitchMax { get; init; }
- public bool Loop { get; init; }
- public bool Is3D { get; init; } // 3D positional vs UI/music flat
-}
+// A local `SoundEntry` scaffold class lived here until 2026-08-08 (Campaign A
+// slice A1). It had no implementers and no readers — the live path consumes
+// DatReaderWriter's `SoundEntry` directly — and four of its eight fields were
+// invented: `PitchMin`/`PitchMax` (retail never calls SetFrequency), `Loop`
+// (retail never sets the DirectSound loop flag; "looping" ambients are
+// re-fired one-shots), and `Is3D` (every retail gameplay buffer is created
+// with m_3D = 0). Its `Priority` was also typed `int` "0..7" where the dat
+// field is a float in [0,1]. The `ISoundCache` interface that returned it went
+// the same way. Evidence:
+// docs/research/2026-08-08-audio-retail-soundmanager-core.md,
+// docs/research/2026-08-08-audio-retail-dat-layer.md §1.
///
/// Raw decoded PCM data from a Wave dat. Set by WaveDecoder at
@@ -102,14 +97,3 @@ public interface IAudioEngine : IDisposable
void PlayMusic(string resourceName, bool loop);
void StopMusic();
}
-
-///
-/// Cache of decoded waves + SoundTable lookups. Owned by the App-layer
-/// AudioEngine; Core exposes the interface.
-///
-public interface ISoundCache
-{
- WaveData GetWave(uint waveId);
- IReadOnlyList GetSoundEntries(SoundId id);
- IReadOnlyList GetSoundEntries(uint soundTableId, SoundId id);
-}
diff --git a/src/AcDream.Core/Audio/SoundCookbook.cs b/src/AcDream.Core/Audio/SoundCookbook.cs
index 8f9c9d83..0430f486 100644
--- a/src/AcDream.Core/Audio/SoundCookbook.cs
+++ b/src/AcDream.Core/Audio/SoundCookbook.cs
@@ -2,82 +2,109 @@ using System;
using System.Collections.Generic;
using DatReaderWriter.DBObjs;
using DRWSound = DatReaderWriter.Enums.Sound;
+using DRWSoundEntry = DatReaderWriter.Types.SoundEntry;
namespace AcDream.Core.Audio;
///
-/// Probabilistic entry picker over a retail .
+/// Retail's sound-selection model over a . Two
+/// INDEPENDENT steps, in retail's own order — a uniform variant pick, then a
+/// Bernoulli play/skip gate on the picked entry.
///
///
-/// Each key in
-/// SoundTable.Sounds maps to a list of
-/// items each carrying a
-/// probability weight. Retail picks one entry per trigger by rolling the
-/// cumulative distribution — that's how footsteps sound slightly
-/// different each step, how weapon swings have 3 swoosh variants, etc.
+/// Step 1 — . SoundManager::GetSound @
+/// 0x00550680: idx = (int)(roll * (n - 1)), truncating toward
+/// zero, where roll is .
+/// The entry's Probability plays NO part in selection.
///
///
///
-/// r05 §4: the picker samples a uniform random in [0,1) and walks the
-/// entries accumulating probabilities; the first entry whose running total
-/// exceeds the sample wins. If all probabilities sum to < 1, the
-/// remaining mass means "silence" — the call returns null. If probabilities
-/// sum to > 1 the picker still works correctly (it clamps on the last
-/// entry).
+/// Step 2 — . SoundManager::PlayProbability
+/// @ 0x005500E0: rand() * (1/32767) < probability. Failing the
+/// gate means the sound is simply not played — there is no fallback entry and
+/// no retry.
+///
+///
+///
+/// Corrects a pre-2026-08-08 divergence. The previous implementation
+/// walked a cumulative distribution weighted BY probability and
+/// short-circuited single-entry lists before rolling at all. The shipped dats
+/// hold 4,184 entries of which 4,183 are single-entry and 686 of those carry
+/// a probability below 1.0, so the gate was categorically absent: retail's
+/// 5%-chance creature idle chatter (Speak1, 49 entries at 0.05) fired
+/// on every trigger, wound/attack/swoosh variants never dropped, and six
+/// 0.0001-probability easter eggs played every time. Census and
+/// disassembly: docs/research/2026-08-08-audio-retail-dat-layer.md §2.
///
///
public static class SoundCookbook
{
///
- /// Pick one entry from a sound's variant list, weighted by probability.
- /// Returns null when the rolled sample falls into the "silence"
- /// remainder of the distribution (probability sum < 1).
+ /// Retail's uniform variant pick. Returns null only for an empty list.
+ ///
+ ///
+ /// The n - 1 is retail's, at 0x005506C8, and it is a real
+ /// Turbine off-by-one: because the roll is clamped below 1.0, the index
+ /// never reaches n - 1 and the LAST entry of a multi-entry sound
+ /// can never be selected. It is reproduced verbatim per the
+ /// port-faithfully rule ("do not 'fix' the decompiled code"). Blast
+ /// radius in the shipped dats is exactly one wave: only
+ /// 0x200000A8 / SoundType 31 has two entries, so
+ /// 0x0A00051E is retail-unreachable. Changing this to n
+ /// would need a divergence-register row.
+ ///
///
- public static DatReaderWriter.Types.SoundEntry? Roll(
- IReadOnlyList entries,
- Random rng)
+ public static DRWSoundEntry? PickVariant(
+ IReadOnlyList entries,
+ ISoundRandom rng)
{
ArgumentNullException.ThrowIfNull(entries);
ArgumentNullException.ThrowIfNull(rng);
if (entries.Count == 0) return null;
- if (entries.Count == 1) return entries[0];
- float sample = (float)rng.NextDouble();
- float cum = 0f;
- for (int i = 0; i < entries.Count; i++)
- {
- cum += Math.Max(0f, entries[i].Probability);
- if (sample < cum) return entries[i];
- }
- // Fell past the last entry — either probabilities sum to >1 (return
- // last) or < 1 and we rolled into the "silence" tail (return null).
- float total = 0f;
- for (int i = 0; i < entries.Count; i++)
- total += Math.Max(0f, entries[i].Probability);
-
- return total > 0.999f ? entries[entries.Count - 1] : null;
+ int idx = (int)(rng.NextVariantRoll() * (entries.Count - 1));
+ // Retail bounds-checks here too (`cmp eax, esi; jae return`); the
+ // branch is dead given the roll clamp, but it costs nothing to keep
+ // the same shape.
+ return idx < entries.Count ? entries[idx] : null;
}
///
- /// Convenience lookup: given a SoundTable + a retail
- /// key (e.g. Swoosh1, Footstep1), roll the
- /// entry list and return the winning entry. Returns null if:
- ///
- /// - has no mapping for
- /// .
- /// - The mapping's entry list is empty.
- /// - The probability roll hits the silence
- /// tail.
- ///
+ /// Retail's Bernoulli play gate. True = play, false = silence.
///
- public static DatReaderWriter.Types.SoundEntry? Roll(
+ public static bool PlayProbability(float probability, ISoundRandom rng)
+ {
+ ArgumentNullException.ThrowIfNull(rng);
+ return rng.NextProbabilityRoll() < probability;
+ }
+
+ ///
+ /// The two steps composed the way every retail play site composes them:
+ /// pick a variant, then gate it. Returns null when the sound is missing,
+ /// its variant list is empty, or the gate says silence.
+ ///
+ public static DRWSoundEntry? Select(
+ IReadOnlyList entries,
+ ISoundRandom rng)
+ {
+ DRWSoundEntry? picked = PickVariant(entries, rng);
+ if (picked is null) return null;
+ return PlayProbability(picked.Probability, rng) ? picked : null;
+ }
+
+ ///
+ /// with
+ /// the table lookup in front: given a and a
+ /// retail slot, resolve the entry to play.
+ ///
+ public static DRWSoundEntry? Select(
SoundTable table,
DRWSound sound,
- Random rng)
+ ISoundRandom rng)
{
ArgumentNullException.ThrowIfNull(table);
if (!table.Sounds.TryGetValue(sound, out var soundData)) return null;
- return Roll(soundData.Entries, rng);
+ return Select(soundData.Entries, rng);
}
}
diff --git a/src/AcDream.Core/Audio/SoundRandom.cs b/src/AcDream.Core/Audio/SoundRandom.cs
new file mode 100644
index 00000000..e8c6b08b
--- /dev/null
+++ b/src/AcDream.Core/Audio/SoundRandom.cs
@@ -0,0 +1,71 @@
+using System;
+
+namespace AcDream.Core.Audio;
+
+///
+/// The two random sources retail's sound code actually draws from. They are
+/// NOT interchangeable, and the difference is observable at small
+/// probabilities, so the seam models both rather than collapsing them.
+///
+///
+/// Variant index — Random::RollDice(0, 1) @ 0x0042C4C0,
+/// a dual-LCG returning a float hard-clamped to 0.99999988
+/// (0x00797D48), i.e. it can never return 1.0. That clamp is what
+/// makes the last variant of a multi-entry sound unreachable — see
+/// .
+///
+///
+///
+/// Probability gate — C library rand() scaled by
+/// 1/32767 inside SoundManager::PlayProbability @
+/// 0x005500E0. rand() returns 0..32767 INCLUSIVE, so the
+/// scaled value reaches exactly 1.0 and the grid step is ~3.05e-5. Two
+/// consequences we reproduce deliberately: a probability of 1.0 is skipped
+/// on the single roll where the value lands on 1.0 (1 chance in 32768), and
+/// the six authored 0.0001 probabilities in the shipped dats resolve to
+/// ~1.2e-4 rather than 1e-4 because only rand() <= 3 passes.
+///
+///
+public interface ISoundRandom
+{
+ ///
+ /// Retail Random::RollDice(0, 1): a float in
+ /// [0, 0.99999988], never 1.0.
+ ///
+ float NextVariantRoll();
+
+ ///
+ /// Retail rand() * (1/32767): a float in [0, 1] inclusive,
+ /// quantised to the 1/32767 grid.
+ ///
+ float NextProbabilityRoll();
+}
+
+///
+/// over . Retail's exact LCG
+/// streams are not reproduced — nothing observable depends on the sequence,
+/// only on each roll's range and quantisation, which this preserves.
+///
+public sealed class SoundRandom : ISoundRandom
+{
+ ///
+ /// Retail's clamp on Random::rand (0x00797D48). Equals
+ /// 1 - 2^-23, the largest float below 1.0.
+ ///
+ internal const float MaxVariantRoll = 0.99999988f;
+
+ /// C's RAND_MAX; retail divides by exactly this.
+ internal const int RandMax = 32767;
+
+ private readonly Random _rng;
+
+ public SoundRandom(Random? rng = null) => _rng = rng ?? Random.Shared;
+
+ public float NextVariantRoll() =>
+ MathF.Min(MaxVariantRoll, (float)_rng.NextDouble());
+
+ // Next's upper bound is exclusive, so RandMax + 1 makes RAND_MAX itself
+ // reachable — which is what lets the scaled roll reach exactly 1.0.
+ public float NextProbabilityRoll() =>
+ _rng.Next(0, RandMax + 1) * (1f / RandMax);
+}
diff --git a/tests/AcDream.Core.Tests/Audio/SoundCookbookTests.cs b/tests/AcDream.Core.Tests/Audio/SoundCookbookTests.cs
index 20568ff0..7a2ce2b7 100644
--- a/tests/AcDream.Core.Tests/Audio/SoundCookbookTests.cs
+++ b/tests/AcDream.Core.Tests/Audio/SoundCookbookTests.cs
@@ -2,96 +2,243 @@ using System;
using System.Collections.Generic;
using AcDream.Core.Audio;
using DatReaderWriter.DBObjs;
-using DRWSoundEntry = DatReaderWriter.Types.SoundEntry;
using DatReaderWriter.Types;
+using DRWSoundEntry = DatReaderWriter.Types.SoundEntry;
using DRWSound = DatReaderWriter.Enums.Sound;
using Xunit;
namespace AcDream.Core.Tests.Audio;
+///
+/// Conformance tests for retail's sound-selection model
+/// (SoundManager::GetSound @ 0x00550680 +
+/// SoundManager::PlayProbability @ 0x005500E0). Golden values are the
+/// disassembled behaviour recorded in
+/// docs/research/2026-08-08-audio-retail-dat-layer.md §2, not this
+/// implementation's own output.
+///
public sealed class SoundCookbookTests
{
- // Deterministic Random for golden-value tests.
- private static Random Seed(int seed) => new Random(seed);
-
- [Fact]
- public void Roll_EmptyList_ReturnsNull()
+ ///
+ /// Feeds exact rolls so index/gate boundaries are pinned rather than
+ /// sampled. Variant and probability rolls are separate queues because
+ /// retail draws them from two different generators.
+ ///
+ private sealed class ScriptedRandom : ISoundRandom
{
- Assert.Null(SoundCookbook.Roll(new List(), Seed(1)));
- }
+ private readonly Queue _variant;
+ private readonly Queue _probability;
- [Fact]
- public void Roll_SingleEntry_AlwaysReturnsIt()
- {
- var e = new DRWSoundEntry { Probability = 0.5f, Priority = 4f, Volume = 1f };
- var entries = new List { e };
- Assert.Same(e, SoundCookbook.Roll(entries, Seed(1)));
- Assert.Same(e, SoundCookbook.Roll(entries, Seed(999)));
- }
-
- [Fact]
- public void Roll_WeightedEntries_DistributionMatches()
- {
- // Three entries: 50%, 30%, 20%. Roll 10000 times and verify counts
- // are within 5% of expected.
- var a = new DRWSoundEntry { Probability = 0.5f };
- var b = new DRWSoundEntry { Probability = 0.3f };
- var c = new DRWSoundEntry { Probability = 0.2f };
- var entries = new List { a, b, c };
-
- var rng = new Random(42);
- int countA = 0, countB = 0, countC = 0, countNull = 0;
- for (int i = 0; i < 10000; i++)
+ public ScriptedRandom(float[]? variant = null, float[]? probability = null)
{
- var picked = SoundCookbook.Roll(entries, rng);
- if (ReferenceEquals(picked, a)) countA++;
- else if (ReferenceEquals(picked, b)) countB++;
- else if (ReferenceEquals(picked, c)) countC++;
- else countNull++;
+ _variant = new Queue(variant ?? Array.Empty());
+ _probability = new Queue(probability ?? Array.Empty());
}
- Assert.InRange(countA, 4500, 5500);
- Assert.InRange(countB, 2500, 3500);
- Assert.InRange(countC, 1500, 2500);
- // Probabilities sum to 1.0 → no null rolls.
- Assert.True(countNull < 100);
+ public float NextVariantRoll() => _variant.Dequeue();
+ public float NextProbabilityRoll() => _probability.Dequeue();
}
+ private static DRWSoundEntry Entry(uint id, float probability = 1f, float volume = 1f, float priority = 1f) =>
+ new() { Id = id, Probability = probability, Volume = volume, Priority = priority };
+
+ // ── PickVariant: retail's uniform index over (n - 1) ────────────────────
+
[Fact]
- public void Roll_SilenceTail_ReturnsNullOccasionally()
+ public void PickVariant_EmptyList_ReturnsNull()
{
- // Two entries that only cover 60% of the probability mass — the
- // remaining 40% should roll as "silence" (null return).
- var a = new DRWSoundEntry { Probability = 0.3f };
- var b = new DRWSoundEntry { Probability = 0.3f };
- var entries = new List { a, b };
-
- var rng = new Random(42);
- int nullCount = 0;
- for (int i = 0; i < 10000; i++)
- {
- if (SoundCookbook.Roll(entries, rng) is null)
- nullCount++;
- }
- Assert.InRange(nullCount, 3500, 4500); // ~40% ± margin
+ Assert.Null(SoundCookbook.PickVariant(new List(), new ScriptedRandom(new[] { 0f })));
}
[Fact]
- public void Roll_WithSoundTable_LooksUpBySound()
+ public void PickVariant_SingleEntry_AlwaysIndexZero()
+ {
+ // n == 1 → idx = (int)(roll * 0) == 0 for every roll, including the
+ // clamp ceiling. This is the one arity where (n-1) is harmless.
+ var entries = new List { Entry(0x0A000001) };
+ foreach (float roll in new[] { 0f, 0.5f, SoundRandom.MaxVariantRoll })
+ {
+ var picked = SoundCookbook.PickVariant(entries, new ScriptedRandom(new[] { roll }));
+ Assert.Equal(0x0A000001u, picked!.Id.DataId);
+ }
+ }
+
+ [Fact]
+ public void PickVariant_TwoEntries_LastIsUnreachable()
+ {
+ // Retail's `lea ecx,[esi-1]` @ 0x005506C8 with the roll clamped below
+ // 1.0 means idx ∈ [0, n-2]: entry[1] can never be selected. This is a
+ // genuine Turbine off-by-one, reproduced deliberately. In the shipped
+ // dats it costs exactly one wave (0x0A00051E under table 0x200000A8).
+ var entries = new List { Entry(0x0A000001), Entry(0x0A00051E) };
+ foreach (float roll in new[] { 0f, 0.25f, 0.5f, 0.75f, 0.999f, SoundRandom.MaxVariantRoll })
+ {
+ var picked = SoundCookbook.PickVariant(entries, new ScriptedRandom(new[] { roll }));
+ Assert.Equal(0x0A000001u, picked!.Id.DataId);
+ }
+ }
+
+ [Theory]
+ // n == 3 → idx = (int)(roll * 2): halves of the roll range map to 0 and 1.
+ [InlineData(0f, 0)]
+ [InlineData(0.49f, 0)]
+ [InlineData(0.5f, 1)]
+ [InlineData(SoundRandom.MaxVariantRoll, 1)]
+ public void PickVariant_ThreeEntries_TruncatesTowardZero(float roll, int expectedIndex)
+ {
+ var entries = new List
+ {
+ Entry(0x0A000001), Entry(0x0A000002), Entry(0x0A000003),
+ };
+ var picked = SoundCookbook.PickVariant(entries, new ScriptedRandom(new[] { roll }));
+ Assert.Equal(entries[expectedIndex].Id.DataId, picked!.Id.DataId);
+ }
+
+ [Fact]
+ public void PickVariant_IgnoresProbabilityEntirely()
+ {
+ // Probability is NOT a selection weight in retail. A 0.0-probability
+ // first entry is still the only selectable one at n == 2.
+ var entries = new List
+ {
+ Entry(0x0A000001, probability: 0f),
+ Entry(0x0A000002, probability: 1f),
+ };
+ var picked = SoundCookbook.PickVariant(entries, new ScriptedRandom(new[] { 0.9f }));
+ Assert.Equal(0x0A000001u, picked!.Id.DataId);
+ }
+
+ // ── PlayProbability: the Bernoulli gate ────────────────────────────────
+
+ [Theory]
+ [InlineData(0.5f, 0.49f, true)]
+ [InlineData(0.5f, 0.5f, false)] // strict <
+ [InlineData(0.5f, 0.51f, false)]
+ [InlineData(0f, 0f, false)] // probability 0 never plays
+ [InlineData(1f, 0.99997f, true)]
+ [InlineData(1f, 1f, false)] // rand() == RAND_MAX → skipped even at p=1
+ public void PlayProbability_IsStrictLessThan(float probability, float roll, bool expected)
+ {
+ Assert.Equal(
+ expected,
+ SoundCookbook.PlayProbability(probability, new ScriptedRandom(probability: new[] { roll })));
+ }
+
+ [Fact]
+ public void PlayProbability_ZeroProbability_NeverPlaysAcrossTheWholeGrid()
+ {
+ // 686 single-entry dat rows carry probability < 1.0; a 0.0 row must be
+ // silent for every possible roll on the 1/32767 grid.
+ var rng = new SoundRandom(new Random(1));
+ for (int i = 0; i < 20_000; i++)
+ Assert.False(SoundCookbook.PlayProbability(0f, rng));
+ }
+
+ [Fact]
+ public void PlayProbability_FivePercent_MatchesRetailRate()
+ {
+ // Speak1 creature idle chatter: 49 dat entries at 0.05. Before this
+ // slice acdream played them at 100%.
+ var rng = new SoundRandom(new Random(20260808));
+ int played = 0;
+ for (int i = 0; i < 100_000; i++)
+ if (SoundCookbook.PlayProbability(0.05f, rng)) played++;
+
+ Assert.InRange(played, 4_600, 5_400); // 5% ± 0.4pp
+ }
+
+ [Fact]
+ public void ProbabilityRoll_ReachesExactlyOne_AndNeverExceedsIt()
+ {
+ // The gate's grid is rand()/32767 with rand() ∈ [0, 32767], so 1.0 is
+ // attainable — that attainability is what makes p=1.0 skip 1-in-32768.
+ var rng = new SoundRandom(new Random(7));
+ bool sawOne = false;
+ for (int i = 0; i < 500_000; i++)
+ {
+ float roll = rng.NextProbabilityRoll();
+ Assert.InRange(roll, 0f, 1f);
+ if (roll == 1f) sawOne = true;
+ }
+ Assert.True(sawOne, "rand()/32767 must be able to return exactly 1.0");
+ }
+
+ [Fact]
+ public void VariantRoll_NeverReachesOne()
+ {
+ // Random::rand's clamp @ 0x00797D48. If this ever returned 1.0, a
+ // 2-entry sound could select its (retail-unreachable) last entry.
+ var rng = new SoundRandom(new Random(11));
+ for (int i = 0; i < 500_000; i++)
+ {
+ float roll = rng.NextVariantRoll();
+ Assert.InRange(roll, 0f, SoundRandom.MaxVariantRoll);
+ Assert.True(roll < 1f);
+ }
+ }
+
+ // ── Select: the two steps composed, as every play site composes them ───
+
+ [Fact]
+ public void Select_GateFailure_ReturnsNull()
+ {
+ var entries = new List { Entry(0x0A000001, probability: 0.05f) };
+ var rng = new ScriptedRandom(variant: new[] { 0f }, probability: new[] { 0.9f });
+ Assert.Null(SoundCookbook.Select(entries, rng));
+ }
+
+ [Fact]
+ public void Select_GatePass_ReturnsPickedEntry()
+ {
+ var entries = new List { Entry(0x0A000001, probability: 0.05f) };
+ var rng = new ScriptedRandom(variant: new[] { 0f }, probability: new[] { 0.01f });
+ Assert.Equal(0x0A000001u, SoundCookbook.Select(entries, rng)!.Id.DataId);
+ }
+
+ [Fact]
+ public void Select_SingleEntryBelowOne_IsGated_NotShortCircuited()
+ {
+ // The regression this slice exists for: 4,183 of 4,184 shipped entries
+ // are single-entry lists, and the old implementation returned them
+ // without ever consulting probability.
+ var entries = new List { Entry(0x0A000001, probability: 0.05f) };
+ var rng = new SoundRandom(new Random(99));
+ int played = 0;
+ for (int i = 0; i < 20_000; i++)
+ if (SoundCookbook.Select(entries, rng) is not null) played++;
+
+ Assert.InRange(played, 800, 1_200); // ~5% of 20k, not 20k
+ }
+
+ [Fact]
+ public void Select_WithSoundTable_LooksUpBySound()
{
var table = new SoundTable();
- var footstep = new DRWSoundEntry { Probability = 1f, Volume = 0.7f };
table.Sounds[DRWSound.Footstep1] = new SoundData();
- table.Sounds[DRWSound.Footstep1].Entries.Add(footstep);
+ table.Sounds[DRWSound.Footstep1].Entries.Add(Entry(0x0A000123, volume: 0.7f));
- var picked = SoundCookbook.Roll(table, DRWSound.Footstep1, Seed(1));
- Assert.Same(footstep, picked);
+ var rng = new ScriptedRandom(variant: new[] { 0f }, probability: new[] { 0f });
+ var picked = SoundCookbook.Select(table, DRWSound.Footstep1, rng);
+ Assert.Equal(0x0A000123u, picked!.Id.DataId);
+ Assert.Equal(0.7f, picked.Volume);
}
[Fact]
- public void Roll_WithSoundTable_MissingSound_ReturnsNull()
+ public void Select_WithSoundTable_MissingSound_ReturnsNull()
{
- var table = new SoundTable(); // no entries at all
- Assert.Null(SoundCookbook.Roll(table, DRWSound.Attack1, Seed(1)));
+ var table = new SoundTable();
+ var rng = new ScriptedRandom(variant: new[] { 0f }, probability: new[] { 0f });
+ Assert.Null(SoundCookbook.Select(table, DRWSound.Attack1, rng));
+ }
+
+ [Fact]
+ public void Select_EmptyEntryList_DoesNotConsumeAProbabilityRoll()
+ {
+ // Retail returns from GetSound before reaching any play site, so the
+ // gate is never rolled. An empty probability queue proves it.
+ var table = new SoundTable();
+ table.Sounds[DRWSound.Attack1] = new SoundData();
+ var rng = new ScriptedRandom(variant: new[] { 0f });
+ Assert.Null(SoundCookbook.Select(table, DRWSound.Attack1, rng));
}
}