acdream/src/AcDream.Core/Audio/SoundRandom.cs
Erik c69b3bde04 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>
2026-08-08 21:28:16 +02:00

71 lines
2.7 KiB
C#

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);
}