feat(audio): implement sceAudioOutOutputs (#605)

* feat(audio): implement batched output submission

* test(audio): cover batched output semantics

* test(audio): cover multi-port output batches

---------

Co-authored-by: diego <diego@DIGOTE-PC>
This commit is contained in:
Digote
2026-07-24 14:13:30 -03:00
committed by GitHub
parent a158960c20
commit 26c502914c
2 changed files with 510 additions and 14 deletions
+279 -14
View File
@@ -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<int, PortState> Ports = new();
private static int _nextPortHandle;
private static Func<uint, IHostAudioStream?>? _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<byte> parameterBytes =
stackalloc byte[AudioOutMaximumOutputCount * AudioOutOutputParamSize];
parameterBytes = parameterBytes[..checked(count * AudioOutOutputParamSize)];
if (!ctx.Memory.TryRead(parameterAddress, parameterBytes))
{
return ctx.SetReturn(AudioOutErrorInvalidPointer);
}
Span<OutputDescriptor> 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<OutputDescriptor> descriptors)
{
var resolvedArray = ArrayPool<ResolvedOutput>.Shared.Rent(descriptors.Length);
var resolved = resolvedArray.AsSpan(0, descriptors.Length);
resolved.Clear();
Span<int> 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<byte>.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<byte>.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<byte>.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<byte>.Shared.Return(hostBuffer);
}
}
ArrayPool<ResolvedOutput>.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<byte> 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<uint, IHostAudioStream?>? 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(
@@ -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<RecordingAudioStream> _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<byte> 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<byte> 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<byte[]> Submissions { get; } = [];
public bool Submit(ReadOnlySpan<byte> stereoPcm16)
{
Submissions.Add(stereoPcm16.ToArray());
return true;
}
public void Dispose()
{
}
}
}