diff --git a/src/SharpEmu.Libs/Audio/AudioOutExports.cs b/src/SharpEmu.Libs/Audio/AudioOutExports.cs index ceee3fb0..7626d5f1 100644 --- a/src/SharpEmu.Libs/Audio/AudioOutExports.cs +++ b/src/SharpEmu.Libs/Audio/AudioOutExports.cs @@ -4,6 +4,7 @@ using SharpEmu.HLE; using SharpEmu.HLE.Host; using System.Buffers; +using System.Buffers.Binary; using System.Collections.Concurrent; using System.Diagnostics; @@ -11,8 +12,17 @@ namespace SharpEmu.Libs.Audio; public static class AudioOutExports { + private const int AudioOutOutputParamSize = 16; + private const int AudioOutMaximumOutputCount = 25; + + internal const int AudioOutErrorInvalidPort = unchecked((int)0x80260003); + internal const int AudioOutErrorInvalidPointer = unchecked((int)0x80260004); + internal const int AudioOutErrorPortFull = unchecked((int)0x80260005); + internal const int AudioOutErrorInvalidSize = unchecked((int)0x80260006); + private static readonly ConcurrentDictionary Ports = new(); private static int _nextPortHandle; + private static Func? _streamFactoryForTests; // Diagnostic: confirm sceAudioOutOutput is actually called and whether the // guest submits real samples or silence. Gated so it costs nothing when off. @@ -56,6 +66,7 @@ public static class AudioOutExports public int BytesPerSample { get; } public bool IsFloat { get; } public IHostAudioStream? Backend { get; } + public object SubmissionGate { get; } = new(); public volatile float Volume = 1.0f; public int BufferByteLength => checked((int)BufferLength * Channels * BytesPerSample); @@ -83,7 +94,24 @@ public static class AudioOutExports } } - public void Dispose() => Backend?.Dispose(); + public void Dispose() + { + lock (SubmissionGate) + { + Backend?.Dispose(); + } + } + } + + private readonly record struct OutputDescriptor(int Handle, ulong SourceAddress); + + private struct ResolvedOutput + { + public int Handle; + public ulong SourceAddress; + public PortState Port; + public byte[]? HostBuffer; + public int HostBufferLength; } [SysAbiExport( @@ -115,9 +143,18 @@ public static class AudioOutExports string backendName; try { - var audio = HostPlatform.Current.Audio; - backend = audio.OpenStereoPcm16Stream(frequency); - backendName = audio.BackendName; + var streamFactory = Volatile.Read(ref _streamFactoryForTests); + if (streamFactory is not null) + { + backend = streamFactory(frequency); + backendName = "test"; + } + else + { + var audio = HostPlatform.Current.Audio; + backend = audio.OpenStereoPcm16Stream(frequency); + backendName = audio.BackendName; + } } catch (Exception exception) { @@ -192,6 +229,46 @@ public static class AudioOutExports return ctx.SetReturn(0); } + [SysAbiExport( + Nid = "w3PdaSTSwGE", + ExportName = "sceAudioOutOutputs", + Target = Generation.Gen4 | Generation.Gen5, + LibraryName = "libSceAudioOut")] + public static int AudioOutOutputs(CpuContext ctx) + { + var parameterAddress = ctx[CpuRegister.Rdi]; + var outputCount = unchecked((uint)ctx[CpuRegister.Rsi]); + if (outputCount == 0 || outputCount > AudioOutMaximumOutputCount) + { + return ctx.SetReturn(AudioOutErrorPortFull); + } + + if (parameterAddress == 0) + { + return ctx.SetReturn(AudioOutErrorInvalidPointer); + } + + var count = checked((int)outputCount); + Span parameterBytes = + stackalloc byte[AudioOutMaximumOutputCount * AudioOutOutputParamSize]; + parameterBytes = parameterBytes[..checked(count * AudioOutOutputParamSize)]; + if (!ctx.Memory.TryRead(parameterAddress, parameterBytes)) + { + return ctx.SetReturn(AudioOutErrorInvalidPointer); + } + + Span descriptors = stackalloc OutputDescriptor[count]; + for (var i = 0; i < count; i++) + { + var entry = parameterBytes.Slice(i * AudioOutOutputParamSize, AudioOutOutputParamSize); + descriptors[i] = new OutputDescriptor( + BinaryPrimitives.ReadInt32LittleEndian(entry), + BinaryPrimitives.ReadUInt64LittleEndian(entry[8..])); + } + + return ctx.SetReturn(SubmitOutputs(ctx, descriptors)); + } + [SysAbiExport( Nid = "QOQtbeDqsT4", ExportName = "sceAudioOutOutput", @@ -225,16 +302,7 @@ public static class AudioOutExports return ctx.SetReturn((int)OrbisGen2Result.ORBIS_GEN2_ERROR_MEMORY_FAULT); } - if (_traceOutput) - { - var n = Interlocked.Increment(ref _outputCount); - if (n <= 8 || n % 200 == 0) - { - var peak = PeakAmplitude(source, port.IsFloat, port.BytesPerSample); - Console.Error.WriteLine( - $"[LOADER][TRACE] audioout.output#{n} handle={handle} bytes={source.Length} ch={port.Channels} float={port.IsFloat} vol={port.Volume:F2} peak={peak:F4} backend={(port.Backend is null ? "none" : "coreaudio")}"); - } - } + TraceOutput(handle, port, source); if (port.Backend is null) { @@ -272,6 +340,184 @@ public static class AudioOutExports } } + private static int SubmitOutputs(CpuContext ctx, ReadOnlySpan descriptors) + { + var resolvedArray = ArrayPool.Shared.Rent(descriptors.Length); + var resolved = resolvedArray.AsSpan(0, descriptors.Length); + resolved.Clear(); + + Span lockOrder = stackalloc int[descriptors.Length]; + var acquiredLocks = 0; + try + { + uint bufferLength = 0; + for (var i = 0; i < descriptors.Length; i++) + { + var descriptor = descriptors[i]; + for (var previous = 0; previous < i; previous++) + { + if (resolved[previous].Handle == descriptor.Handle) + { + return AudioOutErrorInvalidPort; + } + } + + if (!Ports.TryGetValue(descriptor.Handle, out var port)) + { + return _shutdown ? 0 : AudioOutErrorInvalidPort; + } + + if (i == 0) + { + bufferLength = port.BufferLength; + } + else if (port.BufferLength != bufferLength) + { + return AudioOutErrorInvalidSize; + } + + resolved[i].Handle = descriptor.Handle; + resolved[i].SourceAddress = descriptor.SourceAddress; + resolved[i].Port = port; + lockOrder[i] = i; + } + + // Every batch takes port locks in handle order. Two guest threads can + // submit overlapping batches in a different descriptor order without + // deadlocking each other. + for (var i = 1; i < lockOrder.Length; i++) + { + var index = lockOrder[i]; + var position = i; + while (position > 0 && + resolved[lockOrder[position - 1]].Handle > resolved[index].Handle) + { + lockOrder[position] = lockOrder[position - 1]; + position--; + } + + lockOrder[position] = index; + } + + for (; acquiredLocks < lockOrder.Length; acquiredLocks++) + { + Monitor.Enter(resolved[lockOrder[acquiredLocks]].Port.SubmissionGate); + } + + // AudioOutClose removes the handle before waiting for SubmissionGate. + // Recheck after acquiring all gates so a close racing this batch cannot + // turn a validated submission into a write to a disposed backend. + for (var i = 0; i < resolved.Length; i++) + { + if (!Ports.TryGetValue(resolved[i].Handle, out var current) || + !ReferenceEquals(current, resolved[i].Port)) + { + return _shutdown ? 0 : AudioOutErrorInvalidPort; + } + } + + // Stage every guest buffer before the first host submission. A bad + // pointer in a later descriptor therefore cannot partially enqueue the + // earlier ports. + for (var i = 0; i < resolved.Length; i++) + { + ref var output = ref resolved[i]; + if (output.SourceAddress == 0) + { + continue; + } + + var sourceBuffer = ArrayPool.Shared.Rent(output.Port.BufferByteLength); + try + { + var source = sourceBuffer.AsSpan(0, output.Port.BufferByteLength); + if (!ctx.Memory.TryRead(output.SourceAddress, source)) + { + return AudioOutErrorInvalidPointer; + } + + TraceOutput(output.Handle, output.Port, source); + + output.HostBufferLength = checked( + (int)output.Port.BufferLength * AudioPcmConversion.OutputFrameSize); + output.HostBuffer = ArrayPool.Shared.Rent(output.HostBufferLength); + AudioPcmConversion.ConvertToStereoPcm16( + source, + output.HostBuffer.AsSpan(0, output.HostBufferLength), + checked((int)output.Port.BufferLength), + output.Port.Channels, + output.Port.BytesPerSample, + output.Port.IsFloat, + output.Port.Volume); + } + finally + { + ArrayPool.Shared.Return(sourceBuffer); + } + } + + PortState? pacingPort = null; + for (var i = 0; i < resolved.Length; i++) + { + ref var output = ref resolved[i]; + if (output.HostBuffer is null || + output.Port.Backend is null || + !output.Port.Backend.Submit( + output.HostBuffer.AsSpan(0, output.HostBufferLength))) + { + if (pacingPort is null || + HasLongerBufferDuration(output.Port, pacingPort)) + { + pacingPort = output.Port; + } + } + } + + // A batch is one guest scheduling point. When one or more ports have + // no usable backend, pace once using the longest affected buffer rather + // than sleeping once per port. + pacingPort?.PaceSilence(); + return checked((int)resolved[0].Port.BufferLength); + } + finally + { + for (var i = acquiredLocks - 1; i >= 0; i--) + { + Monitor.Exit(resolved[lockOrder[i]].Port.SubmissionGate); + } + + for (var i = 0; i < resolved.Length; i++) + { + if (resolved[i].HostBuffer is { } hostBuffer) + { + ArrayPool.Shared.Return(hostBuffer); + } + } + + ArrayPool.Shared.Return(resolvedArray, clearArray: true); + } + } + + private static bool HasLongerBufferDuration(PortState candidate, PortState current) => + (ulong)candidate.BufferLength * current.Frequency > + (ulong)current.BufferLength * candidate.Frequency; + + private static void TraceOutput(int handle, PortState port, ReadOnlySpan source) + { + if (!_traceOutput) + { + return; + } + + var n = Interlocked.Increment(ref _outputCount); + if (n <= 8 || n % 200 == 0) + { + var peak = PeakAmplitude(source, port.IsFloat, port.BytesPerSample); + Console.Error.WriteLine( + $"[LOADER][TRACE] audioout.output#{n} handle={handle} bytes={source.Length} ch={port.Channels} float={port.IsFloat} vol={port.Volume:F2} peak={peak:F4} backend={(port.Backend is null ? "none" : "coreaudio")}"); + } + } + [SysAbiExport( Nid = "b+uAV89IlxE", ExportName = "sceAudioOutSetVolume", @@ -362,6 +608,25 @@ public static class AudioOutExports } } + internal static void SetStreamFactoryForTests(Func? streamFactory) => + Volatile.Write(ref _streamFactoryForTests, streamFactory); + + internal static void ResetForTests() + { + foreach (var handle in Ports.Keys) + { + if (Ports.TryRemove(handle, out var port)) + { + port.Dispose(); + } + } + + _nextPortHandle = 0; + _outputCount = 0; + Volatile.Write(ref _shutdown, false); + Volatile.Write(ref _streamFactoryForTests, null); + } + private static bool _shutdown; private static bool TryGetFormat( diff --git a/tests/SharpEmu.Libs.Tests/Audio/AudioOutExportsTests.cs b/tests/SharpEmu.Libs.Tests/Audio/AudioOutExportsTests.cs new file mode 100644 index 00000000..95e662f0 --- /dev/null +++ b/tests/SharpEmu.Libs.Tests/Audio/AudioOutExportsTests.cs @@ -0,0 +1,231 @@ +// Copyright (C) 2026 SharpEmu Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +using System.Buffers.Binary; +using SharpEmu.HLE; +using SharpEmu.HLE.Host; +using SharpEmu.Libs.Audio; +using Xunit; + +namespace SharpEmu.Libs.Tests.Audio; + +[CollectionDefinition("AudioOutState", DisableParallelization = true)] +public sealed class AudioOutStateCollection +{ + public const string Name = "AudioOutState"; +} + +[Collection(AudioOutStateCollection.Name)] +public sealed class AudioOutExportsTests : IDisposable +{ + private const ulong MemoryBase = 0x1_0000_0000; + private const int MemorySize = 0x4000; + private const ulong ParameterAddress = MemoryBase + 0x100; + private const ulong FirstSourceAddress = MemoryBase + 0x1000; + private const ulong SecondSourceAddress = MemoryBase + 0x2000; + + private readonly FakeCpuMemory _memory = new(MemoryBase, MemorySize); + private readonly CpuContext _ctx; + private readonly List _streams = []; + + public AudioOutExportsTests() + { + AudioOutExports.ResetForTests(); + AudioOutExports.SetStreamFactoryForTests(_ => + { + var stream = new RecordingAudioStream(); + _streams.Add(stream); + return stream; + }); + _ctx = new CpuContext(_memory, Generation.Gen5); + } + + [Fact] + public void Outputs_StagesAndSubmitsSingleStereoPort() + { + var handle = OpenPort(bufferLength: 2); + Span source = stackalloc byte[8]; + BinaryPrimitives.WriteInt16LittleEndian(source, -32768); + BinaryPrimitives.WriteInt16LittleEndian(source[2..], 32767); + BinaryPrimitives.WriteInt16LittleEndian(source[4..], -1234); + BinaryPrimitives.WriteInt16LittleEndian(source[6..], 5678); + Assert.True(_memory.TryWrite(FirstSourceAddress, source)); + WriteDescriptor(0, handle, FirstSourceAddress); + + var result = Submit(outputCount: 1); + + Assert.Equal(2, result); + Assert.Equal(2UL, _ctx[CpuRegister.Rax]); + Assert.Equal(source.ToArray(), Assert.Single(_streams[0].Submissions)); + } + + [Fact] + public void Outputs_SubmitsEveryPortInTheBatch() + { + var firstHandle = OpenPort(bufferLength: 2); + var secondHandle = OpenPort(bufferLength: 2); + byte[] firstSource = [1, 0, 2, 0, 3, 0, 4, 0]; + byte[] secondSource = [5, 0, 6, 0, 7, 0, 8, 0]; + Assert.True(_memory.TryWrite(FirstSourceAddress, firstSource)); + Assert.True(_memory.TryWrite(SecondSourceAddress, secondSource)); + + // Reverse handle order to exercise canonical lock ordering without + // changing which guest buffer belongs to each port. + WriteDescriptor(0, secondHandle, FirstSourceAddress); + WriteDescriptor(1, firstHandle, SecondSourceAddress); + + var result = Submit(outputCount: 2); + + Assert.Equal(2, result); + Assert.Equal(secondSource, Assert.Single(_streams[0].Submissions)); + Assert.Equal(firstSource, Assert.Single(_streams[1].Submissions)); + } + + [Fact] + public void Outputs_AcceptsNullBufferAsSynchronizationOnly() + { + var handle = OpenPort(bufferLength: 2); + WriteDescriptor(0, handle, sourceAddress: 0); + + var result = Submit(outputCount: 1); + + Assert.Equal(2, result); + Assert.Empty(_streams[0].Submissions); + } + + [Fact] + public void Outputs_FaultInLaterBufferDoesNotPartiallySubmit() + { + var firstHandle = OpenPort(bufferLength: 2); + var secondHandle = OpenPort(bufferLength: 2); + Assert.True(_memory.TryWrite(FirstSourceAddress, new byte[8])); + WriteDescriptor(0, firstHandle, FirstSourceAddress); + WriteDescriptor(1, secondHandle, MemoryBase + MemorySize); + + var result = Submit(outputCount: 2); + + Assert.Equal(AudioOutExports.AudioOutErrorInvalidPointer, result); + Assert.All(_streams, stream => Assert.Empty(stream.Submissions)); + } + + [Fact] + public void Outputs_RejectsDuplicateHandlesBeforeSubmission() + { + var handle = OpenPort(bufferLength: 2); + Assert.True(_memory.TryWrite(FirstSourceAddress, new byte[8])); + Assert.True(_memory.TryWrite(SecondSourceAddress, new byte[8])); + WriteDescriptor(0, handle, FirstSourceAddress); + WriteDescriptor(1, handle, SecondSourceAddress); + + var result = Submit(outputCount: 2); + + Assert.Equal(AudioOutExports.AudioOutErrorInvalidPort, result); + Assert.Empty(_streams[0].Submissions); + } + + [Fact] + public void Outputs_RejectsPortsWithDifferentBufferLengths() + { + var firstHandle = OpenPort(bufferLength: 2); + var secondHandle = OpenPort(bufferLength: 4); + WriteDescriptor(0, firstHandle, FirstSourceAddress); + WriteDescriptor(1, secondHandle, SecondSourceAddress); + + var result = Submit(outputCount: 2); + + Assert.Equal(AudioOutExports.AudioOutErrorInvalidSize, result); + Assert.All(_streams, stream => Assert.Empty(stream.Submissions)); + } + + [Fact] + public void Outputs_RejectsUnknownHandle() + { + WriteDescriptor(0, 99, FirstSourceAddress); + + Assert.Equal( + AudioOutExports.AudioOutErrorInvalidPort, + Submit(outputCount: 1)); + } + + [Theory] + [InlineData(0u)] + [InlineData(26u)] + public void Outputs_RejectsInvalidOutputCount(uint outputCount) + { + Assert.Equal( + AudioOutExports.AudioOutErrorPortFull, + Submit(outputCount)); + } + + [Fact] + public void Outputs_RejectsNullOrUnreadableParameterArray() + { + Assert.Equal( + AudioOutExports.AudioOutErrorInvalidPointer, + Submit(outputCount: 1, parameterAddress: 0)); + Assert.Equal( + AudioOutExports.AudioOutErrorInvalidPointer, + Submit(outputCount: 1, parameterAddress: MemoryBase + MemorySize)); + } + + [Fact] + public void OutputsExportRegistersForBothGenerations() + { + foreach (var generation in new[] { Generation.Gen4, Generation.Gen5 }) + { + var manager = new ModuleManager(); + manager.RegisterExports( + SharpEmu.Generated.SysAbiExportRegistry.CreateExports(generation)); + + Assert.True(manager.TryGetExport("w3PdaSTSwGE", out var export)); + Assert.Equal("sceAudioOutOutputs", export.Name); + Assert.Equal("libSceAudioOut", export.LibraryName); + } + } + + public void Dispose() => AudioOutExports.ResetForTests(); + + private int OpenPort(uint bufferLength) + { + _ctx[CpuRegister.Rdi] = 1; + _ctx[CpuRegister.Rsi] = 0; + _ctx[CpuRegister.Rdx] = 0; + _ctx[CpuRegister.Rcx] = bufferLength; + _ctx[CpuRegister.R8] = 48000; + _ctx[CpuRegister.R9] = 1; + return AudioOutExports.AudioOutOpen(_ctx); + } + + private int Submit(uint outputCount, ulong parameterAddress = ParameterAddress) + { + _ctx[CpuRegister.Rdi] = parameterAddress; + _ctx[CpuRegister.Rsi] = outputCount; + return AudioOutExports.AudioOutOutputs(_ctx); + } + + private void WriteDescriptor(int index, int handle, ulong sourceAddress) + { + Span descriptor = stackalloc byte[16]; + descriptor.Clear(); + BinaryPrimitives.WriteInt32LittleEndian(descriptor, handle); + BinaryPrimitives.WriteUInt64LittleEndian(descriptor[8..], sourceAddress); + Assert.True(_memory.TryWrite( + ParameterAddress + unchecked((ulong)(index * descriptor.Length)), + descriptor)); + } + + private sealed class RecordingAudioStream : IHostAudioStream + { + public List Submissions { get; } = []; + + public bool Submit(ReadOnlySpan stereoPcm16) + { + Submissions.Add(stereoPcm16.ToArray()); + return true; + } + + public void Dispose() + { + } + } +}