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