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

@ -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).
/// <summary>
/// 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).
/// </summary>
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.
/// <summary>
/// Raw decoded PCM data from a Wave dat. Set by <c>WaveDecoder</c> at
@ -102,14 +97,3 @@ public interface IAudioEngine : IDisposable
void PlayMusic(string resourceName, bool loop);
void StopMusic();
}
/// <summary>
/// Cache of decoded waves + SoundTable lookups. Owned by the App-layer
/// AudioEngine; Core exposes the interface.
/// </summary>
public interface ISoundCache
{
WaveData GetWave(uint waveId);
IReadOnlyList<SoundEntry> GetSoundEntries(SoundId id);
IReadOnlyList<SoundEntry> GetSoundEntries(uint soundTableId, SoundId id);
}

View file

@ -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;
/// <summary>
/// Probabilistic entry picker over a retail <see cref="SoundTable"/>.
/// Retail's sound-selection model over a <see cref="SoundTable"/>. Two
/// INDEPENDENT steps, in retail's own order — a uniform variant pick, then a
/// Bernoulli play/skip gate on the picked entry.
///
/// <para>
/// Each <see cref="DatReaderWriter.Enums.Sound"/> key in
/// <c>SoundTable.Sounds</c> maps to a list of
/// <see cref="DatReaderWriter.Types.SoundEntry"/> 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.
/// <b>Step 1 — <see cref="PickVariant"/></b>. <c>SoundManager::GetSound</c> @
/// <c>0x00550680</c>: <c>idx = (int)(roll * (n - 1))</c>, truncating toward
/// zero, where <c>roll</c> is <see cref="ISoundRandom.NextVariantRoll"/>.
/// The entry's <c>Probability</c> plays NO part in selection.
/// </para>
///
/// <para>
/// 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 &lt; 1, the
/// remaining mass means "silence" — the call returns null. If probabilities
/// sum to &gt; 1 the picker still works correctly (it clamps on the last
/// entry).
/// <b>Step 2 — <see cref="PlayProbability"/></b>. <c>SoundManager::PlayProbability</c>
/// @ <c>0x005500E0</c>: <c>rand() * (1/32767) &lt; probability</c>. Failing the
/// gate means the sound is simply not played — there is no fallback entry and
/// no retry.
/// </para>
///
/// <para>
/// <b>Corrects a pre-2026-08-08 divergence.</b> 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 (<c>Speak1</c>, 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: <c>docs/research/2026-08-08-audio-retail-dat-layer.md</c> §2.
/// </para>
/// </summary>
public static class SoundCookbook
{
/// <summary>
/// Pick one entry from a sound's variant list, weighted by probability.
/// Returns <c>null</c> when the rolled sample falls into the "silence"
/// remainder of the distribution (probability sum &lt; 1).
/// Retail's uniform variant pick. Returns null only for an empty list.
///
/// <para>
/// The <c>n - 1</c> is retail's, at <c>0x005506C8</c>, and it is a real
/// Turbine off-by-one: because the roll is clamped below 1.0, the index
/// never reaches <c>n - 1</c> 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
/// <c>0x200000A8</c> / SoundType 31 has two entries, so
/// <c>0x0A00051E</c> is retail-unreachable. Changing this to <c>n</c>
/// would need a divergence-register row.
/// </para>
/// </summary>
public static DatReaderWriter.Types.SoundEntry? Roll(
IReadOnlyList<DatReaderWriter.Types.SoundEntry> entries,
Random rng)
public static DRWSoundEntry? PickVariant(
IReadOnlyList<DRWSoundEntry> 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;
}
/// <summary>
/// Convenience lookup: given a SoundTable + a retail
/// <see cref="DRWSound"/> key (e.g. Swoosh1, Footstep1), roll the
/// entry list and return the winning entry. Returns null if:
/// <list type="bullet">
/// <item><description><paramref name="table"/> has no mapping for
/// <paramref name="sound"/>.</description></item>
/// <item><description>The mapping's entry list is empty.</description></item>
/// <item><description>The probability roll hits the silence
/// tail.</description></item>
/// </list>
/// Retail's Bernoulli play gate. True = play, false = silence.
/// </summary>
public static DatReaderWriter.Types.SoundEntry? Roll(
public static bool PlayProbability(float probability, ISoundRandom rng)
{
ArgumentNullException.ThrowIfNull(rng);
return rng.NextProbabilityRoll() < probability;
}
/// <summary>
/// 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.
/// </summary>
public static DRWSoundEntry? Select(
IReadOnlyList<DRWSoundEntry> entries,
ISoundRandom rng)
{
DRWSoundEntry? picked = PickVariant(entries, rng);
if (picked is null) return null;
return PlayProbability(picked.Probability, rng) ? picked : null;
}
/// <summary>
/// <see cref="Select(IReadOnlyList{DRWSoundEntry}, ISoundRandom)"/> with
/// the table lookup in front: given a <see cref="SoundTable"/> and a
/// retail <see cref="DRWSound"/> slot, resolve the entry to play.
/// </summary>
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);
}
}

View file

@ -0,0 +1,71 @@
using System;
namespace AcDream.Core.Audio;
/// <summary>
/// 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.
///
/// <para>
/// <b>Variant index</b> — <c>Random::RollDice(0, 1)</c> @ <c>0x0042C4C0</c>,
/// a dual-LCG returning a float hard-clamped to <c>0.99999988</c>
/// (<c>0x00797D48</c>), i.e. it can never return 1.0. That clamp is what
/// makes the last variant of a multi-entry sound unreachable — see
/// <see cref="SoundCookbook.PickVariant"/>.
/// </para>
///
/// <para>
/// <b>Probability gate</b> — C library <c>rand()</c> scaled by
/// <c>1/32767</c> inside <c>SoundManager::PlayProbability</c> @
/// <c>0x005500E0</c>. <c>rand()</c> 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 <c>rand() &lt;= 3</c> passes.
/// </para>
/// </summary>
public interface ISoundRandom
{
/// <summary>
/// Retail <c>Random::RollDice(0, 1)</c>: a float in
/// <c>[0, 0.99999988]</c>, never 1.0.
/// </summary>
float NextVariantRoll();
/// <summary>
/// Retail <c>rand() * (1/32767)</c>: a float in <c>[0, 1]</c> inclusive,
/// quantised to the 1/32767 grid.
/// </summary>
float NextProbabilityRoll();
}
/// <summary>
/// <see cref="ISoundRandom"/> over <see cref="Random"/>. 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.
/// </summary>
public sealed class SoundRandom : ISoundRandom
{
/// <summary>
/// Retail's clamp on <c>Random::rand</c> (<c>0x00797D48</c>). Equals
/// <c>1 - 2^-23</c>, the largest float below 1.0.
/// </summary>
internal const float MaxVariantRoll = 0.99999988f;
/// <summary>C's <c>RAND_MAX</c>; retail divides by exactly this.</summary>
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);
}