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)];