using System;
using System.Collections.Generic;
using AcDream.Core.Audio;
using DatReaderWriter.DBObjs;
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
{
///
/// 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
{
private readonly Queue _variant;
private readonly Queue _probability;
public ScriptedRandom(float[]? variant = null, float[]? probability = null)
{
_variant = new Queue(variant ?? Array.Empty());
_probability = new Queue(probability ?? Array.Empty());
}
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 PickVariant_EmptyList_ReturnsNull()
{
Assert.Null(SoundCookbook.PickVariant(new List(), new ScriptedRandom(new[] { 0f })));
}
[Fact]
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();
table.Sounds[DRWSound.Footstep1] = new SoundData();
table.Sounds[DRWSound.Footstep1].Entries.Add(Entry(0x0A000123, volume: 0.7f));
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 Select_WithSoundTable_MissingSound_ReturnsNull()
{
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));
}
}