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>
110 lines
4.5 KiB
C#
110 lines
4.5 KiB
C#
using System;
|
|
using System.Collections.Generic;
|
|
using DatReaderWriter.DBObjs;
|
|
using DRWSound = DatReaderWriter.Enums.Sound;
|
|
using DRWSoundEntry = DatReaderWriter.Types.SoundEntry;
|
|
|
|
namespace AcDream.Core.Audio;
|
|
|
|
/// <summary>
|
|
/// 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>
|
|
/// <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>
|
|
/// <b>Step 2 — <see cref="PlayProbability"/></b>. <c>SoundManager::PlayProbability</c>
|
|
/// @ <c>0x005500E0</c>: <c>rand() * (1/32767) < 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>
|
|
/// 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 DRWSoundEntry? PickVariant(
|
|
IReadOnlyList<DRWSoundEntry> entries,
|
|
ISoundRandom rng)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(entries);
|
|
ArgumentNullException.ThrowIfNull(rng);
|
|
|
|
if (entries.Count == 0) return 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>
|
|
/// Retail's Bernoulli play gate. True = play, false = silence.
|
|
/// </summary>
|
|
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,
|
|
ISoundRandom rng)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(table);
|
|
if (!table.Sounds.TryGetValue(sound, out var soundData)) return null;
|
|
return Select(soundData.Entries, rng);
|
|
}
|
|
}
|