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:
parent
ffa5087527
commit
c69b3bde04
8 changed files with 445 additions and 161 deletions
|
|
@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// Conformance tests for retail's sound-selection model
|
||||
/// (<c>SoundManager::GetSound</c> @ 0x00550680 +
|
||||
/// <c>SoundManager::PlayProbability</c> @ 0x005500E0). Golden values are the
|
||||
/// disassembled behaviour recorded in
|
||||
/// <c>docs/research/2026-08-08-audio-retail-dat-layer.md</c> §2, not this
|
||||
/// implementation's own output.
|
||||
/// </summary>
|
||||
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()
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
private sealed class ScriptedRandom : ISoundRandom
|
||||
{
|
||||
Assert.Null(SoundCookbook.Roll(new List<DRWSoundEntry>(), Seed(1)));
|
||||
}
|
||||
private readonly Queue<float> _variant;
|
||||
private readonly Queue<float> _probability;
|
||||
|
||||
[Fact]
|
||||
public void Roll_SingleEntry_AlwaysReturnsIt()
|
||||
{
|
||||
var e = new DRWSoundEntry { Probability = 0.5f, Priority = 4f, Volume = 1f };
|
||||
var entries = new List<DRWSoundEntry> { 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<DRWSoundEntry> { 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<float>(variant ?? Array.Empty<float>());
|
||||
_probability = new Queue<float>(probability ?? Array.Empty<float>());
|
||||
}
|
||||
|
||||
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<DRWSoundEntry> { 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<DRWSoundEntry>(), 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<DRWSoundEntry> { 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<DRWSoundEntry> { 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<DRWSoundEntry>
|
||||
{
|
||||
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<DRWSoundEntry>
|
||||
{
|
||||
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<DRWSoundEntry> { 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<DRWSoundEntry> { 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<DRWSoundEntry> { 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));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue