Sdl backend (#670)

* [audio] added sdl audio backend and in-tree atrac9 decoder

* [input] replaced per-platform pad readers with sdl gamepad input

* [video] added sdl window and host display plumbing

* [gui] added host display options and per-game render settings

* [bink] synced host movie playback to the guest audio clock

* [cpu] hooked windows write faults into guest image tracking

* [perf] added guest and render profiling, reserved host cpu lanes

* [kernel] fixed stale pthread mutex handle alias

* [host] wired the sdl session, save-data paths and project references

* [audio] hoisted ajm trace stackalloc out of its loop

* [video] Add guest image sync setting

* [build] Strip native symbols

* reuse
This commit is contained in:
Berk
2026-07-28 03:33:26 +03:00
committed by GitHub
parent b4cc5f88ca
commit 2b6bd5a532
111 changed files with 9846 additions and 4479 deletions
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,409 @@
// Copyright (C) 2026 SharpEmu Emulator Project
// SPDX-License-Identifier: GPL-2.0-or-later
using System.Buffers.Binary;
using LibAtrac9;
namespace SharpEmu.Libs.Audio;
internal enum Atrac9PcmEncoding
{
Signed16,
Signed32,
Float,
}
internal readonly record struct Atrac9DecodeResult(
int Status,
int InputConsumed,
int OutputWritten,
ulong TotalDecodedSamples,
uint Frames);
internal sealed class Atrac9DecodeState
{
internal const int ResultNotInitialized = 0x00000001;
internal const int ResultInvalidData = 0x00000002;
internal const int ResultInvalidParameter = 0x00000004;
internal const int ResultPartialInput = 0x00000008;
internal const int ResultNotEnoughRoom = 0x00000010;
internal const int ResultCodecError = 0x40000000;
private const int MaxContainerHeaderBytes = 8 * 1024;
private enum ContainerScan
{
NotContainer,
NeedMoreData,
Found,
}
private readonly object _gate = new();
private Atrac9Decoder? _decoder;
private byte[]? _configData;
private byte[]? _compressed;
private short[][]? _planarPcm;
private byte[]? _containerHeader;
private int _containerHeaderLength;
private int _compressedLength;
private ulong _totalDecodedSamples;
public Atrac9Config? Config
{
get
{
lock (_gate)
{
return _decoder?.Config;
}
}
}
public bool TryInitialize(ReadOnlySpan<byte> configData)
{
if (configData.Length < 4)
{
return false;
}
lock (_gate)
{
try
{
var normalizedConfig = configData[..4].ToArray();
var decoder = new Atrac9Decoder();
decoder.Initialize(normalizedConfig);
var config = decoder.Config;
_decoder = decoder;
_configData = normalizedConfig;
_compressed = new byte[config.SuperframeBytes];
_planarPcm = CreatePcmBuffer(config.ChannelCount, config.SuperframeSamples);
_compressedLength = 0;
_totalDecodedSamples = 0;
_containerHeaderLength = 0;
Trace(
$"initialized config={Convert.ToHexString(normalizedConfig)} channels={config.ChannelCount} " +
$"rate={config.SampleRate} frame_samples={config.FrameSamples} " +
$"superframe_samples={config.SuperframeSamples} superframe_bytes={config.SuperframeBytes} " +
$"frames_per_superframe={config.FramesPerSuperframe}");
return true;
}
catch (Exception exception) when (
exception is ArgumentException or InvalidDataException or InvalidOperationException)
{
Clear();
return false;
}
}
}
public void Reset()
{
lock (_gate)
{
if (_configData is null)
{
Clear();
return;
}
var decoder = new Atrac9Decoder();
decoder.Initialize((byte[])_configData.Clone());
_decoder = decoder;
_compressedLength = 0;
_totalDecodedSamples = 0;
_containerHeaderLength = 0;
if (_compressed is not null)
{
Array.Clear(_compressed);
}
}
}
public Atrac9DecodeResult Decode(
ReadOnlySpan<byte> input,
Span<byte> output,
Atrac9PcmEncoding encoding,
int requestedChannels,
bool multipleFrames)
{
lock (_gate)
{
if (_decoder is null || _compressed is null || _planarPcm is null)
{
return new Atrac9DecodeResult(
ResultNotInitialized,
0,
0,
_totalDecodedSamples,
0);
}
var config = _decoder.Config;
var channels = requestedChannels > 0 ? requestedChannels : config.ChannelCount;
if (channels is < 1 or > 16)
{
return new Atrac9DecodeResult(
ResultInvalidParameter,
0,
0,
_totalDecodedSamples,
0);
}
var bytesPerSample = GetBytesPerSample(encoding);
var outputBytesPerSuperframe = checked(config.SuperframeSamples * channels * bytesPerSample);
var consumed = 0;
var written = 0;
uint frames = 0;
var status = 0;
while (_compressedLength == config.SuperframeBytes ||
consumed < input.Length)
{
// Titles that stream whole .at9 files hand AJM the RIFF/WAVE
// container rather than a pointer into its `data` chunk, so the
// stream has to be advanced past the header before the first
// superframe — otherwise every job fails with invalid data and
// the title drops the voice. This is checked at every superframe
// boundary, not just after initialize, because a looping voice
// rewinds to the file header without reinitialising.
if (_compressedLength == 0 && (_containerHeaderLength != 0 || consumed < input.Length))
{
var scan = ScanContainerHeader(input[consumed..], out var headerBytes);
if (scan == ContainerScan.NeedMoreData)
{
consumed = input.Length;
status |= ResultPartialInput;
break;
}
if (scan == ContainerScan.Found)
{
_containerHeader = null;
_containerHeaderLength = 0;
consumed += headerBytes;
continue;
}
}
if (_compressedLength < config.SuperframeBytes)
{
var copied = Math.Min(config.SuperframeBytes - _compressedLength, input.Length - consumed);
input.Slice(consumed, copied).CopyTo(_compressed.AsSpan(_compressedLength));
_compressedLength += copied;
consumed += copied;
}
if (_compressedLength < config.SuperframeBytes)
{
status |= ResultPartialInput;
break;
}
if (output.Length - written < outputBytesPerSuperframe)
{
status |= ResultNotEnoughRoom;
break;
}
try
{
_decoder.Decode(_compressed, _planarPcm);
}
catch (Exception exception) when (
exception is ArgumentException or InvalidDataException or InvalidOperationException or IndexOutOfRangeException)
{
Trace(
$"decode_failed superframe_bytes={config.SuperframeBytes} " +
$"config={Convert.ToHexString(_configData ?? [])} " +
$"head={Convert.ToHexString(_compressed.AsSpan(0, Math.Min(16, _compressed.Length)))} " +
$"error={exception.GetType().Name}: {exception.Message}");
_compressedLength = 0;
return new Atrac9DecodeResult(
status | ResultInvalidData | ResultCodecError,
consumed,
written,
_totalDecodedSamples,
frames);
}
WriteInterleaved(
_planarPcm,
output.Slice(written, outputBytesPerSuperframe),
config.SuperframeSamples,
channels,
encoding);
written += outputBytesPerSuperframe;
_compressedLength = 0;
_totalDecodedSamples += unchecked((uint)config.SuperframeSamples);
frames += unchecked((uint)config.FramesPerSuperframe);
if (!multipleFrames)
{
break;
}
}
return new Atrac9DecodeResult(
status,
consumed,
written,
_totalDecodedSamples,
frames);
}
}
private ContainerScan ScanContainerHeader(ReadOnlySpan<byte> input, out int inputHeaderBytes)
{
inputHeaderBytes = 0;
if (_containerHeaderLength == 0 &&
(input.Length < 4 || !input[..4].SequenceEqual("RIFF"u8)))
{
return ContainerScan.NotContainer;
}
_containerHeader ??= new byte[MaxContainerHeaderBytes];
var previousLength = _containerHeaderLength;
var copied = Math.Min(input.Length, MaxContainerHeaderBytes - previousLength);
input[..copied].CopyTo(_containerHeader.AsSpan(previousLength));
var totalLength = previousLength + copied;
if (!TryFindRiffDataOffset(_containerHeader.AsSpan(0, totalLength), out var dataOffset))
{
if (totalLength >= MaxContainerHeaderBytes)
{
// Not a shape we understand — fall back to treating the stream
// as raw superframes rather than swallowing it forever. The
// scratch is dropped without advancing the caller's cursor, so
// the bytes are still decoded normally.
Trace($"container_scan_gave_up bytes={totalLength}");
_containerHeader = null;
_containerHeaderLength = 0;
return ContainerScan.NotContainer;
}
_containerHeaderLength = totalLength;
return ContainerScan.NeedMoreData;
}
inputHeaderBytes = dataOffset - previousLength;
Trace($"container_header_skipped bytes={dataOffset} from_this_input={inputHeaderBytes}");
return ContainerScan.Found;
}
private static bool TryFindRiffDataOffset(ReadOnlySpan<byte> header, out int dataOffset)
{
dataOffset = 0;
if (header.Length < 12 ||
!header[..4].SequenceEqual("RIFF"u8) ||
!header.Slice(8, 4).SequenceEqual("WAVE"u8))
{
return false;
}
var offset = 12;
while (offset + 8 <= header.Length)
{
var chunkId = header.Slice(offset, 4);
var chunkSize = BinaryPrimitives.ReadUInt32LittleEndian(header.Slice(offset + 4, 4));
offset += 8;
if (chunkId.SequenceEqual("data"u8))
{
dataOffset = offset;
return true;
}
// RIFF chunks are word aligned.
var advance = chunkSize + (chunkSize & 1);
if (advance > (ulong)(int.MaxValue - offset))
{
return false;
}
offset += (int)advance;
}
return false;
}
private static short[][] CreatePcmBuffer(int channels, int samples)
{
var result = new short[channels][];
for (var channel = 0; channel < channels; channel++)
{
result[channel] = new short[samples];
}
return result;
}
private static int GetBytesPerSample(Atrac9PcmEncoding encoding) =>
encoding switch
{
Atrac9PcmEncoding.Signed16 => sizeof(short),
Atrac9PcmEncoding.Signed32 => sizeof(int),
Atrac9PcmEncoding.Float => sizeof(float),
_ => throw new ArgumentOutOfRangeException(nameof(encoding)),
};
private static void WriteInterleaved(
short[][] source,
Span<byte> destination,
int samples,
int channels,
Atrac9PcmEncoding encoding)
{
var offset = 0;
for (var sample = 0; sample < samples; sample++)
{
for (var channel = 0; channel < channels; channel++)
{
var sourceChannel = Math.Min(channel, source.Length - 1);
var value = source[sourceChannel][sample];
switch (encoding)
{
case Atrac9PcmEncoding.Signed16:
BinaryPrimitives.WriteInt16LittleEndian(destination[offset..], value);
offset += sizeof(short);
break;
case Atrac9PcmEncoding.Signed32:
BinaryPrimitives.WriteInt32LittleEndian(destination[offset..], value << 16);
offset += sizeof(int);
break;
case Atrac9PcmEncoding.Float:
BinaryPrimitives.WriteInt32LittleEndian(
destination[offset..],
BitConverter.SingleToInt32Bits(value / 32768.0f));
offset += sizeof(float);
break;
default:
throw new ArgumentOutOfRangeException(nameof(encoding));
}
}
}
}
private static void Trace(string message)
{
if (string.Equals(Environment.GetEnvironmentVariable("SHARPEMU_LOG_AJM"), "1", StringComparison.Ordinal))
{
Console.Error.WriteLine($"[LOADER][TRACE] ajm.at9.{message}");
}
}
private void Clear()
{
_decoder = null;
_configData = null;
_compressed = null;
_planarPcm = null;
_containerHeader = null;
_containerHeaderLength = 0;
_compressedLength = 0;
_totalDecodedSamples = 0;
}
}
+43 -20
View File
@@ -44,6 +44,7 @@ public static class AudioOutExports
int channels,
int bytesPerSample,
bool isFloat,
bool preservesGuestFormat,
IHostAudioStream? backend)
{
UserId = userId;
@@ -54,6 +55,7 @@ public static class AudioOutExports
Channels = channels;
BytesPerSample = bytesPerSample;
IsFloat = isFloat;
PreservesGuestFormat = preservesGuestFormat;
Backend = backend;
}
@@ -65,6 +67,7 @@ public static class AudioOutExports
public int Channels { get; }
public int BytesPerSample { get; }
public bool IsFloat { get; }
public bool PreservesGuestFormat { get; }
public IHostAudioStream? Backend { get; }
public object SubmissionGate { get; } = new();
public volatile float Volume = 1.0f;
@@ -140,6 +143,7 @@ public static class AudioOutExports
}
IHostAudioStream? backend = null;
var preservesGuestFormat = false;
string backendName;
try
{
@@ -152,7 +156,18 @@ public static class AudioOutExports
else
{
var audio = HostPlatform.Current.Audio;
backend = audio.OpenStereoPcm16Stream(frequency);
if (audio is IHostPcmAudioOutput pcmAudio)
{
backend = pcmAudio.OpenPcmStream(
frequency,
channels,
isFloat ? HostPcmFormat.Float32 : HostPcmFormat.Signed16);
preservesGuestFormat = true;
}
else
{
backend = audio.OpenStereoPcm16Stream(frequency);
}
backendName = audio.BackendName;
}
}
@@ -173,6 +188,7 @@ public static class AudioOutExports
channels,
bytesPerSample,
isFloat,
preservesGuestFormat,
backend);
Console.Error.WriteLine(
$"[LOADER][INFO] AudioOut port {handle}: {frequency} Hz, " +
@@ -321,18 +337,13 @@ public static class AudioOutExports
return ctx.SetReturn(0);
}
var outputLength = checked((int)port.BufferLength * AudioPcmConversion.OutputFrameSize);
var outputLength = port.PreservesGuestFormat
? port.BufferByteLength
: checked((int)port.BufferLength * AudioPcmConversion.OutputFrameSize);
var output = ArrayPool<byte>.Shared.Rent(outputLength);
try
{
AudioPcmConversion.ConvertToStereoPcm16(
source,
output.AsSpan(0, outputLength),
checked((int)port.BufferLength),
port.Channels,
port.BytesPerSample,
port.IsFloat,
port.Volume);
ConvertForHost(port, source, output.AsSpan(0, outputLength));
if (!port.Backend.Submit(output.AsSpan(0, outputLength)))
{
port.PaceSilence();
@@ -449,17 +460,11 @@ public static class AudioOutExports
TraceOutput(output.Handle, output.Port, source);
output.HostBufferLength = checked(
(int)output.Port.BufferLength * AudioPcmConversion.OutputFrameSize);
output.HostBufferLength = output.Port.PreservesGuestFormat
? output.Port.BufferByteLength
: 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);
ConvertForHost(output.Port, source, output.HostBuffer.AsSpan(0, output.HostBufferLength));
}
finally
{
@@ -513,6 +518,24 @@ public static class AudioOutExports
(ulong)candidate.BufferLength * current.Frequency >
(ulong)current.BufferLength * candidate.Frequency;
private static void ConvertForHost(PortState port, ReadOnlySpan<byte> source, Span<byte> destination)
{
if (port.PreservesGuestFormat)
{
AudioPcmConversion.CopyWithVolume(source, destination, port.IsFloat, port.Volume);
return;
}
AudioPcmConversion.ConvertToStereoPcm16(
source,
destination,
checked((int)port.BufferLength),
port.Channels,
port.BytesPerSample,
port.IsFloat,
port.Volume);
}
private static void TraceOutput(int handle, PortState port, ReadOnlySpan<byte> source)
{
if (!_traceOutput)
@@ -43,6 +43,46 @@ internal static class AudioPcmConversion
}
}
/// <summary>
/// Copies interleaved PCM without changing its channel layout. SDL can convert
/// this directly to the physical device, which preserves surround mixes that
/// would otherwise be truncated to the first two guest channels.
/// </summary>
public static void CopyWithVolume(
ReadOnlySpan<byte> source,
Span<byte> destination,
bool isFloat,
float volume)
{
var clampedVolume = Math.Clamp(volume, 0.0f, 1.0f);
if (clampedVolume >= 1.0f)
{
source.CopyTo(destination);
return;
}
if (isFloat)
{
for (var offset = 0; offset < source.Length; offset += sizeof(float))
{
var sample = BinaryPrimitives.ReadSingleLittleEndian(source.Slice(offset, sizeof(float)));
BinaryPrimitives.WriteSingleLittleEndian(
destination.Slice(offset, sizeof(float)),
sample * clampedVolume);
}
return;
}
for (var offset = 0; offset < source.Length; offset += sizeof(short))
{
var sample = BinaryPrimitives.ReadInt16LittleEndian(source.Slice(offset, sizeof(short)));
BinaryPrimitives.WriteInt16LittleEndian(
destination.Slice(offset, sizeof(short)),
ApplyVolume(sample, clampedVolume));
}
}
private static short ReadSample(
ReadOnlySpan<byte> frame,
int channel,