From c69b3bde049ec3dc42c23e3602428ceed493f998 Mon Sep 17 00:00:00 2001 From: Erik Date: Sat, 8 Aug 2026 21:28:16 +0200 Subject: [PATCH] =?UTF-8?q?fix(audio):=20Campaign=20A=20slice=20A1=20?= =?UTF-8?q?=E2=80=94=20retail's=20sound=20probability=20gate=20(#355)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- docs/ISSUES.md | 41 +++ .../plans/2026-08-08-audio-parity-campaign.md | 2 +- src/AcDream.App/Audio/AudioHookSink.cs | 41 ++- src/AcDream.App/Audio/OpenAlAudioEngine.cs | 19 +- src/AcDream.Core/Audio/AudioModel.cs | 38 +-- src/AcDream.Core/Audio/SoundCookbook.cs | 121 +++++--- src/AcDream.Core/Audio/SoundRandom.cs | 71 +++++ .../Audio/SoundCookbookTests.cs | 273 ++++++++++++++---- 8 files changed, 445 insertions(+), 161 deletions(-) create mode 100644 src/AcDream.Core/Audio/SoundRandom.cs 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 indexRandom::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)); } }