using System; using System.Linq; using AcDream.Core.Audio; using Xunit; namespace AcDream.Core.Tests.Audio; /// /// Conformance tests for retail's voice allocator, /// SoundManager::PlaySoundInternal(SoundBufRef*, int, int) @ /// 0x0054FEC0, decoded in /// docs/research/2026-08-08-audio-retail-soundmanager-core.md §1. /// /// /// The behaviour under test is the second-largest change in the audio campaign: /// before it, acdream evicted by GAIN, so a loud unimportant sound could silence /// a quiet important one. /// /// public sealed class RetailVoicePoolTests { private static VoiceSlotState Free() => new(Occupied: false, StillPlaying: false, Priority: 0f); private static VoiceSlotState Finished(float priority) => new(Occupied: true, StillPlaying: false, Priority: priority); private static VoiceSlotState Busy(float priority) => new(Occupied: true, StillPlaying: true, Priority: priority); private static VoiceSlotState[] AllBusy(float priority, int count = 16) { var slots = new VoiceSlotState[count]; Array.Fill(slots, Busy(priority)); return slots; } [Fact] public void EmptyPool_DropsTheSound() { Assert.Equal(RetailVoicePool.NoSlot, RetailVoicePool.Acquire(Array.Empty(), 0, 1f)); } [Fact] public void FirstPass_PrefersAFreeSlot_ScanningFromTheCursor() { var slots = AllBusy(1f); slots[9] = Free(); Assert.Equal(9, RetailVoicePool.Acquire(slots, cursor: 0, priority: 0f)); } [Fact] public void FirstPass_ReclaimsAFinishedVoice_EvenAtHigherPriority() { // A finished voice is as reclaimable as an empty slot, whatever priority // it was claimed with — the first pass never compares priority. var slots = AllBusy(1f); slots[4] = Finished(1f); Assert.Equal(4, RetailVoicePool.Acquire(slots, cursor: 0, priority: 0.1f)); } [Fact] public void FirstPass_WrapsAroundTheRing() { var slots = AllBusy(1f); slots[2] = Free(); // Starting at 5, the scan must wrap past 15 to reach slot 2. Assert.Equal(2, RetailVoicePool.Acquire(slots, cursor: 5, priority: 0f)); } [Fact] public void FirstPass_TakesTheNearestFreeSlotInRingOrder() { var slots = AllBusy(1f); slots[1] = Free(); slots[12] = Free(); Assert.Equal(12, RetailVoicePool.Acquire(slots, cursor: 10, priority: 0f)); } [Fact] public void SecondPass_EvictsStrictlyLowerPriority() { var slots = AllBusy(0.5f); slots[7] = Busy(0.2f); Assert.Equal(7, RetailVoicePool.Acquire(slots, cursor: 0, priority: 0.3f)); } [Fact] public void SecondPass_EqualPriorityNeverEvicts() { // Retail's compare is `slot.priority < new.priority`. A pool full of // equal-priority voices drops the newcomer. var slots = AllBusy(0.5f); Assert.Equal(RetailVoicePool.NoSlot, RetailVoicePool.Acquire(slots, cursor: 0, priority: 0.5f)); } [Fact] public void SecondPass_HigherPriorityPoolDropsTheNewSound() { var slots = AllBusy(0.9f); Assert.Equal(RetailVoicePool.NoSlot, RetailVoicePool.Acquire(slots, cursor: 0, priority: 0.4f)); } [Fact] public void SecondPass_TakesTheFirstLowerSlotInRingOrder_NotTheLowest() { // Retail stops at the FIRST slot below the incoming priority; it does not // search for the quietest or least important one. var slots = AllBusy(0.9f); slots[3] = Busy(0.1f); slots[6] = Busy(0.5f); Assert.Equal(6, RetailVoicePool.Acquire(slots, cursor: 6, priority: 0.6f)); } [Fact] public void Eviction_IgnoresGain_ByConstruction() { // There is no gain in VoiceSlotState at all — the type cannot express the // old behaviour. This test documents that as an intentional property. var slots = AllBusy(0.8f); Assert.Equal( RetailVoicePool.NoSlot, RetailVoicePool.Acquire(slots, cursor: 0, priority: 0.8f)); Assert.DoesNotContain( "Gain", string.Join(",", typeof(VoiceSlotState).GetProperties().Select(p => p.Name))); } [Theory] [InlineData(0, 1)] [InlineData(15, 0)] [InlineData(9, 10)] public void Cursor_AdvancesPastTheClaimedSlot_AndWraps(int claimed, int expected) { Assert.Equal(expected, RetailVoicePool.AdvanceCursor(claimed, 16)); } [Fact] public void RingOrder_IsStableAcrossRepeatedClaims() { // Round-robin over a pool whose voices finish immediately: successive // claims must walk the ring rather than reusing one slot. var slots = new VoiceSlotState[4]; Array.Fill(slots, Free()); int cursor = 0; var claimed = new int[4]; for (int i = 0; i < 4; i++) { claimed[i] = RetailVoicePool.Acquire(slots, cursor, 1f); cursor = RetailVoicePool.AdvanceCursor(claimed[i], slots.Length); } Assert.Equal(new[] { 0, 1, 2, 3 }, claimed); } }