diff --git a/src/SharpEmu.Libs/Ngs2/Ngs2Exports.cs b/src/SharpEmu.Libs/Ngs2/Ngs2Exports.cs index 41a3b26..ecf9112 100644 --- a/src/SharpEmu.Libs/Ngs2/Ngs2Exports.cs +++ b/src/SharpEmu.Libs/Ngs2/Ngs2Exports.cs @@ -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.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.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 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 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 buffer = stackalloc byte[MaximumWaveformPathBytes]; + var length = 0; + Span 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"), diff --git a/src/SharpEmu.Libs/Ngs2/Ngs2WaveformParser.cs b/src/SharpEmu.Libs/Ngs2/Ngs2WaveformParser.cs new file mode 100644 index 0000000..e07c721 --- /dev/null +++ b/src/SharpEmu.Libs/Ngs2/Ngs2WaveformParser.cs @@ -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 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 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 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 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 data) => + Ngs2VagDecoder.IsVag(data) || + (data.Length >= 12 && + data[..4].SequenceEqual("RIFF"u8) && + data.Slice(8, 4).SequenceEqual("WAVE"u8)); + + internal static bool TryParse(ReadOnlySpan 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 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 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 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 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; + } +} diff --git a/tests/SharpEmu.Libs.Tests/Ngs2/Ngs2ParseWaveformExportsTests.cs b/tests/SharpEmu.Libs.Tests/Ngs2/Ngs2ParseWaveformExportsTests.cs new file mode 100644 index 0000000..3d6afad --- /dev/null +++ b/tests/SharpEmu.Libs.Tests/Ngs2/Ngs2ParseWaveformExportsTests.cs @@ -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 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 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 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 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)); + } +}