[Ngs2] Implement sceNgs2ParseWaveformData/File/User
The three waveform-parse entry points returned NOT_FOUND, and Void Terrarium calls ParseWaveformData on its audio thread for every sound it loads. They now parse the container and publish the description SceNgs2WaveformInfo lays out (0xE8 bytes, with the block table at 0x48). A RIFF/WAVE fmt chunk resolves to PCM by bit depth, to IEEE float, through the SubFormat GUID for WAVE_FORMAT_EXTENSIBLE, or to ATRAC9, whose configuration is decoded so that the audio unit size, samples per unit, units per frame and delay samples describe the real frame and superframe. A smpl chunk supplies the loop points and a fact chunk the decoded sample count. The "VAGp" container maps onto the VAG waveform type at 28 samples per 16-byte HE-VAG unit. ParseWaveformFile resolves the guest path and reads the header at the given offset. ParseWaveformUser would have to drive a guest read callback, so it reports INVALID_WAVEFORM_DATA rather than fabricating a description.
This commit is contained in:
parent
4d65ff6d51
commit
e77a02484d
|
|
@ -15,6 +15,16 @@ public static class Ngs2Exports
|
|||
private const int OrbisNgs2ErrorInvalidSystemHandle = unchecked((int)0x804A0230);
|
||||
private const int OrbisNgs2ErrorInvalidRackHandle = unchecked((int)0x804A0261);
|
||||
private const int OrbisNgs2ErrorInvalidVoiceHandle = unchecked((int)0x804A0300);
|
||||
// ngs2/ngs2_errors.h waveform-parse codes. Note the PS5 SDK error base is
|
||||
// 0x804A80xx while the PS4 library (and OrbisNgs2ErrorInvalidOutAddress above)
|
||||
// used 0x804A00xx; the parse APIs only exist with the newer numbering.
|
||||
private const int OrbisNgs2ErrorInvalidParseOutAddress = unchecked((int)0x804A8010);
|
||||
private const int OrbisNgs2ErrorInvalidWaveformAddress = unchecked((int)0x804A8055);
|
||||
private const int OrbisNgs2ErrorInvalidWaveformData = unchecked((int)0x804A8430);
|
||||
private const int OrbisNgs2ErrorInvalidWaveformFormat = unchecked((int)0x804A8431);
|
||||
// Container headers are small; RIFF/VAG descriptors always fit well inside this.
|
||||
private const ulong MaximumWaveformHeaderBytes = 0x1000;
|
||||
private const int MaximumWaveformPathBytes = 1024;
|
||||
private const ulong HandleStorageSize = 0x20;
|
||||
private const int RenderBufferInfoSize = 0x18;
|
||||
private const ulong MaximumRenderBufferSize = 16 * 1024 * 1024;
|
||||
|
|
@ -900,6 +910,197 @@ public static class Ngs2Exports
|
|||
}
|
||||
}
|
||||
|
||||
// ngs2_core.h: int32_t sceNgs2ParseWaveformData(const void *data, size_t dataSize,
|
||||
// SceNgs2WaveformInfo *outInfo).
|
||||
[SysAbiExport(
|
||||
Nid = "hyVLT2VlOYk",
|
||||
ExportName = "sceNgs2ParseWaveformData",
|
||||
Target = Generation.Gen4 | Generation.Gen5,
|
||||
LibraryName = "libSceNgs2")]
|
||||
public static int Ngs2ParseWaveformData(CpuContext ctx)
|
||||
{
|
||||
var dataAddress = ctx[CpuRegister.Rdi];
|
||||
var dataSize = ctx[CpuRegister.Rsi];
|
||||
var outInfoAddress = ctx[CpuRegister.Rdx];
|
||||
if (outInfoAddress == 0)
|
||||
{
|
||||
return SetReturn(ctx, OrbisNgs2ErrorInvalidParseOutAddress);
|
||||
}
|
||||
|
||||
if (dataAddress == 0 || dataSize == 0)
|
||||
{
|
||||
return SetReturn(ctx, OrbisNgs2ErrorInvalidWaveformAddress);
|
||||
}
|
||||
|
||||
// Only the container header is needed; cap the copy so a title that
|
||||
// declares a multi-megabyte streaming buffer does not drag it all in.
|
||||
var length = (int)Math.Min(dataSize, MaximumWaveformHeaderBytes);
|
||||
var buffer = ArrayPool<byte>.Shared.Rent(length);
|
||||
try
|
||||
{
|
||||
var span = buffer.AsSpan(0, length);
|
||||
if (!ctx.Memory.TryRead(dataAddress, span))
|
||||
{
|
||||
return SetReturn(ctx, OrbisNgs2ErrorInvalidWaveformAddress);
|
||||
}
|
||||
|
||||
return WriteParsedWaveform(ctx, span, dataSize, outInfoAddress);
|
||||
}
|
||||
finally
|
||||
{
|
||||
ArrayPool<byte>.Shared.Return(buffer);
|
||||
}
|
||||
}
|
||||
|
||||
// ngs2_core.h: int32_t sceNgs2ParseWaveformFile(const char *path, uint64_t offset,
|
||||
// SceNgs2WaveformInfo *outInfo).
|
||||
[SysAbiExport(
|
||||
Nid = "iprCTXPVWMI",
|
||||
ExportName = "sceNgs2ParseWaveformFile",
|
||||
Target = Generation.Gen4 | Generation.Gen5,
|
||||
LibraryName = "libSceNgs2")]
|
||||
public static int Ngs2ParseWaveformFile(CpuContext ctx)
|
||||
{
|
||||
var pathAddress = ctx[CpuRegister.Rdi];
|
||||
var offset = ctx[CpuRegister.Rsi];
|
||||
var outInfoAddress = ctx[CpuRegister.Rdx];
|
||||
if (outInfoAddress == 0)
|
||||
{
|
||||
return SetReturn(ctx, OrbisNgs2ErrorInvalidParseOutAddress);
|
||||
}
|
||||
|
||||
if (pathAddress == 0 || !TryReadGuestPath(ctx, pathAddress, out var guestPath))
|
||||
{
|
||||
return SetReturn(ctx, OrbisNgs2ErrorInvalidWaveformAddress);
|
||||
}
|
||||
|
||||
var hostPath = KernelMemoryCompatExports.ResolveGuestPath(guestPath);
|
||||
byte[] header;
|
||||
long fileLength;
|
||||
try
|
||||
{
|
||||
using var stream = File.OpenRead(hostPath);
|
||||
fileLength = stream.Length;
|
||||
if (offset >= (ulong)fileLength)
|
||||
{
|
||||
return SetReturn(ctx, OrbisNgs2ErrorInvalidWaveformData);
|
||||
}
|
||||
|
||||
stream.Position = (long)offset;
|
||||
var length = (int)Math.Min((ulong)(fileLength - (long)offset), MaximumWaveformHeaderBytes);
|
||||
header = new byte[length];
|
||||
stream.ReadExactly(header, 0, length);
|
||||
}
|
||||
catch (Exception exception) when (
|
||||
exception is IOException or UnauthorizedAccessException or ArgumentException)
|
||||
{
|
||||
Trace($"parse-waveform-file open-failed path={guestPath} error={exception.Message}");
|
||||
return SetReturn(ctx, OrbisNgs2ErrorInvalidWaveformData);
|
||||
}
|
||||
|
||||
return WriteParsedWaveform(ctx, header, (ulong)fileLength - offset, outInfoAddress);
|
||||
}
|
||||
|
||||
// ngs2_core.h: int32_t sceNgs2ParseWaveformUser(SceNgs2ParseReadHandler handler,
|
||||
// uintptr_t userData, SceNgs2WaveformInfo *outInfo). The handler is a guest
|
||||
// callback, which this HLE cannot drive, so report the data as unparsable
|
||||
// rather than publishing a fabricated description.
|
||||
[SysAbiExport(
|
||||
Nid = "t9T0QM17Kvo",
|
||||
ExportName = "sceNgs2ParseWaveformUser",
|
||||
Target = Generation.Gen4 | Generation.Gen5,
|
||||
LibraryName = "libSceNgs2")]
|
||||
public static int Ngs2ParseWaveformUser(CpuContext ctx) =>
|
||||
SetReturn(
|
||||
ctx,
|
||||
ctx[CpuRegister.Rdx] == 0
|
||||
? OrbisNgs2ErrorInvalidParseOutAddress
|
||||
: OrbisNgs2ErrorInvalidWaveformData);
|
||||
|
||||
private static int WriteParsedWaveform(
|
||||
CpuContext ctx,
|
||||
ReadOnlySpan<byte> header,
|
||||
ulong totalDataSize,
|
||||
ulong outInfoAddress)
|
||||
{
|
||||
if (!Ngs2WaveformParser.TryParse(header, out var parsed))
|
||||
{
|
||||
Trace(
|
||||
Ngs2WaveformParser.IsSupportedContainer(header)
|
||||
? "parse-waveform unsupported-format"
|
||||
: "parse-waveform unknown-container");
|
||||
return SetReturn(
|
||||
ctx,
|
||||
Ngs2WaveformParser.IsSupportedContainer(header)
|
||||
? OrbisNgs2ErrorInvalidWaveformFormat
|
||||
: OrbisNgs2ErrorInvalidWaveformData);
|
||||
}
|
||||
|
||||
// Only the container header was copied in. When the real image is longer,
|
||||
// the data chunk continues past it, so extend it from the caller's total
|
||||
// length instead of reporting the truncated size.
|
||||
if ((ulong)header.Length < totalDataSize && totalDataSize > parsed.DataOffset)
|
||||
{
|
||||
var available = totalDataSize - parsed.DataOffset;
|
||||
parsed.DataSize = (uint)Math.Min(available, uint.MaxValue);
|
||||
if (parsed.AudioUnitSize != 0 &&
|
||||
parsed.WaveformType != Ngs2WaveformParser.WaveformTypeAtrac9)
|
||||
{
|
||||
parsed.NumSamples =
|
||||
parsed.DataSize / parsed.AudioUnitSize * parsed.NumAudioUnitSamples;
|
||||
parsed.LoopEndPosition = parsed.LoopEndPosition == 0 && parsed.NumSamples != 0
|
||||
? parsed.NumSamples - 1
|
||||
: parsed.LoopEndPosition;
|
||||
}
|
||||
}
|
||||
|
||||
Span<byte> info = stackalloc byte[Ngs2WaveformParser.WaveformInfoSize];
|
||||
Ngs2WaveformParser.Write(parsed, info);
|
||||
if (!ctx.Memory.TryWrite(outInfoAddress, info))
|
||||
{
|
||||
return SetReturn(ctx, OrbisNgs2ErrorInvalidParseOutAddress);
|
||||
}
|
||||
|
||||
Trace(
|
||||
$"parse-waveform type=0x{parsed.WaveformType:X} channels={parsed.NumChannels} " +
|
||||
$"rate={parsed.SampleRate} dataOffset=0x{parsed.DataOffset:X} " +
|
||||
$"dataSize=0x{parsed.DataSize:X} samples={parsed.NumSamples}");
|
||||
return SetReturn(ctx, 0);
|
||||
}
|
||||
|
||||
private static bool TryReadGuestPath(CpuContext ctx, ulong address, out string path)
|
||||
{
|
||||
Span<byte> buffer = stackalloc byte[MaximumWaveformPathBytes];
|
||||
var length = 0;
|
||||
Span<byte> one = stackalloc byte[1];
|
||||
while (length < buffer.Length)
|
||||
{
|
||||
if (!ctx.Memory.TryRead(address + (ulong)length, one))
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
if (one[0] == 0)
|
||||
{
|
||||
path = System.Text.Encoding.UTF8.GetString(buffer[..length]);
|
||||
return length != 0;
|
||||
}
|
||||
|
||||
buffer[length++] = one[0];
|
||||
}
|
||||
|
||||
path = string.Empty;
|
||||
return false;
|
||||
}
|
||||
|
||||
private static void Trace(string message)
|
||||
{
|
||||
if (ShouldTrace())
|
||||
{
|
||||
Console.Error.WriteLine($"[LOADER][TRACE] ngs2.{message}");
|
||||
}
|
||||
}
|
||||
|
||||
private static bool ShouldTrace() =>
|
||||
string.Equals(
|
||||
Environment.GetEnvironmentVariable("SHARPEMU_LOG_NGS2"),
|
||||
|
|
|
|||
|
|
@ -0,0 +1,411 @@
|
|||
// Copyright (C) 2026 SharpEmu Emulator Project
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
using LibAtrac9;
|
||||
using System.Buffers.Binary;
|
||||
|
||||
namespace SharpEmu.Libs.Ngs2;
|
||||
|
||||
// Builds SceNgs2WaveformInfo out of a raw waveform image, the way
|
||||
// sceNgs2ParseWaveformData/File/User do. Layout and every derived quantity come
|
||||
// from ngs2/ngs2_core.h (SceNgs2WaveformFormat, SceNgs2WaveformBlock,
|
||||
// SceNgs2WaveformInfo and the audio-unit table documented on the info fields).
|
||||
internal static class Ngs2WaveformParser
|
||||
{
|
||||
// ngs2_core.h waveform types.
|
||||
internal const uint WaveformTypePcmI8 = 0x10;
|
||||
internal const uint WaveformTypePcmU8 = 0x11;
|
||||
internal const uint WaveformTypePcmI16L = 0x12;
|
||||
internal const uint WaveformTypePcmI24L = 0x14;
|
||||
internal const uint WaveformTypePcmI32L = 0x16;
|
||||
internal const uint WaveformTypePcmF32L = 0x18;
|
||||
internal const uint WaveformTypePcmF64L = 0x1A;
|
||||
internal const uint WaveformTypeVag = 0x1C;
|
||||
internal const uint WaveformTypeAtrac9 = 0x40;
|
||||
|
||||
// sizeof(SceNgs2WaveformFormat): 6 x uint32.
|
||||
internal const int FormatSize = 0x18;
|
||||
// sizeof(SceNgs2WaveformBlock): uintptr dataOffset, size_t dataSize,
|
||||
// 4 x uint32, uintptr userData.
|
||||
internal const int BlockSize = 0x28;
|
||||
// SCE_NGS2_WAVEFORM_INFO_MAX_BLOCKS.
|
||||
internal const int MaxBlocks = 4;
|
||||
internal const int BlockArrayOffset = 0x48;
|
||||
// sizeof(SceNgs2WaveformInfo): format (0x18) + 12 x uint32 (0x30, the last
|
||||
// one padded to 8) + aBlock[4].
|
||||
internal const int WaveformInfoSize = BlockArrayOffset + (MaxBlocks * BlockSize);
|
||||
|
||||
// HE-VAG: one 16-byte ADPCM unit carries 28 samples per channel.
|
||||
private const int VagUnitBytesPerChannel = 16;
|
||||
private const int VagUnitSamples = 28;
|
||||
|
||||
// WAVE_FORMAT_EXTENSIBLE SubFormat GUID slot inside the "fmt " chunk.
|
||||
private const int SubFormatOffset = 0x18;
|
||||
// {47E142D2-36BA-4D8D-88FC-61654F8C836C} — Sony ATRAC9.
|
||||
private static ReadOnlySpan<byte> Atrac9SubFormatGuid =>
|
||||
[
|
||||
0xD2, 0x42, 0xE1, 0x47, 0xBA, 0x36, 0x8D, 0x4D,
|
||||
0x88, 0xFC, 0x61, 0x65, 0x4F, 0x8C, 0x83, 0x6C,
|
||||
];
|
||||
|
||||
// KSDATAFORMAT_SUBTYPE_PCM {00000001-0000-0010-8000-00AA00389B71}.
|
||||
private static ReadOnlySpan<byte> PcmSubFormatGuid =>
|
||||
[
|
||||
0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x10, 0x00,
|
||||
0x80, 0x00, 0x00, 0xAA, 0x00, 0x38, 0x9B, 0x71,
|
||||
];
|
||||
|
||||
// KSDATAFORMAT_SUBTYPE_IEEE_FLOAT {00000003-0000-0010-8000-00AA00389B71}.
|
||||
private static ReadOnlySpan<byte> FloatSubFormatGuid =>
|
||||
[
|
||||
0x03, 0x00, 0x00, 0x00, 0x00, 0x00, 0x10, 0x00,
|
||||
0x80, 0x00, 0x00, 0xAA, 0x00, 0x38, 0x9B, 0x71,
|
||||
];
|
||||
|
||||
internal struct WaveformInfo
|
||||
{
|
||||
public uint WaveformType;
|
||||
public uint NumChannels;
|
||||
public uint SampleRate;
|
||||
public uint ConfigData;
|
||||
public uint FrameMargin;
|
||||
public uint FrameOffset;
|
||||
|
||||
public uint DataOffset;
|
||||
public uint DataSize;
|
||||
public uint LoopBeginPosition;
|
||||
public uint LoopEndPosition;
|
||||
public uint NumSamples;
|
||||
|
||||
public uint AudioUnitSize;
|
||||
public uint NumAudioUnitSamples;
|
||||
public uint NumAudioUnitPerFrame;
|
||||
public uint AudioFrameSize;
|
||||
public uint NumAudioFrameSamples;
|
||||
public uint NumDelaySamples;
|
||||
public uint NumBlocks;
|
||||
}
|
||||
|
||||
// Serializes the parsed description into the guest SceNgs2WaveformInfo the
|
||||
// caller supplied. Exactly WaveformInfoSize bytes are produced; the single
|
||||
// block mirrors the whole data chunk, which is what the library reports for a
|
||||
// file that carries no explicit block table.
|
||||
internal static void Write(in WaveformInfo info, Span<byte> destination)
|
||||
{
|
||||
destination[..WaveformInfoSize].Clear();
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(destination[0x00..], info.WaveformType);
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(destination[0x04..], info.NumChannels);
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(destination[0x08..], info.SampleRate);
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(destination[0x0C..], info.ConfigData);
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(destination[0x10..], info.FrameMargin);
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(destination[0x14..], info.FrameOffset);
|
||||
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(destination[0x18..], info.DataOffset);
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(destination[0x1C..], info.DataSize);
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(destination[0x20..], info.LoopBeginPosition);
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(destination[0x24..], info.LoopEndPosition);
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(destination[0x28..], info.NumSamples);
|
||||
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(destination[0x2C..], info.AudioUnitSize);
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(destination[0x30..], info.NumAudioUnitSamples);
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(destination[0x34..], info.NumAudioUnitPerFrame);
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(destination[0x38..], info.AudioFrameSize);
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(destination[0x3C..], info.NumAudioFrameSamples);
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(destination[0x40..], info.NumDelaySamples);
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(destination[0x44..], info.NumBlocks);
|
||||
|
||||
if (info.NumBlocks == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
// aBlock[0]: dataOffset/dataSize plus the sample span, so a title can feed
|
||||
// the block straight back into sceNgs2VoiceControl.
|
||||
var block = destination[BlockArrayOffset..];
|
||||
BinaryPrimitives.WriteUInt64LittleEndian(block[0x00..], info.DataOffset);
|
||||
BinaryPrimitives.WriteUInt64LittleEndian(block[0x08..], info.DataSize);
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(block[0x10..], 0);
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(block[0x14..], 0);
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(block[0x18..], info.NumSamples);
|
||||
}
|
||||
|
||||
// True when the image starts with a container this parser understands.
|
||||
internal static bool IsSupportedContainer(ReadOnlySpan<byte> data) =>
|
||||
Ngs2VagDecoder.IsVag(data) ||
|
||||
(data.Length >= 12 &&
|
||||
data[..4].SequenceEqual("RIFF"u8) &&
|
||||
data.Slice(8, 4).SequenceEqual("WAVE"u8));
|
||||
|
||||
internal static bool TryParse(ReadOnlySpan<byte> data, out WaveformInfo info)
|
||||
{
|
||||
info = default;
|
||||
if (Ngs2VagDecoder.IsVag(data))
|
||||
{
|
||||
return TryParseVag(data, ref info);
|
||||
}
|
||||
|
||||
return TryParseRiff(data, ref info);
|
||||
}
|
||||
|
||||
// Classic Sony "VAGp" container: 0x30-byte big-endian header, then 16-byte
|
||||
// PS-ADPCM units. ngs2_core.h maps this onto SCE_NGS2_WAVEFORM_TYPE_VAG with
|
||||
// 28 samples per unit.
|
||||
private static bool TryParseVag(ReadOnlySpan<byte> data, ref WaveformInfo info)
|
||||
{
|
||||
if (data.Length < Ngs2VagDecoder.VagHeaderSize)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var declaredSize = BinaryPrimitives.ReadUInt32BigEndian(data[0x0C..]);
|
||||
var sampleRate = BinaryPrimitives.ReadUInt32BigEndian(data[0x10..]);
|
||||
var channels = data[0x1E] == 0 ? 1u : data[0x1E];
|
||||
var available = (uint)(data.Length - Ngs2VagDecoder.VagHeaderSize);
|
||||
var dataSize = declaredSize == 0 || declaredSize > available ? available : declaredSize;
|
||||
|
||||
info.WaveformType = WaveformTypeVag;
|
||||
info.NumChannels = channels;
|
||||
info.SampleRate = sampleRate == 0 ? 48000 : sampleRate;
|
||||
info.DataOffset = Ngs2VagDecoder.VagHeaderSize;
|
||||
info.DataSize = dataSize;
|
||||
info.AudioUnitSize = (uint)VagUnitBytesPerChannel * channels;
|
||||
info.NumAudioUnitSamples = VagUnitSamples;
|
||||
info.NumAudioUnitPerFrame = 1;
|
||||
info.NumSamples = info.AudioUnitSize == 0
|
||||
? 0
|
||||
: dataSize / info.AudioUnitSize * VagUnitSamples;
|
||||
FinishDerivedFields(ref info);
|
||||
return true;
|
||||
}
|
||||
|
||||
private static bool TryParseRiff(ReadOnlySpan<byte> data, ref WaveformInfo info)
|
||||
{
|
||||
if (data.Length < 12 ||
|
||||
!data[..4].SequenceEqual("RIFF"u8) ||
|
||||
!data.Slice(8, 4).SequenceEqual("WAVE"u8))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var haveFormat = false;
|
||||
uint dataOffset = 0;
|
||||
uint dataSize = 0;
|
||||
uint loopBegin = 0;
|
||||
uint loopEnd = 0;
|
||||
uint declaredSamples = 0;
|
||||
var offset = 12;
|
||||
while (offset + 8 <= data.Length)
|
||||
{
|
||||
var chunkId = data.Slice(offset, 4);
|
||||
var chunkSize = BinaryPrimitives.ReadUInt32LittleEndian(data.Slice(offset + 4, 4));
|
||||
var body = offset + 8;
|
||||
var bodySize = (int)Math.Min(chunkSize, (uint)(data.Length - body));
|
||||
if (chunkId.SequenceEqual("fmt "u8))
|
||||
{
|
||||
if (!TryParseFormatChunk(data.Slice(body, bodySize), ref info))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
haveFormat = true;
|
||||
}
|
||||
else if (chunkId.SequenceEqual("data"u8))
|
||||
{
|
||||
dataOffset = (uint)body;
|
||||
dataSize = (uint)bodySize;
|
||||
}
|
||||
else if (chunkId.SequenceEqual("fact"u8) && bodySize >= 4)
|
||||
{
|
||||
// "fact" carries the decoded sample count for compressed formats.
|
||||
declaredSamples = BinaryPrimitives.ReadUInt32LittleEndian(data.Slice(body, 4));
|
||||
}
|
||||
else if (chunkId.SequenceEqual("smpl"u8) && bodySize >= 0x3C)
|
||||
{
|
||||
// Sampler chunk: numSampleLoops at +0x1C, first loop start/end at
|
||||
// +0x2C/+0x30 of the chunk body.
|
||||
if (BinaryPrimitives.ReadUInt32LittleEndian(data.Slice(body + 0x1C, 4)) != 0)
|
||||
{
|
||||
loopBegin = BinaryPrimitives.ReadUInt32LittleEndian(data.Slice(body + 0x2C, 4));
|
||||
loopEnd = BinaryPrimitives.ReadUInt32LittleEndian(data.Slice(body + 0x30, 4));
|
||||
}
|
||||
}
|
||||
|
||||
// RIFF chunks are word aligned.
|
||||
var advance = (ulong)chunkSize + (chunkSize & 1);
|
||||
if (advance > (ulong)(int.MaxValue - body))
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
offset = body + (int)advance;
|
||||
}
|
||||
|
||||
if (!haveFormat || dataSize == 0)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
info.DataOffset = dataOffset;
|
||||
info.DataSize = dataSize;
|
||||
info.LoopBeginPosition = loopBegin;
|
||||
info.LoopEndPosition = loopEnd;
|
||||
|
||||
if (info.WaveformType == WaveformTypeAtrac9)
|
||||
{
|
||||
// The ATRAC9 superframe is the NGS2 "audio frame"; a single frame is
|
||||
// the audio unit. NumDelaySamples is the encoder delay, one frame.
|
||||
info.NumSamples = declaredSamples != 0
|
||||
? declaredSamples
|
||||
: info.AudioUnitSize == 0 || info.NumAudioUnitPerFrame == 0
|
||||
? 0
|
||||
: dataSize / (info.AudioUnitSize * info.NumAudioUnitPerFrame) *
|
||||
(info.NumAudioUnitSamples * info.NumAudioUnitPerFrame);
|
||||
info.NumDelaySamples = info.NumAudioUnitSamples;
|
||||
}
|
||||
else if (info.AudioUnitSize != 0)
|
||||
{
|
||||
info.NumSamples = dataSize / info.AudioUnitSize;
|
||||
}
|
||||
|
||||
FinishDerivedFields(ref info);
|
||||
return true;
|
||||
}
|
||||
|
||||
// WAVE "fmt " chunk: wFormatTag, nChannels, nSamplesPerSec, nAvgBytesPerSec,
|
||||
// nBlockAlign, wBitsPerSample, [cbSize, extension...].
|
||||
private static bool TryParseFormatChunk(ReadOnlySpan<byte> chunk, ref WaveformInfo info)
|
||||
{
|
||||
if (chunk.Length < 16)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var formatTag = BinaryPrimitives.ReadUInt16LittleEndian(chunk);
|
||||
var channels = BinaryPrimitives.ReadUInt16LittleEndian(chunk[2..]);
|
||||
var sampleRate = BinaryPrimitives.ReadUInt32LittleEndian(chunk[4..]);
|
||||
var blockAlign = BinaryPrimitives.ReadUInt16LittleEndian(chunk[12..]);
|
||||
var bitsPerSample = BinaryPrimitives.ReadUInt16LittleEndian(chunk[14..]);
|
||||
|
||||
info.NumChannels = channels == 0 ? 1u : channels;
|
||||
info.SampleRate = sampleRate == 0 ? 48000u : sampleRate;
|
||||
info.NumAudioUnitPerFrame = 1;
|
||||
info.NumAudioUnitSamples = 1;
|
||||
|
||||
// WAVE_FORMAT_EXTENSIBLE resolves through the SubFormat GUID at +0x18.
|
||||
// Sony's ATRAC9 GUID is followed by { uint32 versionInfo; uint8 config[4] }.
|
||||
var extensionSize = chunk.Length >= 18
|
||||
? BinaryPrimitives.ReadUInt16LittleEndian(chunk[16..])
|
||||
: 0;
|
||||
if (formatTag == 0xFFFE)
|
||||
{
|
||||
if (extensionSize < 22 || chunk.Length < SubFormatOffset + 16)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var subFormat = chunk.Slice(SubFormatOffset, 16);
|
||||
if (subFormat.SequenceEqual(Atrac9SubFormatGuid))
|
||||
{
|
||||
return TryParseAtrac9Extension(chunk, ref info);
|
||||
}
|
||||
|
||||
if (subFormat.SequenceEqual(PcmSubFormatGuid))
|
||||
{
|
||||
formatTag = 0x0001;
|
||||
}
|
||||
else if (subFormat.SequenceEqual(FloatSubFormatGuid))
|
||||
{
|
||||
formatTag = 0x0003;
|
||||
}
|
||||
else
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
switch (formatTag)
|
||||
{
|
||||
case 0x0001: // WAVE_FORMAT_PCM
|
||||
info.WaveformType = bitsPerSample switch
|
||||
{
|
||||
8 => WaveformTypePcmU8,
|
||||
16 => WaveformTypePcmI16L,
|
||||
24 => WaveformTypePcmI24L,
|
||||
32 => WaveformTypePcmI32L,
|
||||
_ => 0,
|
||||
};
|
||||
break;
|
||||
case 0x0003: // WAVE_FORMAT_IEEE_FLOAT
|
||||
info.WaveformType = bitsPerSample switch
|
||||
{
|
||||
32 => WaveformTypePcmF32L,
|
||||
64 => WaveformTypePcmF64L,
|
||||
_ => 0,
|
||||
};
|
||||
break;
|
||||
default:
|
||||
info.WaveformType = 0;
|
||||
break;
|
||||
}
|
||||
|
||||
if (info.WaveformType == 0)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
// PCM: the audio unit is one interleaved frame and carries one sample.
|
||||
var bytesPerSample = bitsPerSample / 8u;
|
||||
info.AudioUnitSize = blockAlign != 0 ? blockAlign : bytesPerSample * info.NumChannels;
|
||||
return info.AudioUnitSize != 0;
|
||||
}
|
||||
|
||||
private static bool TryParseAtrac9Extension(ReadOnlySpan<byte> chunk, ref WaveformInfo info)
|
||||
{
|
||||
// Sony's at9 fmt extension: SubFormat GUID (+0x18), uint32 versionInfo
|
||||
// (+0x28), uint8 configData[4] (+0x2C).
|
||||
const int configOffset = SubFormatOffset + 16 + 4;
|
||||
if (chunk.Length < configOffset + 4)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var configData = BinaryPrimitives.ReadUInt32LittleEndian(chunk[configOffset..]);
|
||||
Atrac9Config config;
|
||||
try
|
||||
{
|
||||
config = new Atrac9Config(chunk.Slice(configOffset, 4).ToArray());
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
info.WaveformType = WaveformTypeAtrac9;
|
||||
info.ConfigData = configData;
|
||||
info.NumChannels = (uint)config.ChannelCount;
|
||||
info.SampleRate = (uint)config.SampleRate;
|
||||
info.AudioUnitSize = (uint)config.FrameBytes;
|
||||
info.NumAudioUnitSamples = (uint)config.FrameSamples;
|
||||
info.NumAudioUnitPerFrame = (uint)config.FramesPerSuperframe;
|
||||
return true;
|
||||
}
|
||||
|
||||
// ngs2_core.h: audioFrameSize = audioUnitSize x numAudioUnitPerFrame and
|
||||
// numAudioFrameSamples = numAudioUnitSamples x numAudioUnitPerFrame.
|
||||
private static void FinishDerivedFields(ref WaveformInfo info)
|
||||
{
|
||||
if (info.NumAudioUnitPerFrame == 0)
|
||||
{
|
||||
info.NumAudioUnitPerFrame = 1;
|
||||
}
|
||||
|
||||
info.AudioFrameSize = info.AudioUnitSize * info.NumAudioUnitPerFrame;
|
||||
info.NumAudioFrameSamples = info.NumAudioUnitSamples * info.NumAudioUnitPerFrame;
|
||||
if (info.LoopEndPosition == 0 && info.LoopBeginPosition == 0 && info.NumSamples != 0)
|
||||
{
|
||||
info.LoopEndPosition = info.NumSamples - 1;
|
||||
}
|
||||
|
||||
info.NumBlocks = 1;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,199 @@
|
|||
// Copyright (C) 2026 SharpEmu Emulator Project
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
using System.Buffers.Binary;
|
||||
using SharpEmu.HLE;
|
||||
using SharpEmu.Libs.Ngs2;
|
||||
using Xunit;
|
||||
|
||||
namespace SharpEmu.Libs.Tests.Ngs2;
|
||||
|
||||
// sceNgs2ParseWaveformData against the SceNgs2WaveformInfo layout in
|
||||
// ngs2/ngs2_core.h. Void Terrarium calls this on its audio thread for every
|
||||
// sound it loads, so a fabricated or mis-sized description is felt immediately.
|
||||
public sealed class Ngs2ParseWaveformExportsTests
|
||||
{
|
||||
private const ulong MemoryBase = 0x1_0000_0000;
|
||||
private const ulong DataAddress = MemoryBase + 0x100;
|
||||
private const ulong InfoAddress = MemoryBase + 0x800;
|
||||
|
||||
// ngs2_core.h waveform types.
|
||||
private const uint WaveformTypePcmI16L = 0x12;
|
||||
private const uint WaveformTypeVag = 0x1C;
|
||||
// sizeof(SceNgs2WaveformInfo).
|
||||
private const int WaveformInfoSize = 0xE8;
|
||||
|
||||
private const int InvalidOutAddress = unchecked((int)0x804A8010);
|
||||
private const int InvalidWaveformAddress = unchecked((int)0x804A8055);
|
||||
private const int InvalidWaveformData = unchecked((int)0x804A8430);
|
||||
|
||||
private static CpuContext CreateContext(out FakeCpuMemory memory)
|
||||
{
|
||||
memory = new FakeCpuMemory(MemoryBase, 0x2000);
|
||||
return new CpuContext(memory, Generation.Gen5);
|
||||
}
|
||||
|
||||
private static byte[] BuildPcm16Wave(int frames, int channels, uint sampleRate)
|
||||
{
|
||||
var dataBytes = frames * channels * 2;
|
||||
var file = new byte[12 + 8 + 16 + 8 + dataBytes];
|
||||
var span = file.AsSpan();
|
||||
"RIFF"u8.CopyTo(span);
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(span[4..], (uint)(file.Length - 8));
|
||||
"WAVE"u8.CopyTo(span[8..]);
|
||||
"fmt "u8.CopyTo(span[12..]);
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(span[16..], 16);
|
||||
BinaryPrimitives.WriteUInt16LittleEndian(span[20..], 1); // WAVE_FORMAT_PCM
|
||||
BinaryPrimitives.WriteUInt16LittleEndian(span[22..], (ushort)channels);
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(span[24..], sampleRate);
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(span[28..], sampleRate * (uint)channels * 2);
|
||||
BinaryPrimitives.WriteUInt16LittleEndian(span[32..], (ushort)(channels * 2));
|
||||
BinaryPrimitives.WriteUInt16LittleEndian(span[34..], 16);
|
||||
"data"u8.CopyTo(span[36..]);
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(span[40..], (uint)dataBytes);
|
||||
return file;
|
||||
}
|
||||
|
||||
private static byte[] BuildVag(int units, uint sampleRate)
|
||||
{
|
||||
var file = new byte[0x30 + (units * 16)];
|
||||
var span = file.AsSpan();
|
||||
BinaryPrimitives.WriteUInt32BigEndian(span, 0x56414770); // "VAGp"
|
||||
BinaryPrimitives.WriteUInt32BigEndian(span[0x0C..], (uint)(units * 16));
|
||||
BinaryPrimitives.WriteUInt32BigEndian(span[0x10..], sampleRate);
|
||||
span[0x1E] = 1;
|
||||
return file;
|
||||
}
|
||||
|
||||
private static int Parse(CpuContext ctx, FakeCpuMemory memory, byte[] image)
|
||||
{
|
||||
Assert.True(memory.TryWrite(DataAddress, image));
|
||||
ctx[CpuRegister.Rdi] = DataAddress;
|
||||
ctx[CpuRegister.Rsi] = (ulong)image.Length;
|
||||
ctx[CpuRegister.Rdx] = InfoAddress;
|
||||
return Ngs2Exports.Ngs2ParseWaveformData(ctx);
|
||||
}
|
||||
|
||||
private static uint Field(FakeCpuMemory memory, int offset)
|
||||
{
|
||||
Span<byte> bytes = stackalloc byte[4];
|
||||
Assert.True(memory.TryRead(InfoAddress + (ulong)offset, bytes));
|
||||
return BinaryPrimitives.ReadUInt32LittleEndian(bytes);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ParseWaveformData_DescribesAPcm16RiffWave()
|
||||
{
|
||||
var ctx = CreateContext(out var memory);
|
||||
|
||||
var result = Parse(ctx, memory, BuildPcm16Wave(frames: 64, channels: 2, sampleRate: 44100));
|
||||
|
||||
Assert.Equal(0, result);
|
||||
// SceNgs2WaveformFormat: waveformType@0, numChannels@4, sampleRate@8.
|
||||
Assert.Equal(WaveformTypePcmI16L, Field(memory, 0x00));
|
||||
Assert.Equal(2u, Field(memory, 0x04));
|
||||
Assert.Equal(44100u, Field(memory, 0x08));
|
||||
// dataOffset@0x18 / dataSize@0x1C.
|
||||
Assert.Equal(44u, Field(memory, 0x18));
|
||||
Assert.Equal(64u * 2 * 2, Field(memory, 0x1C));
|
||||
// numSamples@0x28 = dataSize / audioUnitSize.
|
||||
Assert.Equal(64u, Field(memory, 0x28));
|
||||
// audioUnitSize@0x2C = blockAlign, numAudioUnitSamples@0x30 = 1 for PCM,
|
||||
// numAudioUnitPerFrame@0x34 = 1.
|
||||
Assert.Equal(4u, Field(memory, 0x2C));
|
||||
Assert.Equal(1u, Field(memory, 0x30));
|
||||
Assert.Equal(1u, Field(memory, 0x34));
|
||||
// audioFrameSize@0x38 = audioUnitSize x numAudioUnitPerFrame.
|
||||
Assert.Equal(4u, Field(memory, 0x38));
|
||||
Assert.Equal(1u, Field(memory, 0x3C));
|
||||
// numDelaySamples@0x40 is 0 for PCM, numBlocks@0x44 = 1.
|
||||
Assert.Equal(0u, Field(memory, 0x40));
|
||||
Assert.Equal(1u, Field(memory, 0x44));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ParseWaveformData_PublishesTheFirstBlockOverTheWholeDataChunk()
|
||||
{
|
||||
var ctx = CreateContext(out var memory);
|
||||
|
||||
Assert.Equal(0, Parse(ctx, memory, BuildPcm16Wave(frames: 32, channels: 1, sampleRate: 48000)));
|
||||
|
||||
// aBlock[0] starts at 0x48: dataOffset (uintptr), dataSize (size_t),
|
||||
// numRepeats, numSkipSamples, numSamples.
|
||||
Span<byte> block = stackalloc byte[0x28];
|
||||
Assert.True(memory.TryRead(InfoAddress + 0x48, block));
|
||||
Assert.Equal(44UL, BinaryPrimitives.ReadUInt64LittleEndian(block));
|
||||
Assert.Equal(64UL, BinaryPrimitives.ReadUInt64LittleEndian(block[8..]));
|
||||
Assert.Equal(0u, BinaryPrimitives.ReadUInt32LittleEndian(block[0x10..]));
|
||||
Assert.Equal(32u, BinaryPrimitives.ReadUInt32LittleEndian(block[0x18..]));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ParseWaveformData_WritesExactlySizeofWaveformInfo()
|
||||
{
|
||||
var ctx = CreateContext(out var memory);
|
||||
Span<byte> paint = stackalloc byte[WaveformInfoSize + 0x10];
|
||||
paint.Fill(0x9C);
|
||||
Assert.True(memory.TryWrite(InfoAddress, paint));
|
||||
|
||||
Assert.Equal(0, Parse(ctx, memory, BuildPcm16Wave(frames: 8, channels: 2, sampleRate: 48000)));
|
||||
|
||||
Span<byte> tail = stackalloc byte[0x10];
|
||||
Assert.True(memory.TryRead(InfoAddress + WaveformInfoSize, tail));
|
||||
foreach (var value in tail)
|
||||
{
|
||||
Assert.Equal(0x9C, value);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ParseWaveformData_DescribesAVagContainer()
|
||||
{
|
||||
var ctx = CreateContext(out var memory);
|
||||
|
||||
var result = Parse(ctx, memory, BuildVag(units: 10, sampleRate: 22050));
|
||||
|
||||
Assert.Equal(0, result);
|
||||
Assert.Equal(WaveformTypeVag, Field(memory, 0x00));
|
||||
Assert.Equal(1u, Field(memory, 0x04));
|
||||
Assert.Equal(22050u, Field(memory, 0x08));
|
||||
Assert.Equal(0x30u, Field(memory, 0x18));
|
||||
Assert.Equal(160u, Field(memory, 0x1C));
|
||||
// 28 samples per 16-byte HE-VAG unit.
|
||||
Assert.Equal(280u, Field(memory, 0x28));
|
||||
Assert.Equal(16u, Field(memory, 0x2C));
|
||||
Assert.Equal(28u, Field(memory, 0x30));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ParseWaveformData_RejectsANullOutInfo()
|
||||
{
|
||||
var ctx = CreateContext(out _);
|
||||
ctx[CpuRegister.Rdi] = DataAddress;
|
||||
ctx[CpuRegister.Rsi] = 0x100;
|
||||
ctx[CpuRegister.Rdx] = 0;
|
||||
|
||||
Assert.Equal(InvalidOutAddress, Ngs2Exports.Ngs2ParseWaveformData(ctx));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ParseWaveformData_RejectsANullData()
|
||||
{
|
||||
var ctx = CreateContext(out _);
|
||||
ctx[CpuRegister.Rdi] = 0;
|
||||
ctx[CpuRegister.Rsi] = 0x100;
|
||||
ctx[CpuRegister.Rdx] = InfoAddress;
|
||||
|
||||
Assert.Equal(InvalidWaveformAddress, Ngs2Exports.Ngs2ParseWaveformData(ctx));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ParseWaveformData_RejectsAnUnknownContainer()
|
||||
{
|
||||
var ctx = CreateContext(out var memory);
|
||||
var junk = new byte[0x40];
|
||||
junk.AsSpan().Fill(0x5A);
|
||||
|
||||
Assert.Equal(InvalidWaveformData, Parse(ctx, memory, junk));
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue