diff --git a/src/SharpEmu.HLE/Host/GuestAudioClock.cs b/src/SharpEmu.HLE/Host/GuestAudioClock.cs new file mode 100644 index 00000000..953e730c --- /dev/null +++ b/src/SharpEmu.HLE/Host/GuestAudioClock.cs @@ -0,0 +1,69 @@ +// Copyright (C) 2026 SharpEmu Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +using System.Diagnostics; + +namespace SharpEmu.HLE.Host; + +/// +/// How much guest audio the host device has actually played, in seconds. +/// +/// This is the only clock in the emulator that advances at the rate the player +/// hears. Wall clock runs ahead of it whenever the guest cannot feed the device +/// (the stream underruns and the missing time is never played), so anything +/// that has to stay in step with the guest's audio — host-decoded video being +/// the case that matters — has to follow this rather than . +/// +/// Reported per stream and kept as the furthest-along value: the guest's ports +/// all carry one mix, and the leading port is the one whose position the +/// listener perceives. +/// +public static class GuestAudioClock +{ + private static long _playedMicroseconds; + private static long _lastAdvanceTimestamp; + + /// Seconds of guest audio the device has played. Monotonic. + public static double PlayedSeconds => + Interlocked.Read(ref _playedMicroseconds) / 1_000_000.0; + + /// + /// True while a stream has reported progress recently. False means no guest + /// audio is playing, and callers must fall back to wall clock rather than + /// stalling on a clock that will never advance. + /// + public static bool IsRunning + { + get + { + var last = Interlocked.Read(ref _lastAdvanceTimestamp); + return last != 0 && + Stopwatch.GetElapsedTime(last) < TimeSpan.FromMilliseconds(250); + } + } + + public static void Report(double playedSeconds) + { + if (double.IsNaN(playedSeconds) || playedSeconds < 0) + { + return; + } + + var microseconds = (long)(playedSeconds * 1_000_000.0); + var current = Interlocked.Read(ref _playedMicroseconds); + while (microseconds > current) + { + var seen = Interlocked.CompareExchange( + ref _playedMicroseconds, + microseconds, + current); + if (seen == current) + { + Interlocked.Exchange(ref _lastAdvanceTimestamp, Stopwatch.GetTimestamp()); + return; + } + + current = seen; + } + } +} diff --git a/src/SharpEmu.HLE/Host/IHostAudioStream.cs b/src/SharpEmu.HLE/Host/IHostAudioStream.cs index 2db99abb..43909c0d 100644 --- a/src/SharpEmu.HLE/Host/IHostAudioStream.cs +++ b/src/SharpEmu.HLE/Host/IHostAudioStream.cs @@ -15,4 +15,17 @@ public interface IHostAudioStream : IDisposable /// audio, in which case the caller paces the guest itself. /// bool Submit(ReadOnlySpan stereoPcm16); + + /// + /// Audio already handed to the device and not yet played, in milliseconds — + /// the cushion protecting playback from a late submission. Zero means the + /// device has run dry and is emitting silence. + /// + /// Callers that pace the guest against an emulated hardware queue need this: + /// pacing purely on wall clock releases exactly one buffer per buffer-period + /// and so keeps the cushion at zero, which turns any scheduling jitter into + /// an audible dropout. Returns -1 when the backend cannot report a depth, in + /// which case callers must fall back to their own pacing. + /// + int QueuedMilliseconds => -1; } diff --git a/src/SharpEmu.HLE/Host/IHostPcmAudioOutput.cs b/src/SharpEmu.HLE/Host/IHostPcmAudioOutput.cs new file mode 100644 index 00000000..1b8c671f --- /dev/null +++ b/src/SharpEmu.HLE/Host/IHostPcmAudioOutput.cs @@ -0,0 +1,19 @@ +// Copyright (C) 2026 SharpEmu Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +namespace SharpEmu.HLE.Host; + +/// +/// Optional host-audio extension for backends that can accept the guest's +/// interleaved PCM layout directly and perform device conversion themselves. +/// +public interface IHostPcmAudioOutput : IHostAudioOutput +{ + IHostAudioStream OpenPcmStream(uint sampleRate, int channels, HostPcmFormat format); +} + +public enum HostPcmFormat +{ + Signed16, + Float32, +} diff --git a/src/SharpEmu.HLE/Host/Sdl/SdlHostAudio.cs b/src/SharpEmu.HLE/Host/Sdl/SdlHostAudio.cs new file mode 100644 index 00000000..9e8f3a15 --- /dev/null +++ b/src/SharpEmu.HLE/Host/Sdl/SdlHostAudio.cs @@ -0,0 +1,325 @@ +// Copyright (C) 2026 SharpEmu Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +using System.Diagnostics; +using System.Runtime.InteropServices; +using SDL; +using static SDL.SDL3; + +namespace SharpEmu.HLE.Host.Sdl; + +internal sealed unsafe class SdlHostAudio : IHostPcmAudioOutput +{ + /// + /// Cap for streams this class paces itself (AudioOut). Blocking the guest + /// here is that path's only pacing, so the device settles at this depth — + /// it is the playback latency, and the floor under it is how much jitter the + /// stream can absorb before it runs dry. + /// + private static readonly int TargetQueuedMilliseconds = + int.TryParse( + Environment.GetEnvironmentVariable("SHARPEMU_AUDIO_LATENCY_MS"), + out var latencyMs) && latencyMs > 0 + ? latencyMs + : 60; + + private const int MaximumWaitMilliseconds = 250; + private static readonly object InitGate = new(); + private static bool _initialized; + + public string BackendName => "sdl3"; + + /// + /// Stereo PCM16 stream with a caller-chosen backpressure cap. Callers that + /// pace the guest themselves pass a deeper cap so this class's backpressure + /// does not fight their pacing. + /// + public IHostAudioStream OpenStereoPcm16Stream(uint sampleRate, int maxQueuedPcmBytes = 32 * 1024) + => OpenStream( + sampleRate, + channels: 2, + HostPcmFormat.Signed16, + maxQueuedPcmBytes > 0 ? maxQueuedPcmBytes : 32 * 1024); + + /// + /// Guest-format stream for AudioOut, which has no queue model of its own: + /// blocking here is that path's only pacing, so the device settles at + /// TargetQueuedMilliseconds and that depth is the playback latency. + /// + public IHostAudioStream OpenPcmStream(uint sampleRate, int channels, HostPcmFormat format) + { + var bytesPerSample = format == HostPcmFormat.Float32 ? sizeof(float) : sizeof(short); + var cap = checked((int)((long)sampleRate * channels * bytesPerSample * + TargetQueuedMilliseconds / 1_000)); + return OpenStream(sampleRate, channels, format, cap); + } + + private static IHostAudioStream OpenStream( + uint sampleRate, + int channels, + HostPcmFormat format, + int maximumQueuedBytes) + { + if (sampleRate is < 8_000 or > 384_000 || channels is < 1 or > 8) + { + throw new ArgumentOutOfRangeException( + sampleRate is < 8_000 or > 384_000 ? nameof(sampleRate) : nameof(channels)); + } + + EnsureInitialized(); + return new AudioStream(sampleRate, channels, format, maximumQueuedBytes); + } + + private static void EnsureInitialized() + { + lock (InitGate) + { + if (_initialized) + { + return; + } + + if ((SDL_WasInit(SDL_InitFlags.SDL_INIT_AUDIO) & SDL_InitFlags.SDL_INIT_AUDIO) == 0 && + !SDL_InitSubSystem(SDL_InitFlags.SDL_INIT_AUDIO)) + { + throw new InvalidOperationException($"SDL audio initialization failed: {GetError()}"); + } + + _initialized = true; + } + } + + private static string GetError() + { + var error = Unsafe_SDL_GetError(); + return error is null ? "unknown SDL error" : Marshal.PtrToStringUTF8((nint)error) ?? "unknown SDL error"; + } + + private static readonly bool _traceQueue = string.Equals( + Environment.GetEnvironmentVariable("SHARPEMU_LOG_AUDIO_QUEUE"), + "1", + StringComparison.Ordinal); + + private static int _nextStreamId; + + private sealed class AudioStream : IHostAudioStream + { + private readonly object _gate = new(); + private readonly int _maximumQueuedBytes; + private readonly int _bytesPerFrame; + private readonly uint _sampleRate; + private readonly int _streamId = Interlocked.Increment(ref _nextStreamId); + private SDL_AudioStream* _stream; + private bool _disposed; + private long _totalSubmittedBytes; + + // Queue diagnostics for the current report window. + private long _windowStart = Stopwatch.GetTimestamp(); + private long _submissions; + private long _submittedBytes; + private long _blockedTicks; + private long _drops; + private long _emptyObservations; + private int _minQueuedBytes = int.MaxValue; + private int _maxQueuedBytes; + private long _queuedByteSum; + + public AudioStream( + uint sampleRate, + int channels, + HostPcmFormat format, + int maximumQueuedBytes) + { + var bytesPerSample = format == HostPcmFormat.Float32 ? sizeof(float) : sizeof(short); + _bytesPerFrame = channels * bytesPerSample; + _sampleRate = sampleRate; + var spec = new SDL_AudioSpec + { + format = format == HostPcmFormat.Float32 + ? SDL_AudioFormat.SDL_AUDIO_F32LE + : SDL_AudioFormat.SDL_AUDIO_S16LE, + channels = checked((byte)channels), + freq = checked((int)sampleRate), + }; + + _stream = SDL_OpenAudioDeviceStream( + SDL_AUDIO_DEVICE_DEFAULT_PLAYBACK, + &spec, + null, + IntPtr.Zero); + if (_stream is null) + { + throw new InvalidOperationException($"SDL audio stream creation failed: {GetError()}"); + } + + if (!SDL_ResumeAudioStreamDevice(_stream)) + { + SDL_DestroyAudioStream(_stream); + _stream = null; + throw new InvalidOperationException($"SDL audio stream start failed: {GetError()}"); + } + + _maximumQueuedBytes = maximumQueuedBytes; + } + + public int QueuedMilliseconds + { + get + { + lock (_gate) + { + if (_disposed || _stream is null) + { + return -1; + } + + var bytesPerSecond = (double)_bytesPerFrame * _sampleRate; + return bytesPerSecond <= 0 + ? -1 + : (int)(SDL_GetAudioStreamQueued(_stream) / bytesPerSecond * 1000.0); + } + } + } + + public bool Submit(ReadOnlySpan pcm) + { + if (pcm.IsEmpty) + { + return true; + } + + lock (_gate) + { + if (_disposed || _stream is null) + { + return false; + } + + var blockStart = Stopwatch.GetTimestamp(); + var deadline = blockStart + + (Stopwatch.Frequency * MaximumWaitMilliseconds / 1_000); + int queued; + var overrun = false; + while ((queued = SDL_GetAudioStreamQueued(_stream)) > _maximumQueuedBytes) + { + if (Stopwatch.GetTimestamp() >= deadline) + { + // Enqueue anyway rather than discarding the buffer. A gap in + // the stream is an audible click; the extra latency of one + // over-deep submission is not, and the queue recovers as soon + // as the device drains back under the cap. + overrun = true; + break; + } + + Thread.Sleep(1); + } + + RecordSubmission(queued, blockStart, dropped: overrun, bytes: pcm.Length); + bool submitted; + fixed (byte* data = pcm) + { + submitted = SDL_PutAudioStreamData(_stream, (nint)data, pcm.Length); + } + + if (submitted) + { + // Everything handed over minus what the device still holds is + // what the player has actually heard. + _totalSubmittedBytes += pcm.Length; + var bytesPerSecond = (double)_bytesPerFrame * _sampleRate; + if (bytesPerSecond > 0) + { + GuestAudioClock.Report( + Math.Max(0, _totalSubmittedBytes - queued - pcm.Length) / bytesPerSecond); + } + } + + return submitted; + } + } + + /// + /// Samples the queue depth at the moment the guest was allowed to write. + /// That depth is the playback latency the guest's audio is subject to, so + /// it is the number to look at when the sound is late; an observed depth + /// of zero is a genuine underrun, which is what a crackle sounds like. + /// Caller holds . + /// + private void RecordSubmission(int queuedBytes, long blockStart, bool dropped, int bytes) + { + if (!_traceQueue) + { + return; + } + + var now = Stopwatch.GetTimestamp(); + _submissions++; + _submittedBytes += bytes; + _blockedTicks += now - blockStart; + _queuedByteSum += queuedBytes; + _minQueuedBytes = Math.Min(_minQueuedBytes, queuedBytes); + _maxQueuedBytes = Math.Max(_maxQueuedBytes, queuedBytes); + if (dropped) + { + _drops++; + } + + if (queuedBytes == 0) + { + _emptyObservations++; + } + + var elapsedTicks = now - _windowStart; + if (elapsedTicks < Stopwatch.Frequency) + { + return; + } + + _windowStart = now; + var seconds = elapsedTicks / (double)Stopwatch.Frequency; + var bytesPerSecond = (double)_bytesPerFrame * _sampleRate; + Console.Error.WriteLine( + $"[PERF][AUDIO] stream#{_streamId} {seconds:F1}s " + + $"queued_ms min={ToMilliseconds(_minQueuedBytes, bytesPerSecond):F0} " + + $"avg={ToMilliseconds((int)(_queuedByteSum / Math.Max(1, _submissions)), bytesPerSecond):F0} " + + $"max={ToMilliseconds(_maxQueuedBytes, bytesPerSecond):F0} " + + $"cap={ToMilliseconds(_maximumQueuedBytes, bytesPerSecond):F0} " + + $"submits/s={_submissions / seconds:F0} " + + $"fill={_submittedBytes / seconds / bytesPerSecond * 100.0:F0}% " + + $"blocked={_blockedTicks * 100.0 / elapsedTicks:F0}% " + + $"empty={_emptyObservations} drops={_drops}"); + + _submissions = 0; + _submittedBytes = 0; + _blockedTicks = 0; + _drops = 0; + _emptyObservations = 0; + _minQueuedBytes = int.MaxValue; + _maxQueuedBytes = 0; + _queuedByteSum = 0; + } + + private static double ToMilliseconds(int bytes, double bytesPerSecond) => + bytesPerSecond <= 0 ? 0 : bytes / bytesPerSecond * 1000.0; + + public void Dispose() + { + lock (_gate) + { + if (_disposed) + { + return; + } + + _disposed = true; + if (_stream is not null) + { + SDL_ClearAudioStream(_stream); + SDL_DestroyAudioStream(_stream); + _stream = null; + } + } + } + } +} diff --git a/src/SharpEmu.GUI/Atrac9/Atrac9Config.cs b/src/SharpEmu.LibAtrac9/Atrac9Config.cs similarity index 99% rename from src/SharpEmu.GUI/Atrac9/Atrac9Config.cs rename to src/SharpEmu.LibAtrac9/Atrac9Config.cs index 8498fd8b..e9177216 100644 --- a/src/SharpEmu.GUI/Atrac9/Atrac9Config.cs +++ b/src/SharpEmu.LibAtrac9/Atrac9Config.cs @@ -1,3 +1,4 @@ +// SPDX-License-Identifier: MIT #nullable disable using System.IO; using LibAtrac9.Utilities; diff --git a/src/SharpEmu.GUI/Atrac9/Atrac9Decoder.cs b/src/SharpEmu.LibAtrac9/Atrac9Decoder.cs similarity index 99% rename from src/SharpEmu.GUI/Atrac9/Atrac9Decoder.cs rename to src/SharpEmu.LibAtrac9/Atrac9Decoder.cs index e99d451a..fa76ff75 100644 --- a/src/SharpEmu.GUI/Atrac9/Atrac9Decoder.cs +++ b/src/SharpEmu.LibAtrac9/Atrac9Decoder.cs @@ -1,3 +1,4 @@ +// SPDX-License-Identifier: MIT #nullable disable using System; using LibAtrac9.Utilities; diff --git a/src/SharpEmu.GUI/Atrac9/Atrac9Rng.cs b/src/SharpEmu.LibAtrac9/Atrac9Rng.cs similarity index 96% rename from src/SharpEmu.GUI/Atrac9/Atrac9Rng.cs rename to src/SharpEmu.LibAtrac9/Atrac9Rng.cs index 90b13c97..00fc4fd6 100644 --- a/src/SharpEmu.GUI/Atrac9/Atrac9Rng.cs +++ b/src/SharpEmu.LibAtrac9/Atrac9Rng.cs @@ -1,3 +1,4 @@ +// SPDX-License-Identifier: MIT #nullable disable namespace LibAtrac9 { diff --git a/src/SharpEmu.GUI/Atrac9/BandExtension.cs b/src/SharpEmu.LibAtrac9/BandExtension.cs similarity index 99% rename from src/SharpEmu.GUI/Atrac9/BandExtension.cs rename to src/SharpEmu.LibAtrac9/BandExtension.cs index 57cc5b43..5a0e8f12 100644 --- a/src/SharpEmu.GUI/Atrac9/BandExtension.cs +++ b/src/SharpEmu.LibAtrac9/BandExtension.cs @@ -1,3 +1,4 @@ +// SPDX-License-Identifier: MIT #nullable disable using System; diff --git a/src/SharpEmu.GUI/Atrac9/BitAllocation.cs b/src/SharpEmu.LibAtrac9/BitAllocation.cs similarity index 99% rename from src/SharpEmu.GUI/Atrac9/BitAllocation.cs rename to src/SharpEmu.LibAtrac9/BitAllocation.cs index f178d8cc..40404a5c 100644 --- a/src/SharpEmu.GUI/Atrac9/BitAllocation.cs +++ b/src/SharpEmu.LibAtrac9/BitAllocation.cs @@ -1,3 +1,4 @@ +// SPDX-License-Identifier: MIT #nullable disable using System; diff --git a/src/SharpEmu.GUI/Atrac9/Block.cs b/src/SharpEmu.LibAtrac9/Block.cs similarity index 98% rename from src/SharpEmu.GUI/Atrac9/Block.cs rename to src/SharpEmu.LibAtrac9/Block.cs index e2675782..4b549ae6 100644 --- a/src/SharpEmu.GUI/Atrac9/Block.cs +++ b/src/SharpEmu.LibAtrac9/Block.cs @@ -1,3 +1,4 @@ +// SPDX-License-Identifier: MIT #nullable disable namespace LibAtrac9 { diff --git a/src/SharpEmu.GUI/Atrac9/Channel.cs b/src/SharpEmu.LibAtrac9/Channel.cs similarity index 98% rename from src/SharpEmu.GUI/Atrac9/Channel.cs rename to src/SharpEmu.LibAtrac9/Channel.cs index 80d80b1f..7aef5183 100644 --- a/src/SharpEmu.GUI/Atrac9/Channel.cs +++ b/src/SharpEmu.LibAtrac9/Channel.cs @@ -1,3 +1,4 @@ +// SPDX-License-Identifier: MIT #nullable disable using LibAtrac9.Utilities; diff --git a/src/SharpEmu.GUI/Atrac9/ChannelConfig.cs b/src/SharpEmu.LibAtrac9/ChannelConfig.cs similarity index 96% rename from src/SharpEmu.GUI/Atrac9/ChannelConfig.cs rename to src/SharpEmu.LibAtrac9/ChannelConfig.cs index b3de1459..e440f941 100644 --- a/src/SharpEmu.GUI/Atrac9/ChannelConfig.cs +++ b/src/SharpEmu.LibAtrac9/ChannelConfig.cs @@ -1,3 +1,4 @@ +// SPDX-License-Identifier: MIT #nullable disable namespace LibAtrac9 { diff --git a/src/SharpEmu.GUI/Atrac9/Frame.cs b/src/SharpEmu.LibAtrac9/Frame.cs similarity index 94% rename from src/SharpEmu.GUI/Atrac9/Frame.cs rename to src/SharpEmu.LibAtrac9/Frame.cs index 73e0b773..fbc7def1 100644 --- a/src/SharpEmu.GUI/Atrac9/Frame.cs +++ b/src/SharpEmu.LibAtrac9/Frame.cs @@ -1,3 +1,4 @@ +// SPDX-License-Identifier: MIT #nullable disable namespace LibAtrac9 { diff --git a/src/SharpEmu.GUI/Atrac9/HuffmanCodebook.cs b/src/SharpEmu.LibAtrac9/HuffmanCodebook.cs similarity index 98% rename from src/SharpEmu.GUI/Atrac9/HuffmanCodebook.cs rename to src/SharpEmu.LibAtrac9/HuffmanCodebook.cs index dcd8a407..f73c1259 100644 --- a/src/SharpEmu.GUI/Atrac9/HuffmanCodebook.cs +++ b/src/SharpEmu.LibAtrac9/HuffmanCodebook.cs @@ -1,3 +1,4 @@ +// SPDX-License-Identifier: MIT #nullable disable using System; using LibAtrac9.Utilities; diff --git a/src/SharpEmu.GUI/Atrac9/HuffmanCodebooks.cs b/src/SharpEmu.LibAtrac9/HuffmanCodebooks.cs similarity index 99% rename from src/SharpEmu.GUI/Atrac9/HuffmanCodebooks.cs rename to src/SharpEmu.LibAtrac9/HuffmanCodebooks.cs index 80c78a18..6138c9e8 100644 --- a/src/SharpEmu.GUI/Atrac9/HuffmanCodebooks.cs +++ b/src/SharpEmu.LibAtrac9/HuffmanCodebooks.cs @@ -1,3 +1,4 @@ +// SPDX-License-Identifier: MIT #nullable disable namespace LibAtrac9 { @@ -1350,4 +1351,4 @@ namespace LibAtrac9 new byte[] {0, 0, 0, 0} }; } -} \ No newline at end of file +} diff --git a/src/SharpEmu.GUI/Atrac9/LICENSE.txt b/src/SharpEmu.LibAtrac9/LICENSE.txt similarity index 100% rename from src/SharpEmu.GUI/Atrac9/LICENSE.txt rename to src/SharpEmu.LibAtrac9/LICENSE.txt diff --git a/src/SharpEmu.GUI/Atrac9/Quantization.cs b/src/SharpEmu.LibAtrac9/Quantization.cs similarity index 98% rename from src/SharpEmu.GUI/Atrac9/Quantization.cs rename to src/SharpEmu.LibAtrac9/Quantization.cs index 4e10dbf9..5fd5e2a8 100644 --- a/src/SharpEmu.GUI/Atrac9/Quantization.cs +++ b/src/SharpEmu.LibAtrac9/Quantization.cs @@ -1,3 +1,4 @@ +// SPDX-License-Identifier: MIT #nullable disable using System; diff --git a/src/SharpEmu.GUI/Atrac9/ScaleFactors.cs b/src/SharpEmu.LibAtrac9/ScaleFactors.cs similarity index 99% rename from src/SharpEmu.GUI/Atrac9/ScaleFactors.cs rename to src/SharpEmu.LibAtrac9/ScaleFactors.cs index ace92457..c0a1284e 100644 --- a/src/SharpEmu.GUI/Atrac9/ScaleFactors.cs +++ b/src/SharpEmu.LibAtrac9/ScaleFactors.cs @@ -1,3 +1,4 @@ +// SPDX-License-Identifier: MIT #nullable disable using System; using System.IO; diff --git a/src/SharpEmu.LibAtrac9/SharpEmu.LibAtrac9.csproj b/src/SharpEmu.LibAtrac9/SharpEmu.LibAtrac9.csproj new file mode 100644 index 00000000..be4224e9 --- /dev/null +++ b/src/SharpEmu.LibAtrac9/SharpEmu.LibAtrac9.csproj @@ -0,0 +1,12 @@ + + + + + SharpEmu.LibAtrac9 + LibAtrac9 + false + + diff --git a/src/SharpEmu.GUI/Atrac9/Stereo.cs b/src/SharpEmu.LibAtrac9/Stereo.cs similarity index 97% rename from src/SharpEmu.GUI/Atrac9/Stereo.cs rename to src/SharpEmu.LibAtrac9/Stereo.cs index ab158a47..d53ff463 100644 --- a/src/SharpEmu.GUI/Atrac9/Stereo.cs +++ b/src/SharpEmu.LibAtrac9/Stereo.cs @@ -1,3 +1,4 @@ +// SPDX-License-Identifier: MIT #nullable disable namespace LibAtrac9 { diff --git a/src/SharpEmu.GUI/Atrac9/Tables.cs b/src/SharpEmu.LibAtrac9/Tables.cs similarity index 99% rename from src/SharpEmu.GUI/Atrac9/Tables.cs rename to src/SharpEmu.LibAtrac9/Tables.cs index 72b4e74b..90c1f1a6 100644 --- a/src/SharpEmu.GUI/Atrac9/Tables.cs +++ b/src/SharpEmu.LibAtrac9/Tables.cs @@ -1,3 +1,4 @@ +// SPDX-License-Identifier: MIT #nullable disable using System; using static LibAtrac9.HuffmanCodebooks; diff --git a/src/SharpEmu.GUI/Atrac9/Unpack.cs b/src/SharpEmu.LibAtrac9/Unpack.cs similarity index 99% rename from src/SharpEmu.GUI/Atrac9/Unpack.cs rename to src/SharpEmu.LibAtrac9/Unpack.cs index 2ddbc45f..793e3fe4 100644 --- a/src/SharpEmu.GUI/Atrac9/Unpack.cs +++ b/src/SharpEmu.LibAtrac9/Unpack.cs @@ -1,3 +1,4 @@ +// SPDX-License-Identifier: MIT #nullable disable using System; using System.IO; diff --git a/src/SharpEmu.GUI/Atrac9/Utilities/Bit.cs b/src/SharpEmu.LibAtrac9/Utilities/Bit.cs similarity index 96% rename from src/SharpEmu.GUI/Atrac9/Utilities/Bit.cs rename to src/SharpEmu.LibAtrac9/Utilities/Bit.cs index a3349be1..49eccebc 100644 --- a/src/SharpEmu.GUI/Atrac9/Utilities/Bit.cs +++ b/src/SharpEmu.LibAtrac9/Utilities/Bit.cs @@ -1,3 +1,4 @@ +// SPDX-License-Identifier: MIT #nullable disable namespace LibAtrac9.Utilities { diff --git a/src/SharpEmu.GUI/Atrac9/Utilities/BitReader.cs b/src/SharpEmu.LibAtrac9/Utilities/BitReader.cs similarity index 99% rename from src/SharpEmu.GUI/Atrac9/Utilities/BitReader.cs rename to src/SharpEmu.LibAtrac9/Utilities/BitReader.cs index 46225609..738f04b4 100644 --- a/src/SharpEmu.GUI/Atrac9/Utilities/BitReader.cs +++ b/src/SharpEmu.LibAtrac9/Utilities/BitReader.cs @@ -1,3 +1,4 @@ +// SPDX-License-Identifier: MIT #nullable disable using System; using System.Diagnostics; diff --git a/src/SharpEmu.GUI/Atrac9/Utilities/Helpers.cs b/src/SharpEmu.LibAtrac9/Utilities/Helpers.cs similarity index 98% rename from src/SharpEmu.GUI/Atrac9/Utilities/Helpers.cs rename to src/SharpEmu.LibAtrac9/Utilities/Helpers.cs index bd5d9400..cfbd44d4 100644 --- a/src/SharpEmu.GUI/Atrac9/Utilities/Helpers.cs +++ b/src/SharpEmu.LibAtrac9/Utilities/Helpers.cs @@ -1,3 +1,4 @@ +// SPDX-License-Identifier: MIT #nullable disable using System.Runtime.CompilerServices; diff --git a/src/SharpEmu.GUI/Atrac9/Utilities/Mdct.cs b/src/SharpEmu.LibAtrac9/Utilities/Mdct.cs similarity index 99% rename from src/SharpEmu.GUI/Atrac9/Utilities/Mdct.cs rename to src/SharpEmu.LibAtrac9/Utilities/Mdct.cs index 0413b61f..b7d68aed 100644 --- a/src/SharpEmu.GUI/Atrac9/Utilities/Mdct.cs +++ b/src/SharpEmu.LibAtrac9/Utilities/Mdct.cs @@ -1,3 +1,4 @@ +// SPDX-License-Identifier: MIT #nullable disable using System; using System.Collections.Generic; diff --git a/src/SharpEmu.Libs/Acm/AcmExports.cs b/src/SharpEmu.Libs/Acm/AcmExports.cs index 36da90d7..827f49df 100644 --- a/src/SharpEmu.Libs/Acm/AcmExports.cs +++ b/src/SharpEmu.Libs/Acm/AcmExports.cs @@ -2,13 +2,17 @@ // SPDX-License-Identifier: GPL-2.0-or-later using System.Buffers.Binary; +using System.Collections.Concurrent; using SharpEmu.HLE; namespace SharpEmu.Libs.Acm; public static class AcmExports { + private const int AcmBatchErrorBytes = 32; + private static readonly ConcurrentDictionary Contexts = new(); private static int _nextContextHandle; + private static int _nextBatchHandle; [SysAbiExport( Nid = "ZIXln2K3XMk", @@ -23,12 +27,17 @@ public static class AcmExports return ctx.SetReturn(OrbisGen2Result.ORBIS_GEN2_ERROR_INVALID_ARGUMENT); } - var handle = (ulong)Interlocked.Increment(ref _nextContextHandle); - Span handleBytes = stackalloc byte[sizeof(ulong)]; - BinaryPrimitives.WriteUInt64LittleEndian(handleBytes, handle); - return ctx.Memory.TryWrite(outContextAddress, handleBytes) - ? ctx.SetReturn(OrbisGen2Result.ORBIS_GEN2_OK) - : ctx.SetReturn(OrbisGen2Result.ORBIS_GEN2_ERROR_MEMORY_FAULT); + var handle = unchecked((uint)Interlocked.Increment(ref _nextContextHandle)); + Span handleBytes = stackalloc byte[sizeof(uint)]; + BinaryPrimitives.WriteUInt32LittleEndian(handleBytes, handle); + if (!ctx.Memory.TryWrite(outContextAddress, handleBytes)) + { + return ctx.SetReturn(OrbisGen2Result.ORBIS_GEN2_ERROR_MEMORY_FAULT); + } + + Contexts[handle] = 0; + Trace($"context_create context={handle}"); + return ctx.SetReturn(OrbisGen2Result.ORBIS_GEN2_OK); } [SysAbiExport( @@ -38,7 +47,196 @@ public static class AcmExports LibraryName = "libSceAcm")] public static int AcmContextDestroy(CpuContext ctx) { - _ = ctx; + var context = unchecked((uint)ctx[CpuRegister.Rdi]); + Contexts.TryRemove(context, out _); + Trace($"context_destroy context={context}"); return ctx.SetReturn(OrbisGen2Result.ORBIS_GEN2_OK); } + + [SysAbiExport( + Nid = "tW9W+CAG4FE", + ExportName = "sceAcmBatchStartBuffer", + Target = Generation.Gen4 | Generation.Gen5, + LibraryName = "libSceAcm")] + public static int AcmBatchStartBuffer(CpuContext ctx) + { + var context = unchecked((uint)ctx[CpuRegister.Rdi]); + var commandsAddress = ctx[CpuRegister.Rsi]; + var commandsSize = ctx[CpuRegister.Rdx]; + var errorAddress = ctx[CpuRegister.Rcx]; + var batchAddress = ctx[CpuRegister.R8]; + + if (!Contexts.ContainsKey(context) || + batchAddress == 0 || + (commandsSize != 0 && commandsAddress == 0)) + { + return ctx.SetReturn(OrbisGen2Result.ORBIS_GEN2_ERROR_INVALID_ARGUMENT); + } + + return CompleteBatchStart(ctx, context, 1, errorAddress, batchAddress); + } + + [SysAbiExport( + Nid = "8fe55ktlNVo", + ExportName = "sceAcmBatchStartBuffers", + Target = Generation.Gen4 | Generation.Gen5, + LibraryName = "libSceAcm")] + public static int AcmBatchStartBuffers(CpuContext ctx) + { + var context = unchecked((uint)ctx[CpuRegister.Rdi]); + var infoCount = unchecked((uint)ctx[CpuRegister.Rsi]); + var infoArrayAddress = ctx[CpuRegister.Rdx]; + var errorAddress = ctx[CpuRegister.Rcx]; + var batchAddress = ctx[CpuRegister.R8]; + + if (!Contexts.ContainsKey(context) || + batchAddress == 0 || + (infoCount != 0 && infoArrayAddress == 0)) + { + return ctx.SetReturn(OrbisGen2Result.ORBIS_GEN2_ERROR_INVALID_ARGUMENT); + } + + return CompleteBatchStart(ctx, context, infoCount, errorAddress, batchAddress); + } + + [SysAbiExport( + Nid = "RLN3gRlXJLE", + ExportName = "sceAcmBatchWait", + Target = Generation.Gen4 | Generation.Gen5, + LibraryName = "libSceAcm")] + public static int AcmBatchWait(CpuContext ctx) + { + var context = unchecked((uint)ctx[CpuRegister.Rdi]); + return ctx.SetReturn( + Contexts.ContainsKey(context) + ? OrbisGen2Result.ORBIS_GEN2_OK + : OrbisGen2Result.ORBIS_GEN2_ERROR_INVALID_ARGUMENT); + } + + [SysAbiExport( + Nid = "r7z5YQFZo+U", + ExportName = "sceAcmBatchJobNotification", + Target = Generation.Gen4 | Generation.Gen5, + LibraryName = "libSceAcm")] + public static int AcmBatchJobNotification(CpuContext ctx) => + AdvanceBatchInfo(ctx, 32); + + [SysAbiExport( + Nid = "u70oWo92SYQ", + ExportName = "sceAcm_ConvReverb_SharedInput", + Target = Generation.Gen4 | Generation.Gen5, + LibraryName = "libSceAcm")] + public static int AcmConvReverbSharedInput(CpuContext ctx) => + AdvanceBatchInfo(ctx, 1024); + + [SysAbiExport( + Nid = "9nLbWmRDpa8", + ExportName = "sceAcm_ConvReverb_SharedIr", + Target = Generation.Gen4 | Generation.Gen5, + LibraryName = "libSceAcm")] + public static int AcmConvReverbSharedIr(CpuContext ctx) => + AdvanceBatchInfo(ctx, 1024); + + [SysAbiExport( + Nid = "KovqaFbmtsM", + ExportName = "sceAcm_FFT", + Target = Generation.Gen4 | Generation.Gen5, + LibraryName = "libSceAcm")] + public static int AcmFft(CpuContext ctx) => + AdvanceBatchInfo(ctx, 256); + + [SysAbiExport( + Nid = "DR-ZCmvVR9Q", + ExportName = "sceAcm_IFFT", + Target = Generation.Gen4 | Generation.Gen5, + LibraryName = "libSceAcm")] + public static int AcmIfft(CpuContext ctx) => + AdvanceBatchInfo(ctx, 256); + + [SysAbiExport( + Nid = "LA4RCNKnFjg", + ExportName = "sceAcm_Panner", + Target = Generation.Gen4 | Generation.Gen5, + LibraryName = "libSceAcm")] + public static int AcmPanner(CpuContext ctx) => + AdvanceBatchInfo(ctx, 512); + + internal static void ResetForTests() + { + Contexts.Clear(); + Interlocked.Exchange(ref _nextContextHandle, 0); + Interlocked.Exchange(ref _nextBatchHandle, 0); + } + + private static int CompleteBatchStart( + CpuContext ctx, + uint context, + uint infoCount, + ulong errorAddress, + ulong batchAddress) + { + if (errorAddress != 0) + { + Span error = stackalloc byte[AcmBatchErrorBytes]; + error.Clear(); + if (!ctx.Memory.TryWrite(errorAddress, error)) + { + return ctx.SetReturn(OrbisGen2Result.ORBIS_GEN2_ERROR_MEMORY_FAULT); + } + } + + var batch = unchecked((uint)Interlocked.Increment(ref _nextBatchHandle)); + Span batchBytes = stackalloc byte[sizeof(uint)]; + BinaryPrimitives.WriteUInt32LittleEndian(batchBytes, batch); + if (!ctx.Memory.TryWrite(batchAddress, batchBytes)) + { + return ctx.SetReturn(OrbisGen2Result.ORBIS_GEN2_ERROR_MEMORY_FAULT); + } + + Trace($"batch_start context={context} count={infoCount} batch={batch}"); + return ctx.SetReturn(OrbisGen2Result.ORBIS_GEN2_OK); + } + + private static int AdvanceBatchInfo(CpuContext ctx, ulong byteCount) + { + var infoAddress = ctx[CpuRegister.Rdi]; + if (infoAddress == 0) + { + return ctx.SetReturn(OrbisGen2Result.ORBIS_GEN2_OK); + } + + Span info = stackalloc byte[24]; + if (!ctx.Memory.TryRead(infoAddress, info)) + { + return ctx.SetReturn(OrbisGen2Result.ORBIS_GEN2_ERROR_MEMORY_FAULT); + } + + var buffer = BinaryPrimitives.ReadUInt64LittleEndian(info); + var offset = BinaryPrimitives.ReadUInt64LittleEndian(info[8..]); + var size = BinaryPrimitives.ReadUInt64LittleEndian(info[16..]); + if (buffer != 0 && size != 0) + { + var nextOffset = offset > ulong.MaxValue - byteCount + ? ulong.MaxValue + : offset + byteCount; + BinaryPrimitives.WriteUInt64LittleEndian(info[8..], Math.Min(size, nextOffset)); + if (!ctx.Memory.TryWrite(infoAddress, info)) + { + return ctx.SetReturn(OrbisGen2Result.ORBIS_GEN2_ERROR_MEMORY_FAULT); + } + } + + return ctx.SetReturn(OrbisGen2Result.ORBIS_GEN2_OK); + } + + private static void Trace(string message) + { + if (string.Equals( + Environment.GetEnvironmentVariable("SHARPEMU_LOG_ACM"), + "1", + StringComparison.Ordinal)) + { + Console.Error.WriteLine($"[LOADER][TRACE] acm.{message}"); + } + } } diff --git a/src/SharpEmu.Libs/Audio/AjmExports.cs b/src/SharpEmu.Libs/Audio/AjmExports.cs index 01571353..82351f44 100644 --- a/src/SharpEmu.Libs/Audio/AjmExports.cs +++ b/src/SharpEmu.Libs/Audio/AjmExports.cs @@ -1,7 +1,9 @@ // Copyright (C) 2026 SharpEmu Emulator Project // SPDX-License-Identifier: GPL-2.0-or-later +using LibAtrac9; using SharpEmu.HLE; +using System.Buffers; using System.Buffers.Binary; using System.Collections.Concurrent; using System.Threading; @@ -16,9 +18,24 @@ public static class AjmExports private const int OrbisAjmErrorOutOfResources = unchecked((int)0x80930007); private const int OrbisAjmErrorCodecAlreadyRegistered = unchecked((int)0x80930009); private const int OrbisAjmErrorCodecNotRegistered = unchecked((int)0x8093000A); - private const int OrbisAjmErrorWrongRevisionFlag = unchecked((int)0x8093000B); + private const int OrbisAjmErrorJobCreation = unchecked((int)0x80930012); + private const ulong MaxSilentPcmBytes = 1 << 20; + private const uint Atrac9CodecType = 1; private const uint MaxCodecType = 25; private const int MaxInstanceIndex = 0x2FFF; + private const int MaxDecodeBufferBytes = 64 * 1024 * 1024; + + // AjmJobFlags: version:3, codec:8, run_flags:2, control_flags:3, + // reserved:29, sideband_flags:3. + private const ulong AjmJobRunFlagGetCodecInfo = 1UL << 11; + private const ulong AjmJobRunFlagMultipleFrames = 1UL << 12; + private const ulong AjmJobSidebandFlagGaplessDecode = 1UL << 45; + private const ulong AjmJobSidebandFlagFormat = 1UL << 46; + private const ulong AjmJobSidebandFlagStream = 1UL << 47; + + // AjmBuffer { u8* p_address; u64 size; } + private const int AjmBufferDescriptorBytes = 16; + private const int MaxBufferDescriptors = 64; private static readonly ConcurrentDictionary Contexts = new(); private static int _nextContextId; private static int _nextBatchId; @@ -27,8 +44,20 @@ public static class AjmExports private sealed class AjmInstanceState { + public required uint Id { get; init; } + public required uint Codec { get; init; } + public required ulong Flags { get; init; } + + /// Channel ceiling from the instance flags; the decoded stream's + /// own config decides the actual PCM layout. + public required int MaxChannels { get; init; } + + public required Atrac9PcmEncoding Encoding { get; init; } + + public Atrac9DecodeState? Atrac9 { get; init; } + public AjmMp3Decoder? Mp3 { get; init; } public bool PreferPcm16 @@ -50,6 +79,8 @@ public static class AjmExports public Dictionary InstancesBySlot { get; } = new(); + public Dictionary RegisteredMemoryPages { get; } = new(); + public int NextInstanceIndex { get; set; } } @@ -93,6 +124,65 @@ public static class AjmExports return 0; } + [SysAbiExport( + Nid = "bkRHEYG6lEM", + ExportName = "sceAjmMemoryRegister", + Target = Generation.Gen4 | Generation.Gen5, + LibraryName = "libSceAjm")] + public static int AjmMemoryRegister(CpuContext ctx) + { + var contextId = unchecked((uint)ctx[CpuRegister.Rdi]); + var address = ctx[CpuRegister.Rsi]; + var pages = ctx[CpuRegister.Rdx]; + if (!Contexts.TryGetValue(contextId, out var state)) + { + return ctx.SetReturn(OrbisAjmErrorInvalidContext); + } + + if (address == 0 || pages == 0) + { + return ctx.SetReturn(OrbisAjmErrorInvalidParameter); + } + + // AJM hardware requires explicit registration. SharpEmu decodes from + // the shared guest address space, so retaining the range is enough. + lock (state.Gate) + { + state.RegisteredMemoryPages[address] = pages; + } + + Trace($"memory_register context={contextId} address=0x{address:X16} pages={pages}"); + return ctx.SetReturn(0); + } + + [SysAbiExport( + Nid = "pIpGiaYkHkM", + ExportName = "sceAjmMemoryUnregister", + Target = Generation.Gen4 | Generation.Gen5, + LibraryName = "libSceAjm")] + public static int AjmMemoryUnregister(CpuContext ctx) + { + var contextId = unchecked((uint)ctx[CpuRegister.Rdi]); + var address = ctx[CpuRegister.Rsi]; + if (!Contexts.TryGetValue(contextId, out var state)) + { + return ctx.SetReturn(OrbisAjmErrorInvalidContext); + } + + if (address == 0) + { + return ctx.SetReturn(OrbisAjmErrorInvalidParameter); + } + + lock (state.Gate) + { + state.RegisteredMemoryPages.Remove(address); + } + + Trace($"memory_unregister context={contextId} address=0x{address:X16}"); + return ctx.SetReturn(0); + } + [SysAbiExport( Nid = "Q3dyFuwGn64", ExportName = "sceAjmModuleRegister", @@ -144,30 +234,34 @@ public static class AjmExports var outputAddress = ctx[CpuRegister.Rcx]; if (!Contexts.TryGetValue(contextId, out var state)) { - return ctx.SetReturn(OrbisAjmErrorInvalidContext); + return TraceInstanceCreateFailure(ctx, contextId, codecType, flags, OrbisAjmErrorInvalidContext); } if (codecType >= MaxCodecType || outputAddress == 0) { - return ctx.SetReturn(OrbisAjmErrorInvalidParameter); + return TraceInstanceCreateFailure(ctx, contextId, codecType, flags, OrbisAjmErrorInvalidParameter); } - if ((flags & 0x7) == 0) - { - return ctx.SetReturn(OrbisAjmErrorWrongRevisionFlag); - } + // AjmInstanceFlags carries a codec id in the high bits plus a low + // channel/revision word whose exact split is not settled: Demon's Souls + // creates ATRAC9 voices with low words 1, 2, 4 and 8. Rejecting any of + // them leaves the title holding SCE_AJM_INSTANCE_INVALID for that voice + // and it plays silence forever, so nothing here is fatal — the decoded + // stream's own config is what actually describes the PCM anyway. + var maxChannels = unchecked((int)(flags & 0xF)); + var encoding = GetPcmEncoding(flags); uint instanceId; lock (state.Gate) { if (!state.RegisteredCodecs.Contains(codecType)) { - return ctx.SetReturn(OrbisAjmErrorCodecNotRegistered); + return TraceInstanceCreateFailure(ctx, contextId, codecType, flags, OrbisAjmErrorCodecNotRegistered); } if (state.InstancesBySlot.Count >= MaxInstanceIndex) { - return ctx.SetReturn(OrbisAjmErrorOutOfResources); + return TraceInstanceCreateFailure(ctx, contextId, codecType, flags, OrbisAjmErrorOutOfResources); } var nextInstanceIndex = state.NextInstanceIndex; @@ -192,8 +286,12 @@ public static class AjmExports instanceSlot, new AjmInstanceState { + Id = instanceId, Codec = codecType, Flags = flags, + MaxChannels = maxChannels, + Encoding = encoding, + Atrac9 = codecType == Atrac9CodecType ? new Atrac9DecodeState() : null, Mp3 = codecType == AjmCodecMp3 ? new AjmMp3Decoder() : null, }); } @@ -247,11 +345,136 @@ public static class AjmExports LibraryName = "libSceAjm")] public static int AjmBatchInitialize(CpuContext ctx) { - // The caller owns and initializes the batch storage. This API resets - // its submission cursor on hardware; FMOD does not consume a return - // value or an additional output object here. - ctx[CpuRegister.Rax] = 0; - return 0; + var bufferAddress = ctx[CpuRegister.Rdi]; + var bufferSize = ctx[CpuRegister.Rsi]; + var infoAddress = ctx[CpuRegister.Rdx]; + if (bufferAddress == 0 || infoAddress == 0) + { + return ctx.SetReturn(OrbisAjmErrorInvalidParameter); + } + + Span info = stackalloc byte[AjmBatchInfoBytes]; + info.Clear(); + BinaryPrimitives.WriteUInt64LittleEndian(info, bufferAddress); + BinaryPrimitives.WriteUInt64LittleEndian(info[16..], bufferSize); + if (!ctx.Memory.TryWrite(infoAddress, info)) + { + return ctx.SetReturn(OrbisAjmErrorInvalidParameter); + } + + Trace($"batch_initialize buffer=0x{bufferAddress:X16} size=0x{bufferSize:X} info=0x{infoAddress:X16}"); + return ctx.SetReturn(0); + } + + [SysAbiExport( + Nid = "1t3ixYNXyuc", + ExportName = "sceAjmDecAt9ParseConfigData", + Target = Generation.Gen4 | Generation.Gen5, + LibraryName = "libSceAjm")] + public static int AjmDecAt9ParseConfigData(CpuContext ctx) + { + var configAddress = ctx[CpuRegister.Rdi]; + var outputAddress = ctx[CpuRegister.Rsi]; + if (configAddress == 0 || outputAddress == 0) + { + return ctx.SetReturn(OrbisAjmErrorInvalidParameter); + } + + Span configData = stackalloc byte[4]; + if (!ctx.Memory.TryRead(configAddress, configData)) + { + return ctx.SetReturn(OrbisAjmErrorInvalidParameter); + } + + try + { + var config = new LibAtrac9.Atrac9Config(configData.ToArray()); + Span info = stackalloc byte[20]; + BinaryPrimitives.WriteUInt32LittleEndian(info[0..], unchecked((uint)config.ChannelCount)); + BinaryPrimitives.WriteUInt32LittleEndian(info[4..], unchecked((uint)config.SampleRate)); + BinaryPrimitives.WriteUInt32LittleEndian(info[8..], unchecked((uint)config.FrameSamples)); + BinaryPrimitives.WriteUInt32LittleEndian(info[12..], unchecked((uint)config.SuperframeSamples)); + BinaryPrimitives.WriteUInt32LittleEndian(info[16..], unchecked((uint)config.SuperframeBytes)); + return ctx.SetReturn( + ctx.Memory.TryWrite(outputAddress, info) + ? 0 + : OrbisAjmErrorInvalidParameter); + } + catch (InvalidDataException) + { + return ctx.SetReturn(OrbisAjmErrorInvalidParameter); + } + } + + [SysAbiExport( + Nid = "ezM2OhNxzck", + ExportName = "sceAjmBatchJobInitialize", + Target = Generation.Gen4 | Generation.Gen5, + LibraryName = "libSceAjm")] + public static int AjmBatchJobInitialize(CpuContext ctx) + { + var infoAddress = ctx[CpuRegister.Rdi]; + var instanceId = unchecked((uint)ctx[CpuRegister.Rsi]); + var parametersAddress = ctx[CpuRegister.Rdx]; + var parametersSize = ctx[CpuRegister.Rcx]; + var resultAddress = ctx[CpuRegister.R8]; + + if (!TryAppendBatchJob(ctx, infoAddress, AjmJobControlSize)) + { + return ctx.SetReturn(OrbisAjmErrorJobCreation); + } + + var status = Atrac9DecodeState.ResultInvalidParameter; + if (TryGetInstance(instanceId, out var instance)) + { + if (instance.Codec != Atrac9CodecType) + { + status = 0; + } + else if (instance.Atrac9 is not null && + parametersAddress != 0 && + parametersSize >= 4) + { + Span configData = stackalloc byte[4]; + if (ctx.Memory.TryRead(parametersAddress, configData) && + instance.Atrac9.TryInitialize(configData)) + { + status = 0; + } + } + } + + WriteBasicResult(ctx, resultAddress, status); + Trace( + $"batch_job_initialize instance=0x{instanceId:X8} params=0x{parametersAddress:X16}+0x{parametersSize:X} status=0x{status:X8}"); + return ctx.SetReturn(0); + } + + [SysAbiExport( + Nid = "uJ3m8INuikg", + ExportName = "sceAjmBatchJobClearContext", + Target = Generation.Gen4 | Generation.Gen5, + LibraryName = "libSceAjm")] + public static int AjmBatchJobClearContext(CpuContext ctx) + { + var infoAddress = ctx[CpuRegister.Rdi]; + var instanceId = unchecked((uint)ctx[CpuRegister.Rsi]); + var resultAddress = ctx[CpuRegister.Rdx]; + + if (!TryAppendBatchJob(ctx, infoAddress, AjmJobControlSize)) + { + return ctx.SetReturn(OrbisAjmErrorJobCreation); + } + + var status = Atrac9DecodeState.ResultInvalidParameter; + if (TryGetInstance(instanceId, out var instance)) + { + instance.Atrac9?.Reset(); + status = 0; + } + + WriteBasicResult(ctx, resultAddress, status); + return ctx.SetReturn(0); } /// @@ -264,103 +487,181 @@ public static class AjmExports ExportName = "sceAjmBatchJobDecode", Target = Generation.Gen4 | Generation.Gen5, LibraryName = "libSceAjm")] - public static int AjmBatchJobDecode(CpuContext ctx) + public static int AjmBatchJobDecode(CpuContext ctx) => + AjmBatchJobDecodeCore(ctx, multipleFrames: true); + + [SysAbiExport( + Nid = "5LLWbpP5xi8", + ExportName = "sceAjmBatchJobDecodeSingle", + Target = Generation.Gen4 | Generation.Gen5, + LibraryName = "libSceAjm")] + public static int AjmBatchJobDecodeSingle(CpuContext ctx) => + AjmBatchJobDecodeCore(ctx, multipleFrames: false); + + /// + /// The flag-driven decode entry point. Titles built on the modern AJM API + /// drive ATRAC9 playback exclusively through Run/RunSplit — + /// is never called — so leaving these + /// unresolved silences every streamed voice. + /// + [SysAbiExport( + Nid = "jVCWcthifr8", + ExportName = "sceAjmBatchJobRun", + Target = Generation.Gen4 | Generation.Gen5, + LibraryName = "libSceAjm")] + public static int AjmBatchJobRun(CpuContext ctx) => AjmBatchJobRunCore(ctx, split: false); + + [SysAbiExport( + Nid = "Z9NVCesiP0Q", + ExportName = "sceAjmBatchJobRunSplit", + Target = Generation.Gen4 | Generation.Gen5, + LibraryName = "libSceAjm")] + public static int AjmBatchJobRunSplit(CpuContext ctx) => AjmBatchJobRunCore(ctx, split: true); + + // The BufferRa variants only append a guest return address after the nine + // shared arguments, so they decode identically. + [SysAbiExport( + Nid = "ElslOCpOIns", + ExportName = "sceAjmBatchJobRunBufferRa", + Target = Generation.Gen4 | Generation.Gen5, + LibraryName = "libSceAjm")] + public static int AjmBatchJobRunBufferRa(CpuContext ctx) => AjmBatchJobRunCore(ctx, split: false); + + [SysAbiExport( + Nid = "7jdAXK+2fMo", + ExportName = "sceAjmBatchJobRunSplitBufferRa", + Target = Generation.Gen4 | Generation.Gen5, + LibraryName = "libSceAjm")] + public static int AjmBatchJobRunSplitBufferRa(CpuContext ctx) => AjmBatchJobRunCore(ctx, split: true); + + /// + /// Records the gapless-decode window for a looping voice. SharpEmu decodes + /// whole superframes and does not trim lead-in/lead-out samples yet, so this + /// only has to acknowledge the job instead of failing the batch. + /// + [SysAbiExport( + Nid = "SkEwpiu3tZg", + ExportName = "sceAjmBatchJobSetGaplessDecode", + Target = Generation.Gen4 | Generation.Gen5, + LibraryName = "libSceAjm")] + public static int AjmBatchJobSetGaplessDecode(CpuContext ctx) { var infoAddress = ctx[CpuRegister.Rdi]; var instanceId = unchecked((uint)ctx[CpuRegister.Rsi]); - var inputAddress = ctx[CpuRegister.Rdx]; - var inputSize = ctx[CpuRegister.Rcx]; - var outputAddress = ctx[CpuRegister.R8]; - var outputSize = ctx[CpuRegister.R9]; - var resultAddress = ReadStackArg64(ctx, 0); + var gaplessAddress = ctx[CpuRegister.Rdx]; + var resultAddress = ctx[CpuRegister.Rcx]; - if (infoAddress == 0) + if (!TryAppendBatchJob(ctx, infoAddress, AjmJobControlSize)) { - return ctx.SetReturn(OrbisAjmErrorInvalidParameter); + return ctx.SetReturn(OrbisAjmErrorJobCreation); } - _ = TryAppendBatchJob(ctx, infoAddress, AjmJobRunSize); + var status = TryGetInstance(instanceId, out _) ? 0 : Atrac9DecodeState.ResultInvalidParameter; + WriteBasicResult(ctx, resultAddress, status); + Trace($"batch_job_set_gapless_decode instance=0x{instanceId:X8} gapless=0x{gaplessAddress:X16} status=0x{status:X8}"); + return ctx.SetReturn(0); + } - var inputConsumed = 0; - var outputWritten = 0; - ulong totalSamples = 0; - var frames = 0u; - var decoded = false; + [SysAbiExport( + Nid = "3cAg7xN995U", + ExportName = "sceAjmBatchJobGetStatistics", + Target = Generation.Gen4 | Generation.Gen5, + LibraryName = "libSceAjm")] + public static int AjmBatchJobGetStatistics(CpuContext ctx) + { + var infoAddress = ctx[CpuRegister.Rdi]; + var resultAddress = ctx[CpuRegister.Rsi]; - if (TryGetInstance(instanceId, out var instance) && - instance.Mp3 is not null && - inputAddress != 0 && - inputSize is > 0 and <= MaxSilentPcmBytes && + if (!TryAppendBatchJob(ctx, infoAddress, AjmJobGetStatisticsSize)) + { + return ctx.SetReturn(OrbisAjmErrorJobCreation); + } + + if (resultAddress != 0) + { + Span result = stackalloc byte[AjmStatisticsResultBytes]; + result.Clear(); + if (!ctx.Memory.TryWrite(resultAddress, result)) + { + return ctx.SetReturn(OrbisAjmErrorInvalidParameter); + } + } + + ctx.GetXmmRegister(0, out var intervalBits, out _); + var interval = BitConverter.Int32BitsToSingle(unchecked((int)(uint)intervalBits)); + Trace($"batch_job_get_statistics interval={interval:R} result=0x{resultAddress:X16}"); + return ctx.SetReturn(0); + } + + + /// + /// Decodes one MP3 job into the guest's output buffer. Reported through the + /// same result shape as ATRAC9 so the sideband writer stays codec-agnostic. + /// + private static Atrac9DecodeResult DecodeMp3( + CpuContext ctx, + AjmInstanceState instance, + ulong inputAddress, + ulong inputSize, + ulong outputAddress, + ulong outputSize) + { + var mp3 = instance.Mp3!; + if (inputAddress != 0 && + inputSize is > 0 and <= MaxDecodeBufferBytes && outputAddress != 0 && - outputSize is > 0 and <= MaxSilentPcmBytes) + outputSize is > 0 and <= MaxDecodeBufferBytes) { var input = new byte[inputSize]; var output = new byte[outputSize]; if (ctx.Memory.TryRead(inputAddress, input)) { - var result = instance.Mp3.Decode(input, output, pcm16: instance.PreferPcm16); - if (result.OutputWritten > 0) + var decoded = mp3.Decode(input, output, pcm16: instance.PreferPcm16); + if (decoded.OutputWritten > 0 && + ctx.Memory.TryWrite(outputAddress, output.AsSpan(0, decoded.OutputWritten))) { - if (!ctx.Memory.TryWrite(outputAddress, output.AsSpan(0, result.OutputWritten))) - { - return ctx.SetReturn(OrbisAjmErrorInvalidParameter); - } - // Zero any remainder so stale PCM does not leak into FMOD. - if ((ulong)result.OutputWritten < outputSize) + if ((ulong)decoded.OutputWritten < outputSize) { ClearGuestMemory( ctx, - outputAddress + (ulong)result.OutputWritten, - outputSize - (ulong)result.OutputWritten); + outputAddress + (ulong)decoded.OutputWritten, + outputSize - (ulong)decoded.OutputWritten); } - decoded = true; - inputConsumed = result.InputConsumed; - outputWritten = result.OutputWritten; - frames = result.Frames; - totalSamples = instance.Mp3.TotalDecodedSamples; + return new Atrac9DecodeResult( + 0, + decoded.InputConsumed, + decoded.OutputWritten, + mp3.TotalDecodedSamples, + decoded.Frames); } - else + + if (decoded.InputConsumed > 0) { - inputConsumed = result.InputConsumed; - totalSamples = instance.Mp3.TotalDecodedSamples; + ClearGuestMemory(ctx, outputAddress, outputSize); + return new Atrac9DecodeResult( + 0, + decoded.InputConsumed, + 0, + mp3.TotalDecodedSamples, + 0); } } } - if (!decoded) + // Fallback: silence and consume the input so the guest does not spin. + if (outputAddress != 0 && outputSize is > 0 and <= MaxDecodeBufferBytes) { - // Fallback: silence + consume input so the guest does not spin. - if (outputAddress != 0 && outputSize != 0 && outputSize <= MaxSilentPcmBytes) - { - ClearGuestMemory(ctx, outputAddress, outputSize); - } - - if (inputConsumed == 0) - { - inputConsumed = inputSize > int.MaxValue ? int.MaxValue : (int)inputSize; - } - - if (frames == 0 && (inputSize != 0 || outputSize != 0)) - { - frames = 1; - } + ClearGuestMemory(ctx, outputAddress, outputSize); } - WriteDecodeStreamResult( - ctx, - resultAddress, - inputConsumed, - outputWritten, - totalSamples, - frames); - - Trace( - $"batch_job_decode info=0x{infoAddress:X16} instance=0x{instanceId:X8} " + - $"in=0x{inputAddress:X16}+0x{inputSize:X} out=0x{outputAddress:X16}+0x{outputSize:X} " + - $"written={outputWritten} frames={frames} result=0x{resultAddress:X16}"); - return ctx.SetReturn(0); + return new Atrac9DecodeResult( + 0, + unchecked((int)Math.Min(inputSize, int.MaxValue)), + 0, + mp3.TotalDecodedSamples, + inputSize != 0 || outputSize != 0 ? 1u : 0u); } private static bool TryGetInstance(uint instanceId, out AjmInstanceState instance) @@ -458,6 +759,473 @@ public static class AjmExports return ctx.SetReturn(0); } + private static int AjmBatchJobDecodeCore(CpuContext ctx, bool multipleFrames) + { + var infoAddress = ctx[CpuRegister.Rdi]; + var instanceId = unchecked((uint)ctx[CpuRegister.Rsi]); + var inputAddress = ctx[CpuRegister.Rdx]; + var inputSize = ctx[CpuRegister.Rcx]; + var outputAddress = ctx[CpuRegister.R8]; + var outputSize = ctx[CpuRegister.R9]; + var resultAddress = ReadStackArg64(ctx, 0); + + if (!TryAppendBatchJob(ctx, infoAddress, AjmJobRunSize)) + { + return ctx.SetReturn(OrbisAjmErrorJobCreation); + } + + Atrac9DecodeResult result; + if (!TryGetInstance(instanceId, out var instance)) + { + result = new Atrac9DecodeResult( + Atrac9DecodeState.ResultInvalidParameter, + 0, + 0, + 0, + 0); + } + else if (instance.Mp3 is not null) + { + // GTA V Enhanced streams menu music through AJM MP3 (codec 0). + // Decoded eagerly here so BatchStart/Wait stay synchronous no-ops. + result = DecodeMp3( + ctx, + instance, + inputAddress, + inputSize, + outputAddress, + outputSize); + } + else if (instance.Codec != Atrac9CodecType || instance.Atrac9 is null) + { + if (outputAddress != 0 && + outputSize is > 0 and <= MaxDecodeBufferBytes) + { + ClearGuestMemory(ctx, outputAddress, outputSize); + } + + result = new Atrac9DecodeResult( + 0, + unchecked((int)Math.Min(inputSize, int.MaxValue)), + 0, + 0, + inputSize != 0 || outputSize != 0 ? 1u : 0u); + } + else + { + result = DecodeAtrac9( + ctx, + instance, + inputAddress, + inputSize, + outputAddress, + outputSize, + multipleFrames); + } + + WriteDecodeStreamResult(ctx, resultAddress, result, multipleFrames); + Trace( + $"batch_job_decode instance=0x{instanceId:X8} in=0x{inputAddress:X16}+0x{inputSize:X} " + + $"out=0x{outputAddress:X16}+0x{outputSize:X} consumed={result.InputConsumed} " + + $"produced={result.OutputWritten} frames={result.Frames} status=0x{result.Status:X8}"); + return ctx.SetReturn(0); + } + + private static int TraceInstanceCreateFailure( + CpuContext ctx, + uint contextId, + uint codecType, + ulong flags, + int error) + { + Trace($"instance_create_failed context={contextId} codec={codecType} flags=0x{flags:X} error=0x{unchecked((uint)error):X8}"); + return ctx.SetReturn(error); + } + + private readonly record struct AjmGuestBuffer(ulong Address, int Length); + + /// + /// Shared body of sceAjmBatchJobRun / sceAjmBatchJobRunSplit. The split form + /// passes arrays of AjmBuffer descriptors instead of one pointer/size pair; + /// the descriptors are a single logical stream, so they are gathered on the + /// way in and scattered on the way out. + /// + private static int AjmBatchJobRunCore(CpuContext ctx, bool split) + { + var infoAddress = ctx[CpuRegister.Rdi]; + var instanceId = unchecked((uint)ctx[CpuRegister.Rsi]); + var flags = ctx[CpuRegister.Rdx]; + var inputAddress = ctx[CpuRegister.Rcx]; + var inputCountOrSize = ctx[CpuRegister.R8]; + var outputAddress = ctx[CpuRegister.R9]; + var outputCountOrSize = ReadStackArg64(ctx, 0); + var sidebandAddress = ReadStackArg64(ctx, 1); + var sidebandSize = ReadStackArg64(ctx, 2); + + var descriptors = split + ? Math.Min(inputCountOrSize, MaxBufferDescriptors) + Math.Min(outputCountOrSize, MaxBufferDescriptors) + : 0; + if (!TryAppendBatchJob(ctx, infoAddress, AjmJobRunSize + (descriptors * AjmBufferDescriptorBytes))) + { + return ctx.SetReturn(OrbisAjmErrorJobCreation); + } + + var multipleFrames = (flags & AjmJobRunFlagMultipleFrames) != 0; + Atrac9Config? config = null; + Atrac9DecodeResult result; + + if (!TryGetInstance(instanceId, out var instance)) + { + result = new Atrac9DecodeResult(Atrac9DecodeState.ResultInvalidParameter, 0, 0, 0, 0); + } + else if (!TryCollectBuffers(ctx, split, inputAddress, inputCountOrSize, out var inputs, out var inputLength) || + !TryCollectBuffers(ctx, split, outputAddress, outputCountOrSize, out var outputs, out var outputLength)) + { + result = new Atrac9DecodeResult(Atrac9DecodeState.ResultInvalidParameter, 0, 0, 0, 0); + } + else if (instance.Codec != Atrac9CodecType || instance.Atrac9 is null) + { + foreach (var buffer in outputs) + { + ClearGuestMemory(ctx, buffer.Address, (ulong)buffer.Length); + } + + result = new Atrac9DecodeResult( + 0, + inputLength, + 0, + 0, + inputLength != 0 || outputLength != 0 ? 1u : 0u); + } + else + { + config = instance.Atrac9.Config; + result = DecodeAtrac9Scattered(ctx, instance, inputs, inputLength, outputs, outputLength, multipleFrames); + config ??= instance.Atrac9.Config; + } + + WriteRunSideband(ctx, sidebandAddress, sidebandSize, flags, result, instance, config); + Trace( + $"batch_job_run{(split ? "_split" : string.Empty)} instance=0x{instanceId:X8} flags=0x{flags:X} " + + $"in=0x{inputAddress:X16}#{inputCountOrSize} out=0x{outputAddress:X16}#{outputCountOrSize} " + + $"sideband=0x{sidebandAddress:X16}+0x{sidebandSize:X} consumed={result.InputConsumed} " + + $"produced={result.OutputWritten} frames={result.Frames} status=0x{result.Status:X8}"); + TraceBufferDescriptors(ctx, split, "in", inputAddress, inputCountOrSize); + TraceBufferDescriptors(ctx, split, "out", outputAddress, outputCountOrSize); + return ctx.SetReturn(0); + } + + private static void TraceBufferDescriptors( + CpuContext ctx, + bool split, + string label, + ulong address, + ulong countOrSize) + { + if (!split || + address == 0 || + countOrSize == 0 || + countOrSize > MaxBufferDescriptors || + !string.Equals(Environment.GetEnvironmentVariable("SHARPEMU_LOG_AJM"), "1", StringComparison.Ordinal)) + { + return; + } + + Span descriptor = stackalloc byte[AjmBufferDescriptorBytes]; + for (var index = 0UL; index < countOrSize; index++) + { + if (!ctx.Memory.TryRead(address + (index * AjmBufferDescriptorBytes), descriptor)) + { + return; + } + + var bufferAddress = BinaryPrimitives.ReadUInt64LittleEndian(descriptor); + var bufferSize = BinaryPrimitives.ReadUInt64LittleEndian(descriptor[8..]); + Span head = stackalloc byte[16]; + var readable = bufferAddress != 0 && bufferSize != 0 && ctx.Memory.TryRead(bufferAddress, head); + Trace( + $"buffer[{label}][{index}] address=0x{bufferAddress:X16} size=0x{bufferSize:X} " + + $"head={(readable ? Convert.ToHexString(head) : "")}"); + } + } + + private static bool TryCollectBuffers( + CpuContext ctx, + bool split, + ulong address, + ulong countOrSize, + out List buffers, + out int totalLength) + { + buffers = new List(split ? (int)Math.Min(countOrSize, MaxBufferDescriptors) : 1); + totalLength = 0; + + if (!split) + { + if (countOrSize > MaxDecodeBufferBytes || (countOrSize != 0 && address == 0)) + { + return false; + } + + if (countOrSize != 0) + { + buffers.Add(new AjmGuestBuffer(address, unchecked((int)countOrSize))); + totalLength = unchecked((int)countOrSize); + } + + return true; + } + + if (countOrSize > MaxBufferDescriptors || (countOrSize != 0 && address == 0)) + { + return false; + } + + Span descriptor = stackalloc byte[AjmBufferDescriptorBytes]; + for (var index = 0UL; index < countOrSize; index++) + { + if (!ctx.Memory.TryRead(address + (index * AjmBufferDescriptorBytes), descriptor)) + { + return false; + } + + var bufferAddress = BinaryPrimitives.ReadUInt64LittleEndian(descriptor); + var bufferSize = BinaryPrimitives.ReadUInt64LittleEndian(descriptor[8..]); + if (bufferSize == 0) + { + continue; + } + + if (bufferAddress == 0 || + bufferSize > MaxDecodeBufferBytes || + (ulong)totalLength + bufferSize > MaxDecodeBufferBytes) + { + return false; + } + + buffers.Add(new AjmGuestBuffer(bufferAddress, unchecked((int)bufferSize))); + totalLength += unchecked((int)bufferSize); + } + + return true; + } + + private static Atrac9DecodeResult DecodeAtrac9Scattered( + CpuContext ctx, + AjmInstanceState instance, + List inputs, + int inputLength, + List outputs, + int outputLength, + bool multipleFrames) + { + var input = ArrayPool.Shared.Rent(Math.Max(inputLength, 1)); + var output = ArrayPool.Shared.Rent(Math.Max(outputLength, 1)); + try + { + var gathered = 0; + foreach (var buffer in inputs) + { + if (!ctx.Memory.TryRead(buffer.Address, input.AsSpan(gathered, buffer.Length))) + { + return new Atrac9DecodeResult(Atrac9DecodeState.ResultInvalidParameter, 0, 0, 0, 0); + } + + gathered += buffer.Length; + } + + var result = instance.Atrac9!.Decode( + input.AsSpan(0, inputLength), + output.AsSpan(0, outputLength), + instance.Encoding, + requestedChannels: 0, + multipleFrames); + + var scattered = 0; + foreach (var buffer in outputs) + { + if (scattered >= result.OutputWritten) + { + break; + } + + var chunk = Math.Min(buffer.Length, result.OutputWritten - scattered); + if (!ctx.Memory.TryWrite(buffer.Address, output.AsSpan(scattered, chunk))) + { + return result with + { + Status = result.Status | Atrac9DecodeState.ResultInvalidParameter, + OutputWritten = scattered, + }; + } + + scattered += chunk; + } + + return result; + } + finally + { + ArrayPool.Shared.Return(input); + ArrayPool.Shared.Return(output); + } + } + + private static void WriteRunSideband( + CpuContext ctx, + ulong address, + ulong size, + ulong flags, + Atrac9DecodeResult result, + AjmInstanceState? instance, + Atrac9Config? config) + { + if (address == 0 || size < AjmSidebandResultBytes) + { + return; + } + + // Layout is positional and driven purely by the job flags: + // Result, then Stream, Format, GaplessDecode, MFrame, CodecInfo — each + // only present when its flag is set and there is still room. + Span sideband = stackalloc byte[ + AjmSidebandResultBytes + + AjmSidebandStreamBytes + + AjmSidebandFormatBytes + + AjmSidebandGaplessDecodeBytes + + AjmSidebandMFrameBytes]; + sideband.Clear(); + + BinaryPrimitives.WriteInt32LittleEndian(sideband, result.Status); + var offset = AjmSidebandResultBytes; + + if ((flags & AjmJobSidebandFlagStream) != 0 && (ulong)(offset + AjmSidebandStreamBytes) <= size) + { + BinaryPrimitives.WriteInt32LittleEndian(sideband[offset..], result.InputConsumed); + BinaryPrimitives.WriteInt32LittleEndian(sideband[(offset + 4)..], result.OutputWritten); + BinaryPrimitives.WriteUInt64LittleEndian(sideband[(offset + 8)..], result.TotalDecodedSamples); + offset += AjmSidebandStreamBytes; + } + + if ((flags & AjmJobSidebandFlagFormat) != 0 && (ulong)(offset + AjmSidebandFormatBytes) <= size) + { + var channels = config?.ChannelCount ?? instance?.MaxChannels ?? 0; + BinaryPrimitives.WriteUInt32LittleEndian(sideband[offset..], unchecked((uint)channels)); + BinaryPrimitives.WriteUInt32LittleEndian(sideband[(offset + 4)..], ChannelMaskFor(channels)); + BinaryPrimitives.WriteUInt32LittleEndian(sideband[(offset + 8)..], unchecked((uint)(config?.SampleRate ?? 0))); + BinaryPrimitives.WriteUInt32LittleEndian( + sideband[(offset + 12)..], + unchecked((uint)(instance?.Encoding ?? Atrac9PcmEncoding.Signed16))); + offset += AjmSidebandFormatBytes; + } + + if ((flags & AjmJobSidebandFlagGaplessDecode) != 0 && (ulong)(offset + AjmSidebandGaplessDecodeBytes) <= size) + { + offset += AjmSidebandGaplessDecodeBytes; + } + + if ((flags & AjmJobRunFlagMultipleFrames) != 0 && (ulong)(offset + AjmSidebandMFrameBytes) <= size) + { + BinaryPrimitives.WriteUInt32LittleEndian(sideband[offset..], result.Frames); + offset += AjmSidebandMFrameBytes; + } + + _ = ctx.Memory.TryWrite(address, sideband[..offset]); + + if ((flags & AjmJobRunFlagGetCodecInfo) != 0 && (ulong)offset < size) + { + // No codec-info block is produced yet; zero it so the caller reads + // defined values instead of whatever the buffer held before. + ClearGuestMemory( + ctx, + address + (ulong)offset, + Math.Min(size - (ulong)offset, AjmSidebandCodecInfoBytes)); + } + } + + private static uint ChannelMaskFor(int channels) => + channels switch + { + 1 => 0x4, + 2 => 0x3, + 6 => 0x3F, + 8 => 0x63F, + _ => 0, + }; + + private static Atrac9DecodeResult DecodeAtrac9( + CpuContext ctx, + AjmInstanceState instance, + ulong inputAddress, + ulong inputSize, + ulong outputAddress, + ulong outputSize, + bool multipleFrames) + { + if ((inputSize != 0 && inputAddress == 0) || + (outputSize != 0 && outputAddress == 0) || + inputSize > MaxDecodeBufferBytes || + outputSize > MaxDecodeBufferBytes) + { + return new Atrac9DecodeResult( + Atrac9DecodeState.ResultInvalidParameter, + 0, + 0, + 0, + 0); + } + + var inputLength = unchecked((int)inputSize); + var outputLength = unchecked((int)outputSize); + var input = ArrayPool.Shared.Rent(Math.Max(inputLength, 1)); + var output = ArrayPool.Shared.Rent(Math.Max(outputLength, 1)); + try + { + if (inputLength != 0 && + !ctx.Memory.TryRead(inputAddress, input.AsSpan(0, inputLength))) + { + return new Atrac9DecodeResult( + Atrac9DecodeState.ResultInvalidParameter, + 0, + 0, + 0, + 0); + } + + var result = instance.Atrac9!.Decode( + input.AsSpan(0, inputLength), + output.AsSpan(0, outputLength), + instance.Encoding, + requestedChannels: 0, + multipleFrames); + + if (result.OutputWritten != 0 && + !ctx.Memory.TryWrite(outputAddress, output.AsSpan(0, result.OutputWritten))) + { + return result with + { + Status = result.Status | Atrac9DecodeState.ResultInvalidParameter, + OutputWritten = 0, + }; + } + + return result; + } + finally + { + ArrayPool.Shared.Return(input); + ArrayPool.Shared.Return(output); + } + } + + private static Atrac9PcmEncoding GetPcmEncoding(ulong flags) => + ((flags >> 7) & 0x7) switch + { + 1 => Atrac9PcmEncoding.Signed32, + 2 => Atrac9PcmEncoding.Float, + _ => Atrac9PcmEncoding.Signed16, + }; + internal static void ResetForTests() { Contexts.Clear(); @@ -466,14 +1234,24 @@ public static class AjmExports } // AjmBatchInfo: buffer, offset, size, last_good_job, last_good_job_ra (5× u64). + private const int AjmBatchInfoBytes = 40; private const ulong AjmBatchInfoOffsetField = 8; private const ulong AjmBatchInfoSizeField = 16; private const ulong AjmBatchInfoLastGoodJobField = 24; + private const ulong AjmBatchInfoLastGoodJobRaField = 32; + private const ulong AjmJobControlSize = 48; private const ulong AjmJobRunSize = 64; - private const int OrbisAjmErrorJobCreation = unchecked((int)0x80930012); - private const ulong MaxSilentPcmBytes = 1 << 20; + private const ulong AjmJobGetStatisticsSize = 88; + private const int AjmStatisticsResultBytes = 48; + private const int AjmSidebandResultBytes = 8; + private const int AjmSidebandStreamBytes = 16; + private const int AjmSidebandFormatBytes = 24; + private const int AjmSidebandGaplessDecodeBytes = 8; + private const int AjmSidebandMFrameBytes = 8; + private const int AjmSidebandCodecInfoBytes = 64; // AjmSidebandResult (8) + AjmSidebandStream (16) + AjmSidebandMFrame (8). - private const int DecodeSidebandBytes = 32; + private const int DecodeSidebandBytes = + AjmSidebandResultBytes + AjmSidebandStreamBytes + AjmSidebandMFrameBytes; private static bool TryAppendBatchJob(CpuContext ctx, ulong infoAddress, ulong jobSize) { @@ -492,6 +1270,7 @@ public static class AjmExports var jobAddress = buffer + offset; ClearGuestMemory(ctx, jobAddress, jobSize); return TryWriteUInt64(ctx, infoAddress + AjmBatchInfoLastGoodJobField, jobAddress) && + TryWriteUInt64(ctx, infoAddress + AjmBatchInfoLastGoodJobRaField, 0) && TryWriteUInt64(ctx, infoAddress + AjmBatchInfoOffsetField, offset + jobSize); } @@ -513,10 +1292,8 @@ public static class AjmExports private static void WriteDecodeStreamResult( CpuContext ctx, ulong resultAddress, - int inputConsumed, - int outputWritten, - ulong totalDecodedSamples, - uint frames) + Atrac9DecodeResult result, + bool multipleFrames) { if (resultAddress == 0) { @@ -525,12 +1302,31 @@ public static class AjmExports Span sideband = stackalloc byte[DecodeSidebandBytes]; sideband.Clear(); - // AjmSidebandResult.result / internal_result = 0 (OK) - BinaryPrimitives.WriteInt32LittleEndian(sideband.Slice(8, 4), inputConsumed); - BinaryPrimitives.WriteInt32LittleEndian(sideband.Slice(12, 4), outputWritten); - BinaryPrimitives.WriteUInt64LittleEndian(sideband.Slice(16, 8), totalDecodedSamples); - BinaryPrimitives.WriteUInt32LittleEndian(sideband.Slice(24, 4), frames); - _ = ctx.Memory.TryWrite(resultAddress, sideband); + BinaryPrimitives.WriteInt32LittleEndian(sideband, result.Status); + BinaryPrimitives.WriteInt32LittleEndian(sideband[8..], result.InputConsumed); + BinaryPrimitives.WriteInt32LittleEndian(sideband[12..], result.OutputWritten); + BinaryPrimitives.WriteUInt64LittleEndian(sideband[16..], result.TotalDecodedSamples); + if (multipleFrames) + { + BinaryPrimitives.WriteUInt32LittleEndian(sideband[24..], result.Frames); + } + + _ = ctx.Memory.TryWrite( + resultAddress, + multipleFrames ? sideband : sideband[..24]); + } + + private static void WriteBasicResult(CpuContext ctx, ulong resultAddress, int status) + { + if (resultAddress == 0) + { + return; + } + + Span result = stackalloc byte[8]; + result.Clear(); + BinaryPrimitives.WriteInt32LittleEndian(result, status); + _ = ctx.Memory.TryWrite(resultAddress, result); } private static void ClearGuestMemory(CpuContext ctx, ulong address, ulong byteCount) diff --git a/src/SharpEmu.Libs/Audio/Atrac9DecodeState.cs b/src/SharpEmu.Libs/Audio/Atrac9DecodeState.cs new file mode 100644 index 00000000..6f02d3bc --- /dev/null +++ b/src/SharpEmu.Libs/Audio/Atrac9DecodeState.cs @@ -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 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 input, + Span 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 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 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 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; + } +} diff --git a/src/SharpEmu.Libs/Audio/AudioOutExports.cs b/src/SharpEmu.Libs/Audio/AudioOutExports.cs index 85bed025..cf13faa1 100644 --- a/src/SharpEmu.Libs/Audio/AudioOutExports.cs +++ b/src/SharpEmu.Libs/Audio/AudioOutExports.cs @@ -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.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.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 source, Span 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 source) { if (!_traceOutput) diff --git a/src/SharpEmu.Libs/Audio/AudioPcmConversion.cs b/src/SharpEmu.Libs/Audio/AudioPcmConversion.cs index 6294402f..f560409e 100644 --- a/src/SharpEmu.Libs/Audio/AudioPcmConversion.cs +++ b/src/SharpEmu.Libs/Audio/AudioPcmConversion.cs @@ -43,6 +43,46 @@ internal static class AudioPcmConversion } } + /// + /// 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. + /// + public static void CopyWithVolume( + ReadOnlySpan source, + Span 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 frame, int channel, diff --git a/src/SharpEmu.Libs/Codec/CodecExports.cs b/src/SharpEmu.Libs/Codec/CodecExports.cs index fbf85acd..b93a5381 100644 --- a/src/SharpEmu.Libs/Codec/CodecExports.cs +++ b/src/SharpEmu.Libs/Codec/CodecExports.cs @@ -2,28 +2,84 @@ // SPDX-License-Identifier: GPL-2.0-or-later using SharpEmu.HLE; +using SharpEmu.Libs.Audio; using SharpEmu.Libs.Kernel; +using System.Buffers; +using System.Buffers.Binary; using System.Collections.Concurrent; using System.Threading; namespace SharpEmu.Libs.Codec; /// -/// libSceVideodec / libSceAudiodec handle management. Actual H.264/HEVC and -/// AAC/AT9 decoding requires an external codec, which is out of scope; these -/// exports keep the decoder lifecycle resolvable (create/decode/flush/delete) -/// and report "no output produced" so guests advance instead of failing on -/// unresolved imports. +/// libSceVideodec / libSceAudiodec compatibility exports. /// public static class CodecExports { private const int Ok = 0; private const int VideodecErrorInvalidArg = unchecked((int)0x80620801); + private const int AudiodecErrorInvalidType = unchecked((int)0x807F0001); private const int AudiodecErrorInvalidArg = unchecked((int)0x807F0002); + private const int AudiodecErrorInvalidParamSize = unchecked((int)0x807F0004); + private const int AudiodecErrorInvalidBsiInfoSize = unchecked((int)0x807F0005); + private const int AudiodecErrorInvalidAuInfoSize = unchecked((int)0x807F0006); + private const int AudiodecErrorInvalidPcmItemSize = unchecked((int)0x807F0007); + private const int AudiodecErrorInvalidCtrlPointer = unchecked((int)0x807F0008); + private const int AudiodecErrorInvalidParamPointer = unchecked((int)0x807F0009); + private const int AudiodecErrorInvalidBsiInfoPointer = unchecked((int)0x807F000A); + private const int AudiodecErrorInvalidAuInfoPointer = unchecked((int)0x807F000B); + private const int AudiodecErrorInvalidPcmItemPointer = unchecked((int)0x807F000C); + private const int AudiodecErrorInvalidAuPointer = unchecked((int)0x807F000D); + private const int AudiodecErrorInvalidPcmPointer = unchecked((int)0x807F000E); + private const int AudiodecErrorInvalidHandle = unchecked((int)0x807F000F); + private const int AudiodecErrorInvalidWordLength = unchecked((int)0x807F0010); + private const int AudiodecErrorInvalidAuSize = unchecked((int)0x807F0011); + private const int AudiodecErrorInvalidPcmSize = unchecked((int)0x807F0012); + private const uint AudiodecTypeAt9 = 1; + private const uint AudiodecTypeMp3 = 2; + private const uint AudiodecTypeAac = 3; + private const int MaxAudioDecoders = 64; + private const int MaxDecodeBufferBytes = 64 * 1024 * 1024; private static readonly ConcurrentDictionary VideoDecoders = new(); - private static readonly ConcurrentDictionary AudioDecoders = new(); + private static readonly ConcurrentDictionary AudioDecoders = new(); + private static readonly ConcurrentDictionary AudioCodecInitCounts = new(); + private static readonly object AudioDecoderGate = new(); private static long _nextHandle = 1; + private static int _nextAudioHandle; + + private sealed class AudioDecoderState + { + public required uint CodecType { get; init; } + + public required int WordSize { get; init; } + + public required int Channels { get; init; } + + public required int SampleRate { get; init; } + + public required int FrameBytes { get; init; } + + public required int FramesPerSuperframe { get; init; } + + public required int FrameSamples { get; init; } + + public Atrac9DecodeState? Atrac9 { get; init; } + } + + private readonly record struct AudioControl( + ulong ParamAddress, + ulong BsiInfoAddress, + ulong AuInfoAddress, + ulong PcmItemAddress, + ulong AuAddress, + uint AuSize, + ulong PcmAddress, + uint PcmSize, + int WordSize, + byte[]? Atrac9Config, + uint AacMaxChannels, + uint AacSampleRateIndex); // ---- Video decoder ---- @@ -65,34 +121,572 @@ public static class CodecExports // ---- Audio decoder ---- + [SysAbiExport(Nid = "VjhsmxpcezI", ExportName = "sceAudiodecInitLibrary", + Target = Generation.Gen4 | Generation.Gen5, LibraryName = "libSceAudiodec")] + public static int AudiodecInitLibrary(CpuContext ctx) + { + var codecType = unchecked((uint)ctx[CpuRegister.Rdi]); + if (!IsValidAudioCodecType(codecType)) + { + return SetReturn(ctx, AudiodecErrorInvalidType); + } + + AudioCodecInitCounts.AddOrUpdate(codecType, 1, static (_, count) => count + 1); + return SetReturn(ctx, Ok); + } + + [SysAbiExport(Nid = "h5jSB2QIDV0", ExportName = "sceAudiodecTermLibrary", + Target = Generation.Gen4 | Generation.Gen5, LibraryName = "libSceAudiodec")] + public static int AudiodecTermLibrary(CpuContext ctx) + { + var codecType = unchecked((uint)ctx[CpuRegister.Rdi]); + if (!IsValidAudioCodecType(codecType)) + { + return SetReturn(ctx, AudiodecErrorInvalidType); + } + + AudioCodecInitCounts.AddOrUpdate(codecType, 0, static (_, count) => Math.Max(0, count - 1)); + return SetReturn(ctx, Ok); + } + [SysAbiExport(Nid = "O3f1sLMWRvs", ExportName = "sceAudiodecCreateDecoder", Target = Generation.Gen4 | Generation.Gen5, LibraryName = "libSceAudiodec")] public static int AudiodecCreateDecoder(CpuContext ctx) { - var handle = (ulong)Interlocked.Increment(ref _nextHandle); - AudioDecoders[handle] = 1; - // sceAudiodec returns the handle directly (>= 0) or a negative error. - ctx[CpuRegister.Rax] = handle; - return unchecked((int)handle); + var controlAddress = ctx[CpuRegister.Rdi]; + var codecType = unchecked((uint)ctx[CpuRegister.Rsi]); + if (!IsValidAudioCodecType(codecType)) + { + return SetReturn(ctx, AudiodecErrorInvalidType); + } + + var validation = TryReadAudioControl(ctx, controlAddress, codecType, decode: false, out var control); + if (validation != Ok) + { + return SetReturn(ctx, validation); + } + + if (!AudioCodecInitCounts.TryGetValue(codecType, out var initCount) || initCount == 0) + { + return SetReturn(ctx, AudiodecErrorInvalidArg); + } + + var decoder = CreateAudioDecoder(codecType, control); + if (decoder is null) + { + return SetReturn(ctx, AudiodecErrorInvalidArg); + } + + int handle; + lock (AudioDecoderGate) + { + if (AudioDecoders.Count >= MaxAudioDecoders) + { + return SetReturn(ctx, AudiodecErrorInvalidArg); + } + + do + { + _nextAudioHandle = _nextAudioHandle % MaxAudioDecoders + 1; + handle = _nextAudioHandle; + } + while (AudioDecoders.ContainsKey(handle)); + + AudioDecoders[handle] = decoder; + } + + WriteAudioDecoderInfo(ctx, control.BsiInfoAddress, decoder, Ok); + return SetReturn(ctx, handle); } [SysAbiExport(Nid = "KHXHMDLkILw", ExportName = "sceAudiodecDecode", Target = Generation.Gen4 | Generation.Gen5, LibraryName = "libSceAudiodec")] public static int AudiodecDecode(CpuContext ctx) { - // No decoder present: report success with zero output samples so the - // caller treats the frame as silent rather than erroring. - return SetReturn(ctx, AudioDecoders.ContainsKey(ctx[CpuRegister.Rdi]) ? Ok : AudiodecErrorInvalidArg); + var handle = unchecked((int)ctx[CpuRegister.Rdi]); + if (!AudioDecoders.TryGetValue(handle, out var decoder)) + { + return SetReturn(ctx, AudiodecErrorInvalidHandle); + } + + var validation = TryReadAudioControl( + ctx, + ctx[CpuRegister.Rsi], + decoder.CodecType, + decode: true, + out var control); + if (validation != Ok) + { + return SetReturn(ctx, validation); + } + + if (control.AuSize > MaxDecodeBufferBytes) + { + return SetReturn(ctx, AudiodecErrorInvalidAuSize); + } + + if (control.PcmSize > MaxDecodeBufferBytes) + { + return SetReturn(ctx, AudiodecErrorInvalidPcmSize); + } + + if (decoder.CodecType == AudiodecTypeAt9 && decoder.Atrac9 is not null) + { + DecodeAtrac9(ctx, decoder, control); + } + else + { + DecodeAudioSilence(ctx, decoder, control); + } + + return SetReturn(ctx, Ok); } [SysAbiExport(Nid = "Tp+ZEy69mLk", ExportName = "sceAudiodecDeleteDecoder", Target = Generation.Gen4 | Generation.Gen5, LibraryName = "libSceAudiodec")] public static int AudiodecDeleteDecoder(CpuContext ctx) { - AudioDecoders.TryRemove(ctx[CpuRegister.Rdi], out _); + var handle = unchecked((int)ctx[CpuRegister.Rdi]); + return SetReturn( + ctx, + AudioDecoders.TryRemove(handle, out _) + ? Ok + : AudiodecErrorInvalidHandle); + } + + [SysAbiExport(Nid = "6Vf9WTLDoss", ExportName = "sceAudiodecClearContext", + Target = Generation.Gen4 | Generation.Gen5, LibraryName = "libSceAudiodec")] + public static int AudiodecClearContext(CpuContext ctx) + { + var handle = unchecked((int)ctx[CpuRegister.Rdi]); + if (!AudioDecoders.TryGetValue(handle, out var decoder)) + { + return SetReturn(ctx, AudiodecErrorInvalidHandle); + } + + decoder.Atrac9?.Reset(); return SetReturn(ctx, Ok); } + private static bool IsValidAudioCodecType(uint codecType) => + codecType is AudiodecTypeAt9 or AudiodecTypeMp3 or AudiodecTypeAac; + + private static int TryReadAudioControl( + CpuContext ctx, + ulong controlAddress, + uint codecType, + bool decode, + out AudioControl control) + { + control = default; + if (controlAddress == 0) + { + return AudiodecErrorInvalidCtrlPointer; + } + + Span controlData = stackalloc byte[32]; + if (!ctx.Memory.TryRead(controlAddress, controlData)) + { + return AudiodecErrorInvalidCtrlPointer; + } + + var paramAddress = BinaryPrimitives.ReadUInt64LittleEndian(controlData); + var bsiInfoAddress = BinaryPrimitives.ReadUInt64LittleEndian(controlData[8..]); + var auInfoAddress = BinaryPrimitives.ReadUInt64LittleEndian(controlData[16..]); + var pcmItemAddress = BinaryPrimitives.ReadUInt64LittleEndian(controlData[24..]); + if (paramAddress == 0) + { + return AudiodecErrorInvalidParamPointer; + } + + if (bsiInfoAddress == 0) + { + return AudiodecErrorInvalidBsiInfoPointer; + } + + if (auInfoAddress == 0) + { + return AudiodecErrorInvalidAuInfoPointer; + } + + if (pcmItemAddress == 0) + { + return AudiodecErrorInvalidPcmItemPointer; + } + + Span auInfo = stackalloc byte[24]; + if (!ctx.Memory.TryRead(auInfoAddress, auInfo)) + { + return AudiodecErrorInvalidAuInfoPointer; + } + + if (BinaryPrimitives.ReadUInt32LittleEndian(auInfo) != auInfo.Length) + { + return AudiodecErrorInvalidAuInfoSize; + } + + Span pcmItem = stackalloc byte[24]; + if (!ctx.Memory.TryRead(pcmItemAddress, pcmItem)) + { + return AudiodecErrorInvalidPcmItemPointer; + } + + if (BinaryPrimitives.ReadUInt32LittleEndian(pcmItem) != pcmItem.Length) + { + return AudiodecErrorInvalidPcmItemSize; + } + + var auAddress = BinaryPrimitives.ReadUInt64LittleEndian(auInfo[8..]); + var auSize = BinaryPrimitives.ReadUInt32LittleEndian(auInfo[16..]); + var pcmAddress = BinaryPrimitives.ReadUInt64LittleEndian(pcmItem[8..]); + var pcmSize = BinaryPrimitives.ReadUInt32LittleEndian(pcmItem[16..]); + if (decode && auAddress == 0) + { + return AudiodecErrorInvalidAuPointer; + } + + if (decode && pcmAddress == 0) + { + return AudiodecErrorInvalidPcmPointer; + } + + var parameterResult = TryReadAudioParameters( + ctx, + codecType, + paramAddress, + bsiInfoAddress, + out var wordSize, + out var atrac9Config, + out var aacMaxChannels, + out var aacSampleRateIndex); + if (parameterResult != Ok) + { + return parameterResult; + } + + if (decode && auSize == 0) + { + return AudiodecErrorInvalidAuSize; + } + + if (decode && pcmSize == 0) + { + return AudiodecErrorInvalidPcmSize; + } + + control = new AudioControl( + paramAddress, + bsiInfoAddress, + auInfoAddress, + pcmItemAddress, + auAddress, + auSize, + pcmAddress, + pcmSize, + wordSize, + atrac9Config, + aacMaxChannels, + aacSampleRateIndex); + return Ok; + } + + private static int TryReadAudioParameters( + CpuContext ctx, + uint codecType, + ulong paramAddress, + ulong bsiInfoAddress, + out int wordSize, + out byte[]? atrac9Config, + out uint aacMaxChannels, + out uint aacSampleRateIndex) + { + wordSize = 0; + atrac9Config = null; + aacMaxChannels = 0; + aacSampleRateIndex = 0; + + var paramSize = codecType switch + { + AudiodecTypeAt9 => 12, + AudiodecTypeMp3 => 8, + AudiodecTypeAac => 24, + _ => 0, + }; + var bsiSize = codecType switch + { + AudiodecTypeAt9 => 36, + AudiodecTypeMp3 => 24, + AudiodecTypeAac => 20, + _ => 0, + }; + if (paramSize == 0 || bsiSize == 0) + { + return AudiodecErrorInvalidType; + } + + Span param = stackalloc byte[paramSize]; + if (!ctx.Memory.TryRead(paramAddress, param)) + { + return AudiodecErrorInvalidParamPointer; + } + + var suppliedParamSize = BinaryPrimitives.ReadUInt32LittleEndian(param); + if (codecType == AudiodecTypeAac + ? suppliedParamSize < paramSize + : suppliedParamSize != paramSize) + { + return AudiodecErrorInvalidParamSize; + } + + Span bsi = stackalloc byte[bsiSize]; + if (!ctx.Memory.TryRead(bsiInfoAddress, bsi)) + { + return AudiodecErrorInvalidBsiInfoPointer; + } + + if (BinaryPrimitives.ReadUInt32LittleEndian(bsi) != bsiSize) + { + return AudiodecErrorInvalidBsiInfoSize; + } + + wordSize = BinaryPrimitives.ReadInt32LittleEndian(param[4..]); + if (wordSize is < 0 or > 2) + { + return AudiodecErrorInvalidWordLength; + } + + if (codecType == AudiodecTypeAt9) + { + atrac9Config = param[8..12].ToArray(); + } + else if (codecType == AudiodecTypeAac) + { + aacSampleRateIndex = BinaryPrimitives.ReadUInt32LittleEndian(param[12..]); + aacMaxChannels = BinaryPrimitives.ReadUInt32LittleEndian(param[16..]); + } + + return Ok; + } + + private static AudioDecoderState? CreateAudioDecoder(uint codecType, AudioControl control) + { + if (codecType == AudiodecTypeAt9) + { + var atrac9 = new Atrac9DecodeState(); + if (control.Atrac9Config is null || !atrac9.TryInitialize(control.Atrac9Config)) + { + return null; + } + + var config = atrac9.Config!; + return new AudioDecoderState + { + CodecType = codecType, + WordSize = control.WordSize, + Channels = config.ChannelCount, + SampleRate = config.SampleRate, + FrameBytes = config.SuperframeBytes, + FramesPerSuperframe = config.FramesPerSuperframe, + FrameSamples = config.FrameSamples, + Atrac9 = atrac9, + }; + } + + if (codecType == AudiodecTypeMp3) + { + return new AudioDecoderState + { + CodecType = codecType, + WordSize = control.WordSize, + Channels = 2, + SampleRate = 48_000, + FrameBytes = 1_441, + FramesPerSuperframe = 1, + FrameSamples = 1_152, + }; + } + + var channels = control.AacMaxChannels == 0 + ? 2 + : unchecked((int)Math.Min(control.AacMaxChannels, 8)); + return new AudioDecoderState + { + CodecType = codecType, + WordSize = control.WordSize, + Channels = channels, + SampleRate = GetAacSampleRate(control.AacSampleRateIndex), + FrameBytes = 4_608, + FramesPerSuperframe = 1, + FrameSamples = 2_048, + }; + } + + private static void DecodeAtrac9(CpuContext ctx, AudioDecoderState decoder, AudioControl control) + { + var input = ArrayPool.Shared.Rent(checked((int)control.AuSize)); + var output = ArrayPool.Shared.Rent(checked((int)control.PcmSize)); + try + { + if (!ctx.Memory.TryRead(control.AuAddress, input.AsSpan(0, checked((int)control.AuSize)))) + { + WriteAudioDecoderInfo(ctx, control.BsiInfoAddress, decoder, AudiodecErrorInvalidAuPointer); + WriteAudioBufferSizes(ctx, control, 0, 0); + return; + } + + var result = decoder.Atrac9!.Decode( + input.AsSpan(0, checked((int)control.AuSize)), + output.AsSpan(0, checked((int)control.PcmSize)), + GetAtrac9Encoding(decoder.WordSize), + decoder.Channels, + multipleFrames: false); + + var outputWritten = result.OutputWritten; + if (outputWritten != 0 && + !ctx.Memory.TryWrite(control.PcmAddress, output.AsSpan(0, outputWritten))) + { + outputWritten = 0; + } + + WriteAudioBufferSizes( + ctx, + control, + unchecked((uint)result.InputConsumed), + unchecked((uint)outputWritten)); + WriteAudioDecoderInfo(ctx, control.BsiInfoAddress, decoder, result.Status); + } + finally + { + ArrayPool.Shared.Return(input); + ArrayPool.Shared.Return(output); + } + } + + private static void DecodeAudioSilence(CpuContext ctx, AudioDecoderState decoder, AudioControl control) + { + var wantedPcm = checked( + decoder.FrameSamples * + decoder.Channels * + GetAudioBytesPerSample(decoder.WordSize)); + var outputSize = Math.Min(control.PcmSize, unchecked((uint)wantedPcm)); + ClearGuestMemory(ctx, control.PcmAddress, outputSize); + WriteAudioBufferSizes( + ctx, + control, + Math.Min(control.AuSize, unchecked((uint)decoder.FrameBytes)), + outputSize); + WriteAudioDecoderInfo(ctx, control.BsiInfoAddress, decoder, Ok); + } + + private static void WriteAudioBufferSizes( + CpuContext ctx, + AudioControl control, + uint inputConsumed, + uint outputWritten) + { + Span value = stackalloc byte[sizeof(uint)]; + BinaryPrimitives.WriteUInt32LittleEndian(value, inputConsumed); + _ = ctx.Memory.TryWrite(control.AuInfoAddress + 16, value); + BinaryPrimitives.WriteUInt32LittleEndian(value, outputWritten); + _ = ctx.Memory.TryWrite(control.PcmItemAddress + 16, value); + } + + private static void WriteAudioDecoderInfo( + CpuContext ctx, + ulong infoAddress, + AudioDecoderState decoder, + int result) + { + if (decoder.CodecType == AudiodecTypeAt9) + { + Span info = stackalloc byte[36]; + info.Clear(); + BinaryPrimitives.WriteUInt32LittleEndian(info, unchecked((uint)info.Length)); + BinaryPrimitives.WriteUInt32LittleEndian(info[4..], unchecked((uint)decoder.Channels)); + var superframeSamples = checked(decoder.FrameSamples * decoder.FramesPerSuperframe); + var bitrate = superframeSamples == 0 + ? 0u + : unchecked((uint)((ulong)decoder.FrameBytes * 8 * (uint)decoder.SampleRate / + (uint)superframeSamples)); + BinaryPrimitives.WriteUInt32LittleEndian(info[8..], bitrate); + BinaryPrimitives.WriteUInt32LittleEndian(info[12..], unchecked((uint)decoder.SampleRate)); + BinaryPrimitives.WriteUInt32LittleEndian(info[16..], unchecked((uint)decoder.FrameBytes)); + BinaryPrimitives.WriteUInt32LittleEndian(info[20..], unchecked((uint)decoder.FramesPerSuperframe)); + BinaryPrimitives.WriteUInt32LittleEndian( + info[24..], + unchecked((uint)(decoder.FrameBytes / Math.Max(decoder.FramesPerSuperframe, 1)))); + BinaryPrimitives.WriteUInt32LittleEndian(info[28..], unchecked((uint)decoder.FrameSamples)); + BinaryPrimitives.WriteInt32LittleEndian(info[32..], result); + _ = ctx.Memory.TryWrite(infoAddress, info); + return; + } + + if (decoder.CodecType == AudiodecTypeMp3) + { + Span info = stackalloc byte[24]; + info.Clear(); + BinaryPrimitives.WriteUInt32LittleEndian(info, unchecked((uint)info.Length)); + BinaryPrimitives.WriteInt32LittleEndian(info[20..], result); + _ = ctx.Memory.TryWrite(infoAddress, info); + return; + } + + Span aacInfo = stackalloc byte[20]; + aacInfo.Clear(); + BinaryPrimitives.WriteUInt32LittleEndian(aacInfo, unchecked((uint)aacInfo.Length)); + BinaryPrimitives.WriteUInt32LittleEndian(aacInfo[4..], unchecked((uint)decoder.SampleRate)); + BinaryPrimitives.WriteUInt32LittleEndian(aacInfo[8..], unchecked((uint)decoder.Channels)); + BinaryPrimitives.WriteInt32LittleEndian(aacInfo[16..], result); + _ = ctx.Memory.TryWrite(infoAddress, aacInfo); + } + + private static Atrac9PcmEncoding GetAtrac9Encoding(int wordSize) => + wordSize switch + { + 0 => Atrac9PcmEncoding.Signed32, + 1 => Atrac9PcmEncoding.Signed16, + 2 => Atrac9PcmEncoding.Float, + _ => throw new ArgumentOutOfRangeException(nameof(wordSize)), + }; + + private static int GetAudioBytesPerSample(int wordSize) => + wordSize == 1 ? sizeof(short) : sizeof(int); + + private static int GetAacSampleRate(uint index) + { + ReadOnlySpan rates = + [ + 96_000, 88_200, 64_000, 48_000, 44_100, 32_000, + 24_000, 22_050, 16_000, 12_000, 11_025, 8_000, + ]; + return index < rates.Length ? rates[unchecked((int)index)] : 48_000; + } + + private static void ClearGuestMemory(CpuContext ctx, ulong address, uint byteCount) + { + Span zero = stackalloc byte[256]; + var cursor = address; + var remaining = byteCount; + while (remaining != 0) + { + var length = unchecked((int)Math.Min(remaining, (uint)zero.Length)); + if (!ctx.Memory.TryWrite(cursor, zero[..length])) + { + return; + } + + cursor += unchecked((uint)length); + remaining -= unchecked((uint)length); + } + } + + internal static void ResetAudioDecodersForTests() + { + AudioDecoders.Clear(); + AudioCodecInitCounts.Clear(); + Interlocked.Exchange(ref _nextAudioHandle, 0); + } + private static bool TryWriteHandle(CpuContext ctx, ulong address, ulong handle) => ctx.TryWriteUInt64(address, handle); diff --git a/src/SharpEmu.Libs/Ngs2/Ngs2Exports.cs b/src/SharpEmu.Libs/Ngs2/Ngs2Exports.cs index 19ed2b48..41a3b26e 100644 --- a/src/SharpEmu.Libs/Ngs2/Ngs2Exports.cs +++ b/src/SharpEmu.Libs/Ngs2/Ngs2Exports.cs @@ -30,7 +30,7 @@ public static class Ngs2Exports // The grain length defaults to 256 frames (matching the 8192-byte AudioOut // buffers games copy it into) until the title overrides it. private const int DefaultGrainSamples = 256; - private const double OutputSampleRate = 48000.0; + private const int DefaultSampleRate = 48000; private sealed class SystemState { @@ -38,6 +38,7 @@ public static class Ngs2Exports public uint Uid { get; } public int GrainSamples { get; set; } = DefaultGrainSamples; + public int SampleRate { get; set; } = DefaultSampleRate; } private sealed record RackState(ulong SystemHandle, uint RackId); @@ -496,6 +497,7 @@ public static class Ngs2Exports return SetReturn(ctx, (int)OrbisGen2Result.ORBIS_GEN2_ERROR_INVALID_ARGUMENT); } + Span renderBufferInfo = stackalloc byte[RenderBufferInfoSize]; for (uint i = 0; i < bufferInfoCount; i++) { var entryAddress = bufferInfoAddress + (i * RenderBufferInfoSize); @@ -527,10 +529,9 @@ public static class Ngs2Exports if (ShouldTrace() && Interlocked.Increment(ref _renderInfoDumps) <= 4) { - Span rbi = stackalloc byte[RenderBufferInfoSize]; - ctx.Memory.TryRead(entryAddress, rbi); + ctx.Memory.TryRead(entryAddress, renderBufferInfo); Console.Error.WriteLine( - $"[LOADER][TRACE] ngs2.renderbufinfo addr=0x{bufferAddress:X} size={bufferSize} ch={channels} raw={Convert.ToHexString(rbi)}"); + $"[LOADER][TRACE] ngs2.renderbufinfo addr=0x{bufferAddress:X} size={bufferSize} ch={channels} raw={Convert.ToHexString(renderBufferInfo)}"); } } } @@ -552,6 +553,7 @@ public static class Ngs2Exports CpuContext ctx, ulong systemHandle, ulong bufferAddress, ulong bufferSize, int channels) { int grain; + int sampleRate; lock (StateGate) { if (!Systems.TryGetValue(systemHandle, out var system)) @@ -560,6 +562,7 @@ public static class Ngs2Exports } grain = system.GrainSamples; + sampleRate = system.SampleRate; } var capacityFrames = (int)Math.Min((ulong)grain, bufferSize / (ulong)(channels * sizeof(float))); @@ -590,7 +593,7 @@ public static class Ngs2Exports continue; } - MixOneVoice(accum, capacityFrames, channels, voice); + MixOneVoice(accum, capacityFrames, channels, sampleRate, voice); mixedAnything = true; } } @@ -606,15 +609,20 @@ public static class Ngs2Exports } } - // Resample one voice from its source rate to 48 kHz (nearest-sample) and add + // Resample one voice to the system rate and add // it to the front stereo pair. Advances the voice cursor and handles loop / // one-shot end. Must be called under StateGate. - private static void MixOneVoice(float[] accum, int frames, int channels, VoiceState voice) + private static void MixOneVoice( + float[] accum, + int frames, + int channels, + int outputSampleRate, + VoiceState voice) { var pcm = voice.Pcm!; var loopEnd = voice.LoopEnd > 0 && voice.LoopEnd <= pcm.Length ? voice.LoopEnd : pcm.Length; var loopStart = voice.LoopStart; - var step = voice.SourceRate / OutputSampleRate; + var step = voice.SourceRate / (double)outputSampleRate; var gain = voice.Gain / 32768f; var pos = voice.Position; for (var f = 0; f < frames; f++) @@ -640,7 +648,14 @@ public static class Ngs2Exports break; } - var sample = pcm[idx] * gain; + var next = idx + 1; + if (next >= loopEnd) + { + next = loopStart >= 0 && loopStart < loopEnd ? loopStart : idx; + } + + var fraction = pos - idx; + var sample = (float)((pcm[idx] + ((pcm[next] - pcm[idx]) * fraction)) * gain); var baseIndex = f * channels; accum[baseIndex] += sample; if (channels > 1) @@ -736,7 +751,27 @@ public static class Ngs2Exports ExportName = "sceNgs2SystemSetSampleRate", Target = Generation.Gen4 | Generation.Gen5, LibraryName = "libSceNgs2")] - public static int Ngs2SystemSetSampleRate(CpuContext ctx) => ValidateSystem(ctx); + public static int Ngs2SystemSetSampleRate(CpuContext ctx) + { + var systemHandle = ctx[CpuRegister.Rdi]; + var sampleRate = unchecked((int)ctx[CpuRegister.Rsi]); + lock (StateGate) + { + if (!Systems.TryGetValue(systemHandle, out var system)) + { + return SetReturn(ctx, OrbisNgs2ErrorInvalidSystemHandle); + } + + if (sampleRate is < 8000 or > 192000) + { + return SetReturn(ctx, (int)OrbisGen2Result.ORBIS_GEN2_ERROR_INVALID_ARGUMENT); + } + + system.SampleRate = sampleRate; + } + + return SetReturn(ctx, 0); + } [SysAbiExport( Nid = "gThZqM5PYlQ", diff --git a/tests/SharpEmu.Libs.Tests/Audio/AcmExportsTests.cs b/tests/SharpEmu.Libs.Tests/Audio/AcmExportsTests.cs new file mode 100644 index 00000000..12e57c08 --- /dev/null +++ b/tests/SharpEmu.Libs.Tests/Audio/AcmExportsTests.cs @@ -0,0 +1,130 @@ +// Copyright (C) 2026 SharpEmu Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +using System.Buffers.Binary; +using SharpEmu.HLE; +using SharpEmu.Libs.Acm; +using Xunit; + +namespace SharpEmu.Libs.Tests.Audio; + +[CollectionDefinition("AcmState", DisableParallelization = true)] +public sealed class AcmStateCollection +{ + public const string Name = "AcmState"; +} + +[Collection(AcmStateCollection.Name)] +public sealed class AcmExportsTests : IDisposable +{ + private const ulong MemoryBase = 0x1_1000_0000; + private const ulong ContextAddress = MemoryBase + 0x100; + private const ulong InfoArrayAddress = MemoryBase + 0x200; + private const ulong ErrorAddress = MemoryBase + 0x300; + private const ulong BatchAddress = MemoryBase + 0x400; + + private readonly FakeCpuMemory _memory = new(MemoryBase, 0x1000); + private readonly CpuContext _ctx; + + public AcmExportsTests() + { + AcmExports.ResetForTests(); + _ctx = new CpuContext(_memory, Generation.Gen5); + } + + [Fact] + public void ContextAndBatchLifecycle_UsesFourByteHandlesAndClearsErrors() + { + Span contextSentinel = stackalloc byte[8]; + contextSentinel.Fill(0xCC); + Assert.True(_memory.TryWrite(ContextAddress, contextSentinel)); + + _ctx[CpuRegister.Rdi] = ContextAddress; + Assert.Equal(0, AcmExports.AcmContextCreate(_ctx)); + var context = ReadUInt32(ContextAddress); + Assert.Equal(1u, context); + Assert.Equal(0xCCCCCCCCu, ReadUInt32(ContextAddress + 4)); + + Span errorSentinel = stackalloc byte[32]; + errorSentinel.Fill(0xCC); + Assert.True(_memory.TryWrite(ErrorAddress, errorSentinel)); + WriteUInt64(InfoArrayAddress, MemoryBase + 0x500); + + _ctx[CpuRegister.Rdi] = context; + _ctx[CpuRegister.Rsi] = 1; + _ctx[CpuRegister.Rdx] = InfoArrayAddress; + _ctx[CpuRegister.Rcx] = ErrorAddress; + _ctx[CpuRegister.R8] = BatchAddress; + Assert.Equal(0, AcmExports.AcmBatchStartBuffers(_ctx)); + Assert.Equal(1u, ReadUInt32(BatchAddress)); + Assert.All(ReadBytes(ErrorAddress, 32), value => Assert.Equal(0, value)); + + _ctx[CpuRegister.Rdi] = context; + _ctx[CpuRegister.Rsi] = 1; + _ctx[CpuRegister.Rdx] = 0; + Assert.Equal(0, AcmExports.AcmBatchWait(_ctx)); + } + + [Fact] + public void BatchStartBuffers_RejectsUnknownContextAndMissingOutputs() + { + _ctx[CpuRegister.Rdi] = 99; + _ctx[CpuRegister.Rsi] = 1; + _ctx[CpuRegister.Rdx] = InfoArrayAddress; + _ctx[CpuRegister.R8] = BatchAddress; + Assert.Equal( + (int)OrbisGen2Result.ORBIS_GEN2_ERROR_INVALID_ARGUMENT, + AcmExports.AcmBatchStartBuffers(_ctx)); + + _ctx[CpuRegister.Rdi] = ContextAddress; + Assert.Equal(0, AcmExports.AcmContextCreate(_ctx)); + _ctx[CpuRegister.Rdi] = ReadUInt32(ContextAddress); + _ctx[CpuRegister.Rsi] = 1; + _ctx[CpuRegister.Rdx] = 0; + _ctx[CpuRegister.R8] = BatchAddress; + Assert.Equal( + (int)OrbisGen2Result.ORBIS_GEN2_ERROR_INVALID_ARGUMENT, + AcmExports.AcmBatchStartBuffers(_ctx)); + } + + [Fact] + public void AcmBatchExports_RegisterForBothGenerations() + { + foreach (var generation in new[] { Generation.Gen4, Generation.Gen5 }) + { + var manager = new ModuleManager(); + manager.RegisterExports(SharpEmu.Generated.SysAbiExportRegistry.CreateExports(generation)); + + Assert.True(manager.TryGetExport("8fe55ktlNVo", out var start)); + Assert.Equal("sceAcmBatchStartBuffers", start.Name); + Assert.True(manager.TryGetExport("RLN3gRlXJLE", out var wait)); + Assert.Equal("sceAcmBatchWait", wait.Name); + } + } + + public void Dispose() + { + AcmExports.ResetForTests(); + } + + private uint ReadUInt32(ulong address) + { + Span value = stackalloc byte[sizeof(uint)]; + Assert.True(_memory.TryRead(address, value)); + return BinaryPrimitives.ReadUInt32LittleEndian(value); + } + + private byte[] ReadBytes(ulong address, int length) + { + var value = new byte[length]; + Assert.True(_memory.TryRead(address, value)); + return value; + } + + private void WriteUInt64(ulong address, ulong value) + { + Span bytes = stackalloc byte[sizeof(ulong)]; + BinaryPrimitives.WriteUInt64LittleEndian(bytes, value); + Assert.True(_memory.TryWrite(address, bytes)); + } +} diff --git a/tests/SharpEmu.Libs.Tests/Audio/AjmExportsTests.cs b/tests/SharpEmu.Libs.Tests/Audio/AjmExportsTests.cs index 1669c289..ef030b82 100644 --- a/tests/SharpEmu.Libs.Tests/Audio/AjmExportsTests.cs +++ b/tests/SharpEmu.Libs.Tests/Audio/AjmExportsTests.cs @@ -25,6 +25,9 @@ public sealed class AjmExportsTests : IDisposable private const ulong MemoryBase = 0x1_0000_0000; private const ulong ContextAddress = MemoryBase + 0x100; private const ulong InstanceAddress = MemoryBase + 0x200; + private const ulong BatchInfoAddress = MemoryBase + 0x300; + private const ulong StatisticsAddress = MemoryBase + 0x400; + private const ulong BatchBufferAddress = MemoryBase + 0x500; private readonly FakeCpuMemory _memory = new(MemoryBase, 0x1000); private readonly CpuContext _ctx; @@ -80,6 +83,171 @@ public sealed class AjmExportsTests : IDisposable Assert.Equal(InvalidContext, RegisterCodec(contextId + 1, 1)); } + [Fact] + public void MemoryRegistration_TracksValidContextAndToleratesRepeatedUnregister() + { + var contextId = Initialize(); + const ulong address = 0x4_D4E0_0000; + + Assert.Equal(0, RegisterMemory(contextId, address, 4)); + Assert.Equal(0, UnregisterMemory(contextId, address)); + Assert.Equal(0, UnregisterMemory(contextId, address)); + Assert.Equal(InvalidContext, RegisterMemory(contextId + 1, address, 4)); + Assert.Equal(InvalidContext, UnregisterMemory(contextId + 1, address)); + Assert.Equal(InvalidParameter, RegisterMemory(contextId, 0, 4)); + Assert.Equal(InvalidParameter, RegisterMemory(contextId, address, 0)); + Assert.Equal(InvalidParameter, UnregisterMemory(contextId, 0)); + } + + [Fact] + public void BatchInitializeAndStatistics_WriteExpectedAbiStructures() + { + Span sentinel = stackalloc byte[48]; + sentinel.Fill(0xCC); + Assert.True(_memory.TryWrite(StatisticsAddress, sentinel)); + + _ctx[CpuRegister.Rdi] = BatchBufferAddress; + _ctx[CpuRegister.Rsi] = 0x200; + _ctx[CpuRegister.Rdx] = BatchInfoAddress; + Assert.Equal(0, AjmExports.AjmBatchInitialize(_ctx)); + + Assert.Equal(BatchBufferAddress, ReadUInt64(BatchInfoAddress)); + Assert.Equal(0ul, ReadUInt64(BatchInfoAddress + 8)); + Assert.Equal(0x200ul, ReadUInt64(BatchInfoAddress + 16)); + Assert.Equal(0ul, ReadUInt64(BatchInfoAddress + 24)); + Assert.Equal(0ul, ReadUInt64(BatchInfoAddress + 32)); + + _ctx[CpuRegister.Rdi] = BatchInfoAddress; + _ctx[CpuRegister.Rsi] = StatisticsAddress; + _ctx.SetXmmRegister(0, BitConverter.SingleToUInt32Bits(0.25f), 0); + Assert.Equal(0, AjmExports.AjmBatchJobGetStatistics(_ctx)); + + Assert.Equal(88ul, ReadUInt64(BatchInfoAddress + 8)); + Assert.Equal(BatchBufferAddress, ReadUInt64(BatchInfoAddress + 24)); + Assert.Equal(0ul, ReadUInt64(BatchInfoAddress + 32)); + Assert.All(ReadBytes(StatisticsAddress, 48), value => Assert.Equal(0, value)); + Assert.All(ReadBytes(BatchBufferAddress, 88), value => Assert.Equal(0, value)); + } + + /// + /// Demon's Souls creates ATRAC9 voices with low flag words 1, 2, 4 and 8 — + /// the channel counts of the streams they carry. Rejecting any of them left + /// the title holding SCE_AJM_INSTANCE_INVALID for that voice, so its 4- and + /// 8-channel movie stems played silence. + /// + [Theory] + [InlineData(0x1_0000_0001ul)] + [InlineData(0x1_0000_0002ul)] + [InlineData(0x1_0000_0004ul)] + [InlineData(0x1_0000_0008ul)] + public void InstanceCreate_AcceptsEveryChannelCountFlagWord(ulong flags) + { + var contextId = Initialize(); + Assert.Equal(0, RegisterCodec(contextId, 1)); + + Assert.Equal(0, CreateInstance(contextId, 1, flags, InstanceAddress)); + Assert.Equal(0x4001u, ReadUInt32(InstanceAddress)); + } + + /// + /// sceAjmBatchJobRunSplit gathers arrays of AjmBuffer descriptors rather than + /// a single pointer/size pair, and its sideband layout is positional: result, + /// then only the blocks the job flags asked for. + /// + [Fact] + public void BatchJobRunSplit_GathersDescriptorsAndWritesFlaggedSideband() + { + const ulong inputDescriptors = MemoryBase + 0x600; + const ulong outputDescriptors = MemoryBase + 0x640; + const ulong inputData = MemoryBase + 0x700; + const ulong outputData = MemoryBase + 0x800; + const ulong sideband = MemoryBase + 0x900; + const ulong streamSideband = 1ul << 47; + const ulong multipleFrames = 1ul << 12; + + var contextId = Initialize(); + Assert.Equal(0, RegisterCodec(contextId, 2)); + Assert.Equal(0, CreateInstance(contextId, 2, 0x2_0000_0002, InstanceAddress)); + var instanceId = ReadUInt32(InstanceAddress); + + InitializeBatch(BatchBufferAddress, 0x200, BatchInfoAddress); + + // Two input descriptors totalling 0x30 bytes, one output of 0x40. + WriteUInt64(inputDescriptors, inputData); + WriteUInt64(inputDescriptors + 8, 0x20); + WriteUInt64(inputDescriptors + 16, inputData + 0x20); + WriteUInt64(inputDescriptors + 24, 0x10); + WriteUInt64(outputDescriptors, outputData); + WriteUInt64(outputDescriptors + 8, 0x40); + + Span dirty = stackalloc byte[0x40]; + dirty.Fill(0xAB); + Assert.True(_memory.TryWrite(outputData, dirty)); + + _ctx[CpuRegister.Rdi] = BatchInfoAddress; + _ctx[CpuRegister.Rsi] = instanceId; + _ctx[CpuRegister.Rdx] = streamSideband | multipleFrames; + _ctx[CpuRegister.Rcx] = inputDescriptors; + _ctx[CpuRegister.R8] = 2; + _ctx[CpuRegister.R9] = outputDescriptors; + WriteStackArgs(outputCount: 1, sidebandAddress: sideband, sidebandSize: 0x20); + + Assert.Equal(0, AjmExports.AjmBatchJobRunSplit(_ctx)); + + // A non-ATRAC9 instance stays a silence stub: the output is cleared and + // the whole gathered input is reported consumed. + Assert.All(ReadBytes(outputData, 0x40), value => Assert.Equal(0, value)); + + var result = ReadBytes(sideband, 0x20); + Assert.Equal(0, BinaryPrimitives.ReadInt32LittleEndian(result)); // AjmSidebandResult + Assert.Equal(0x30, BinaryPrimitives.ReadInt32LittleEndian(result.AsSpan(8))); // stream.input_consumed + Assert.Equal(0, BinaryPrimitives.ReadInt32LittleEndian(result.AsSpan(12))); // stream.output_written + Assert.Equal(1u, BinaryPrimitives.ReadUInt32LittleEndian(result.AsSpan(24))); // mframe.num_frames + + // The batch cursor advanced past the job plus its descriptors. + Assert.True(ReadUInt64(BatchInfoAddress + 8) > 0); + } + + [Fact] + public void BatchJobRunSplit_OmitsSidebandBlocksTheJobFlagsDidNotRequest() + { + const ulong inputDescriptors = MemoryBase + 0x600; + const ulong outputDescriptors = MemoryBase + 0x640; + const ulong inputData = MemoryBase + 0x700; + const ulong outputData = MemoryBase + 0x800; + const ulong sideband = MemoryBase + 0x900; + + var contextId = Initialize(); + Assert.Equal(0, RegisterCodec(contextId, 2)); + Assert.Equal(0, CreateInstance(contextId, 2, 0x2_0000_0002, InstanceAddress)); + var instanceId = ReadUInt32(InstanceAddress); + + InitializeBatch(BatchBufferAddress, 0x200, BatchInfoAddress); + WriteUInt64(inputDescriptors, inputData); + WriteUInt64(inputDescriptors + 8, 0x20); + WriteUInt64(outputDescriptors, outputData); + WriteUInt64(outputDescriptors + 8, 0x40); + + Span sentinel = stackalloc byte[0x20]; + sentinel.Fill(0x5A); + Assert.True(_memory.TryWrite(sideband, sentinel)); + + _ctx[CpuRegister.Rdi] = BatchInfoAddress; + _ctx[CpuRegister.Rsi] = instanceId; + _ctx[CpuRegister.Rdx] = 0; // No stream sideband, no multiple-frames. + _ctx[CpuRegister.Rcx] = inputDescriptors; + _ctx[CpuRegister.R8] = 1; + _ctx[CpuRegister.R9] = outputDescriptors; + WriteStackArgs(outputCount: 1, sidebandAddress: sideband, sidebandSize: 0x20); + + Assert.Equal(0, AjmExports.AjmBatchJobRunSplit(_ctx)); + + // Only the 8-byte result block is written; the rest keeps its sentinel. + var written = ReadBytes(sideband, 0x20); + Assert.Equal(0, BinaryPrimitives.ReadInt32LittleEndian(written)); + Assert.All(written.AsSpan(8).ToArray(), value => Assert.Equal(0x5A, value)); + } + [Theory] [InlineData(23u)] [InlineData(24u)] @@ -155,6 +323,12 @@ public sealed class AjmExportsTests : IDisposable Assert.Equal("sceAjmInstanceCreate", create.Name); Assert.True(manager.TryGetExport("RbLbuKv8zho", out var destroy)); Assert.Equal("sceAjmInstanceDestroy", destroy.Name); + Assert.True(manager.TryGetExport("bkRHEYG6lEM", out var memoryRegister)); + Assert.Equal("sceAjmMemoryRegister", memoryRegister.Name); + Assert.True(manager.TryGetExport("pIpGiaYkHkM", out var memoryUnregister)); + Assert.Equal("sceAjmMemoryUnregister", memoryUnregister.Name); + Assert.True(manager.TryGetExport("3cAg7xN995U", out var statistics)); + Assert.Equal("sceAjmBatchJobGetStatistics", statistics.Name); } } @@ -179,6 +353,21 @@ public sealed class AjmExportsTests : IDisposable return AjmExports.AjmModuleRegister(_ctx); } + private int RegisterMemory(uint contextId, ulong address, ulong pages) + { + _ctx[CpuRegister.Rdi] = contextId; + _ctx[CpuRegister.Rsi] = address; + _ctx[CpuRegister.Rdx] = pages; + return AjmExports.AjmMemoryRegister(_ctx); + } + + private int UnregisterMemory(uint contextId, ulong address) + { + _ctx[CpuRegister.Rdi] = contextId; + _ctx[CpuRegister.Rsi] = address; + return AjmExports.AjmMemoryUnregister(_ctx); + } + private int CreateInstance(uint contextId, uint codecType, ulong flags, ulong outputAddress) { _ctx[CpuRegister.Rdi] = contextId; @@ -202,6 +391,48 @@ public sealed class AjmExportsTests : IDisposable return BinaryPrimitives.ReadUInt32LittleEndian(value); } + private void InitializeBatch(ulong bufferAddress, ulong bufferSize, ulong infoAddress) + { + _ctx[CpuRegister.Rdi] = bufferAddress; + _ctx[CpuRegister.Rsi] = bufferSize; + _ctx[CpuRegister.Rdx] = infoAddress; + Assert.Equal(0, AjmExports.AjmBatchInitialize(_ctx)); + } + + /// + /// Places the seventh, eighth and ninth SysV arguments where the export reads + /// them: just past the return address slot at [rsp]. + /// + private void WriteStackArgs(ulong outputCount, ulong sidebandAddress, ulong sidebandSize) + { + const ulong stackAddress = MemoryBase + 0xA00; + _ctx[CpuRegister.Rsp] = stackAddress; + WriteUInt64(stackAddress + 8, outputCount); + WriteUInt64(stackAddress + 16, sidebandAddress); + WriteUInt64(stackAddress + 24, sidebandSize); + } + + private void WriteUInt64(ulong address, ulong value) + { + Span bytes = stackalloc byte[sizeof(ulong)]; + BinaryPrimitives.WriteUInt64LittleEndian(bytes, value); + Assert.True(_memory.TryWrite(address, bytes)); + } + + private ulong ReadUInt64(ulong address) + { + Span value = stackalloc byte[sizeof(ulong)]; + Assert.True(_memory.TryRead(address, value)); + return BinaryPrimitives.ReadUInt64LittleEndian(value); + } + + private byte[] ReadBytes(ulong address, int length) + { + var value = new byte[length]; + Assert.True(_memory.TryRead(address, value)); + return value; + } + private void WriteUInt32(ulong address, uint value) { Span bytes = stackalloc byte[sizeof(uint)];