diff --git a/src/SharpEmu.Core/Cpu/Native/DirectExecutionBackend.StackBounds.cs b/src/SharpEmu.Core/Cpu/Native/DirectExecutionBackend.StackBounds.cs index b6ec6e9..f5c713c 100644 --- a/src/SharpEmu.Core/Cpu/Native/DirectExecutionBackend.StackBounds.cs +++ b/src/SharpEmu.Core/Cpu/Native/DirectExecutionBackend.StackBounds.cs @@ -9,10 +9,23 @@ public sealed partial class DirectExecutionBackend { private const int HostStackStateBytes = 3 * sizeof(ulong); - private static unsafe bool TryGetGuestStackBounds(ulong stackPointer, out ulong bottom, out ulong top) + internal static unsafe bool TryGetGuestStackBounds(ulong stackPointer, out ulong bottom, out ulong top) { + if (OperatingSystem.IsWindows() && + QueryVirtualMemoryInformation(-1, (void*)stackPointer, 0, out var allocation, + (nuint)sizeof(StackMemoryRegionInformation), null) != 0 && + ((allocation.Flags & 1) == 0 || allocation.CommitSize == allocation.RegionSize) && + allocation.RegionSize != 0 && allocation.RegionSize <= ulong.MaxValue - allocation.AllocationBase && + stackPointer >= allocation.AllocationBase && stackPointer - allocation.AllocationBase < allocation.RegionSize) + { + bottom = allocation.AllocationBase; + top = bottom + allocation.RegionSize; + return true; + } + bottom = top = 0; - if (VirtualQuery((void*)stackPointer, out var region, (nuint)sizeof(MEMORY_BASIC_INFORMATION64)) == 0 || + var queried = VirtualQuery((void*)stackPointer, out var region, (nuint)sizeof(MEMORY_BASIC_INFORMATION64)); + if (queried == 0 || region.State != 0x1000 || region.RegionSize == 0 || region.RegionSize > ulong.MaxValue - region.BaseAddress) { return false; @@ -176,4 +189,18 @@ public sealed partial class DirectExecutionBackend [DllImport("kernel32.dll", ExactSpelling = true)] private static extern void GetCurrentThreadStackLimits(out ulong lowLimit, out ulong highLimit); + + [StructLayout(LayoutKind.Sequential)] + private struct StackMemoryRegionInformation + { + public ulong AllocationBase; + public uint AllocationProtect; + public uint Flags; + public ulong RegionSize; + public ulong CommitSize; + } + + [DllImport("api-ms-win-core-memory-l1-1-4.dll", ExactSpelling = true)] + private static extern unsafe int QueryVirtualMemoryInformation(nint process, void* address, int informationClass, + out StackMemoryRegionInformation information, nuint informationSize, nuint* returnSize); } diff --git a/src/SharpEmu.HLE/GpuMemory/GuestGpuMemory.cs b/src/SharpEmu.HLE/GpuMemory/GuestGpuMemory.cs index 4e55ca6..a99f4b5 100644 --- a/src/SharpEmu.HLE/GpuMemory/GuestGpuMemory.cs +++ b/src/SharpEmu.HLE/GpuMemory/GuestGpuMemory.cs @@ -30,6 +30,9 @@ public sealed class GuestGpuMemory : IDisposable private readonly PageGuard _pages; private readonly ReaderWriterLockSlim _spansLock = new(); private readonly SpanSet _spans = new(); + private long _spanVersion; + + public long SpanVersion => Interlocked.Read(ref _spanVersion); private sealed record GpuAttachment(IGpuQueueRelay? Gpu, IGpuTickScheduler? Scheduler); private readonly object _attachGate = new(); @@ -204,6 +207,7 @@ public sealed class GuestGpuMemory : IDisposable try { _spans.Add(address, size); + Interlocked.Increment(ref _spanVersion); } finally { @@ -276,6 +280,7 @@ public sealed class GuestGpuMemory : IDisposable try { _spans.Remove(address, size); + Interlocked.Increment(ref _spanVersion); } finally { diff --git a/src/SharpEmu.Libs/Audio/AjmExports.cs b/src/SharpEmu.Libs/Audio/AjmExports.cs index b7ad235..a9e09fb 100644 --- a/src/SharpEmu.Libs/Audio/AjmExports.cs +++ b/src/SharpEmu.Libs/Audio/AjmExports.cs @@ -590,7 +590,16 @@ public static class AjmExports return ctx.SetReturn(OrbisAjmErrorJobCreation); } - var status = TryGetInstance(instanceId, out _) ? 0 : Atrac9DecodeState.ResultInvalidParameter; + var status = TryGetInstance(instanceId, out var instance) ? 0 : Atrac9DecodeState.ResultInvalidParameter; + Span gapless = stackalloc byte[AjmSidebandGaplessDecodeBytes]; + if (status == 0 && gaplessAddress != 0 && ctx.Memory.TryRead(gaplessAddress, gapless)) + { + instance!.Atrac9?.SetGapless( + BinaryPrimitives.ReadUInt32LittleEndian(gapless), + BinaryPrimitives.ReadUInt16LittleEndian(gapless[4..]), + reset != 0); + } + WriteBasicResult(ctx, resultAddress, status); Trace( $"batch_job_set_gapless_decode instance=0x{instanceId:X8} " + @@ -1163,6 +1172,10 @@ public static class AjmExports if ((flags & AjmJobSidebandFlagGaplessDecode) != 0 && (ulong)(offset + AjmSidebandGaplessDecodeBytes) <= size) { + var gapless = instance?.Atrac9?.Gapless ?? default; + BinaryPrimitives.WriteUInt32LittleEndian(sideband[offset..], gapless.TotalSamples); + BinaryPrimitives.WriteUInt16LittleEndian(sideband[(offset + 4)..], gapless.SkipSamples); + BinaryPrimitives.WriteUInt16LittleEndian(sideband[(offset + 6)..], gapless.SkippedSamples); offset += AjmSidebandGaplessDecodeBytes; } diff --git a/src/SharpEmu.Libs/Audio/Atrac9DecodeState.cs b/src/SharpEmu.Libs/Audio/Atrac9DecodeState.cs index 6f02d3b..92feeb7 100644 --- a/src/SharpEmu.Libs/Audio/Atrac9DecodeState.cs +++ b/src/SharpEmu.Libs/Audio/Atrac9DecodeState.cs @@ -20,6 +20,8 @@ internal readonly record struct Atrac9DecodeResult( ulong TotalDecodedSamples, uint Frames); +internal readonly record struct Atrac9Gapless(uint TotalSamples, ushort SkipSamples, ushort SkippedSamples); + internal sealed class Atrac9DecodeState { internal const int ResultNotInitialized = 0x00000001; @@ -47,6 +49,33 @@ internal sealed class Atrac9DecodeState private int _containerHeaderLength; private int _compressedLength; private ulong _totalDecodedSamples; + private Atrac9Gapless _gaplessInit; + private Atrac9Gapless _gapless; + + public Atrac9Gapless Gapless + { + get + { + lock (_gate) + { + return _gapless; + } + } + } + + public void SetGapless(uint totalSamples, ushort skipSamples, bool reset) + { + lock (_gate) + { + _gaplessInit = new Atrac9Gapless(totalSamples, skipSamples, 0); + if (reset || _gapless == default) + { + _gapless = _gaplessInit; + } + } + } + + private bool HasSampleLimit => _gaplessInit.TotalSamples is not 0 and not uint.MaxValue; public Atrac9Config? Config { @@ -82,6 +111,7 @@ internal sealed class Atrac9DecodeState _compressedLength = 0; _totalDecodedSamples = 0; _containerHeaderLength = 0; + _gapless = _gaplessInit; Trace( $"initialized config={Convert.ToHexString(normalizedConfig)} channels={config.ChannelCount} " + $"rate={config.SampleRate} frame_samples={config.FrameSamples} " + @@ -114,6 +144,7 @@ internal sealed class Atrac9DecodeState _compressedLength = 0; _totalDecodedSamples = 0; _containerHeaderLength = 0; + _gapless = _gaplessInit; if (_compressed is not null) { Array.Clear(_compressed); @@ -153,7 +184,6 @@ internal sealed class Atrac9DecodeState } var bytesPerSample = GetBytesPerSample(encoding); - var outputBytesPerSuperframe = checked(config.SuperframeSamples * channels * bytesPerSample); var consumed = 0; var written = 0; uint frames = 0; @@ -162,6 +192,11 @@ internal sealed class Atrac9DecodeState while (_compressedLength == config.SuperframeBytes || consumed < input.Length) { + if (HasSampleLimit && _gapless.TotalSamples == 0) + { + break; + } + // 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 @@ -202,7 +237,15 @@ internal sealed class Atrac9DecodeState break; } - if (output.Length - written < outputBytesPerSuperframe) + var skip = Math.Min(config.SuperframeSamples, (int)_gapless.SkipSamples); + var samples = config.SuperframeSamples - skip; + if (HasSampleLimit) + { + samples = (int)Math.Min((uint)samples, _gapless.TotalSamples); + } + + var outputBytes = checked(samples * channels * bytesPerSample); + if (output.Length - written < outputBytes) { status |= ResultNotEnoughRoom; break; @@ -231,14 +274,19 @@ internal sealed class Atrac9DecodeState WriteInterleaved( _planarPcm, - output.Slice(written, outputBytesPerSuperframe), - config.SuperframeSamples, + output.Slice(written, outputBytes), + skip, + samples, channels, encoding); - written += outputBytesPerSuperframe; + _gapless = new Atrac9Gapless( + HasSampleLimit ? _gapless.TotalSamples - (uint)samples : _gapless.TotalSamples, + (ushort)(_gapless.SkipSamples - skip), + (ushort)Math.Min(ushort.MaxValue, _gapless.SkippedSamples + config.SuperframeSamples - samples)); + written += outputBytes; _compressedLength = 0; - _totalDecodedSamples += unchecked((uint)config.SuperframeSamples); + _totalDecodedSamples += unchecked((uint)samples); frames += unchecked((uint)config.FramesPerSuperframe); if (!multipleFrames) @@ -353,12 +401,13 @@ internal sealed class Atrac9DecodeState private static void WriteInterleaved( short[][] source, Span destination, + int firstSample, int samples, int channels, Atrac9PcmEncoding encoding) { var offset = 0; - for (var sample = 0; sample < samples; sample++) + for (var sample = firstSample; sample < firstSample + samples; sample++) { for (var channel = 0; channel < channels; channel++) { diff --git a/src/SharpEmu.Libs/Audio/AudioOut2Exports.cs b/src/SharpEmu.Libs/Audio/AudioOut2Exports.cs index 07b17a5..59da5f3 100644 --- a/src/SharpEmu.Libs/Audio/AudioOut2Exports.cs +++ b/src/SharpEmu.Libs/Audio/AudioOut2Exports.cs @@ -60,6 +60,14 @@ public static class AudioOut2Exports // SceAudioOut2Attribute: u32 attributeId, 4 pad, const void* value, size_t valueSize. private const int AttributeEntrySize = 0x18; private const uint PortAttributeIdPcm = 0; + private const uint PortAttributeIdGain = 1; + private const uint PortAttributeIdPassthrough = 5; + private const uint PortAttributeIdAmbisonics = 8; + internal const uint AmbisonicsNone = uint.MaxValue; + internal const uint AmbisonicsAcnBase = 64; + internal const uint PassthroughLeft = 1; + internal const uint PassthroughRight = 2; + private const float HalfPower = 0.70710677f; private const ushort PortStateOutputConnectedPrimary = 0x01; // A host device that already holds this much audio is pacing the context; anything beyond the @@ -197,6 +205,9 @@ public static class AudioOut2Exports public uint SamplingFrequency { get; } public uint GrainSamples { get; } public ulong PcmAddress; + public float Gain = 1f; + public uint Ambisonics = AmbisonicsNone; + public uint Passthrough; public int PcmPending; @@ -545,6 +556,7 @@ public static class AudioOut2Exports Span entry = stackalloc byte[AttributeEntrySize]; Span pcm = stackalloc byte[8]; + Span word = stackalloc byte[sizeof(uint)]; for (uint i = 0; i < attributeCount; i++) { if (!ctx.Memory.TryRead(attributesAddress + (i * AttributeEntrySize), entry)) @@ -555,7 +567,41 @@ public static class AudioOut2Exports var attributeId = BinaryPrimitives.ReadUInt32LittleEndian(entry); var valueAddress = BinaryPrimitives.ReadUInt64LittleEndian(entry[0x08..]); var valueSize = BinaryPrimitives.ReadUInt64LittleEndian(entry[0x10..]); - if (attributeId != PortAttributeIdPcm || valueAddress == 0 || valueSize < 8) + if (valueAddress == 0) + { + continue; + } + + if (attributeId is PortAttributeIdGain or PortAttributeIdPassthrough or PortAttributeIdAmbisonics) + { + if (valueSize < sizeof(uint) || !ctx.Memory.TryRead(valueAddress, word)) + { + continue; + } + + var value = BinaryPrimitives.ReadUInt32LittleEndian(word); + switch (attributeId) + { + case PortAttributeIdGain: + var gain = BitConverter.UInt32BitsToSingle(value); + if (float.IsFinite(gain)) + { + port.Gain = gain; + } + + break; + case PortAttributeIdPassthrough: + port.Passthrough = value; + break; + default: + port.Ambisonics = value; + break; + } + + continue; + } + + if (attributeId != PortAttributeIdPcm || valueSize < 8) { continue; } @@ -1087,7 +1133,9 @@ public static class AudioOut2Exports if (port.ContextHandle != context.Handle || port.PcmAddress == 0 || Interlocked.Exchange(ref port.PcmPending, 0) == 0 || - !TryDecodeDataFormat(port.DataFormat, out var ch, out var bps, out var isFloat)) + !TryDecodeDataFormat(port.DataFormat, out var ch, out var bps, out var isFloat) || + !TryGetStereoGains(port.PortType, ch, port.Gain, port.Ambisonics, port.Passthrough, + out var leftGain, out var rightGain)) { continue; } @@ -1111,7 +1159,8 @@ public static class AudioOut2Exports ch, bps, isFloat, - additive: mixedPorts > 0); + leftGain, + rightGain); mixedPorts++; } @@ -1162,20 +1211,69 @@ public static class AudioOut2Exports } } - private static bool IsMainOrBgmPort(ushort portType) + internal static bool RoutesToSpeakers(ushort portType) => (portType & 0xFF) is 0 or 1 or 2; + + internal static (float Gain, uint Ambisonics, uint Passthrough)? PortMixAttributes(ulong portHandle) => + Ports.TryGetValue(portHandle, out var port) ? (port.Gain, port.Ambisonics, port.Passthrough) : null; + + internal static bool TryGetStereoGains( + ushort portType, + int channels, + float gain, + uint ambisonics, + uint passthrough, + out float left, + out float right) { - var kind = portType & 0xFF; - return kind is 0 or 1; + left = 0f; + right = 0f; + if (!RoutesToSpeakers(portType) || gain == 0f) + { + return false; + } + + if (!IsObjectPort(portType) || channels != 1) + { + left = gain; + right = gain; + return true; + } + + if (ambisonics == AmbisonicsNone) + { + left = passthrough == PassthroughRight ? 0f : passthrough == PassthroughLeft ? gain : gain * HalfPower; + right = passthrough == PassthroughLeft ? 0f : passthrough == PassthroughRight ? gain : gain * HalfPower; + return true; + } + + var acn = ambisonics >= AmbisonicsAcnBase; + var component = acn ? ambisonics - AmbisonicsAcnBase : ambisonics; + if (component == 0) + { + left = acn ? gain * HalfPower : gain; + right = left; + return true; + } + + if (component == (acn ? 1u : 2u)) + { + left = gain * HalfPower; + right = -left; + return true; + } + + return false; } - private static void MixPortIntoStereo( + internal static void MixPortIntoStereo( ReadOnlySpan source, Span mix, int frames, int channels, int bytesPerSample, bool isFloat, - bool additive) + float leftGain, + float rightGain) { var frameSize = channels * bytesPerSample; for (var frame = 0; frame < frames; frame++) @@ -1204,16 +1302,8 @@ public static class AudioOut2Exports : ReadNormalizedSample(frameBytes, 1, bytesPerSample, isFloat); } - if (additive) - { - mix[frame * 2] += left; - mix[(frame * 2) + 1] += right; - } - else - { - mix[frame * 2] = left; - mix[(frame * 2) + 1] = right; - } + mix[frame * 2] += left * leftGain; + mix[(frame * 2) + 1] += right * rightGain; } } diff --git a/src/SharpEmu.Libs/Gpu/Buffers/GuestBufferCache.cs b/src/SharpEmu.Libs/Gpu/Buffers/GuestBufferCache.cs index 8bd5cd9..4ee950a 100644 --- a/src/SharpEmu.Libs/Gpu/Buffers/GuestBufferCache.cs +++ b/src/SharpEmu.Libs/Gpu/Buffers/GuestBufferCache.cs @@ -557,7 +557,21 @@ public sealed unsafe class GuestBufferCache : IGuestBufferStore, IDisposable private ulong _bdaTouchTick = ulong.MaxValue; private ulong _bdaTouchMapping; + public static ulong MappingKey(IReadOnlyCollection spans) + { + var mapping = (ulong)spans.Count; + foreach (var span in spans) + mapping = (mapping ^ span.Address ^ (span.Size << 17)) * 0x100000001B3UL; + return mapping; + } + public void PrepareBda(IEnumerable mapped) + { + var spans = mapped as IReadOnlyCollection ?? mapped.ToList(); + PrepareBda(spans, MappingKey(spans)); + } + + public void PrepareBda(IReadOnlyCollection mapped, ulong mapping) { var traceAddress = GuestGpuMemoryHook.TraceAddress; var traceCovered = false; @@ -572,10 +586,7 @@ public sealed unsafe class GuestBufferCache : IGuestBufferStore, IDisposable } else { - var spans = mapped as IReadOnlyCollection ?? mapped.ToList(); - var mapping = (ulong)spans.Count; - foreach (var span in spans) - mapping = (mapping ^ span.Address ^ (span.Size << 17)) * 0x100000001B3UL; + var spans = mapped; if (_retirementPolicy.CurrentTick != _bdaTouchTick || mapping != _bdaTouchMapping) { foreach (var span in spans) diff --git a/src/SharpEmu.Libs/VideoOut/VulkanVideoPresenter.Descriptors.cs b/src/SharpEmu.Libs/VideoOut/VulkanVideoPresenter.Descriptors.cs index b93ca9b..885b7f5 100644 --- a/src/SharpEmu.Libs/VideoOut/VulkanVideoPresenter.Descriptors.cs +++ b/src/SharpEmu.Libs/VideoOut/VulkanVideoPresenter.Descriptors.cs @@ -402,8 +402,7 @@ internal static unsafe partial class VulkanVideoPresenter } using var profileScope = BufferUploadProfile.BeginSweep(vertexProgramHash, pixelProgramHash, computeProgramHash); var memory = GuestGpuMemoryHook.Current ?? throw SubmissionScheduler.Fatal("A device-address program needs the guest GPU memory registry."); - var spans = new List(); - memory.ForEachSpan((address, size) => spans.Add(new GuestSpan(address, size))); + var spans = DeviceAddressSpans(memory); var traceAddress = GuestGpuMemoryHook.TraceAddress; if (traceAddress != 0) { @@ -420,7 +419,29 @@ internal static unsafe partial class VulkanVideoPresenter if (traceAddress != 0 && !memory.Covers(traceAddress, 1)) GuestGpuMemoryHook.Trace(traceAddress, 1, $"device-address-mapping-check readable={_guestMemory.CanRead(traceAddress, 1)} backed={_guestBacking.IsBackedView(traceAddress)}"); - _bufferCache.PrepareBda(spans); + _bufferCache.PrepareBda(spans, _bdaSpanMapping); + } + + private List? _bdaSpans; + private GuestGpuMemory? _bdaSpanMemory; + private long _bdaSpanVersion = -1; + private ulong _bdaSpanMapping; + + private List DeviceAddressSpans(GuestGpuMemory memory) + { + var version = memory.SpanVersion; + if (_bdaSpans is { } cached && ReferenceEquals(_bdaSpanMemory, memory) && _bdaSpanVersion == version) + { + return cached; + } + + var spans = new List(); + memory.ForEachSpan((address, size) => spans.Add(new GuestSpan(address, size))); + _bdaSpans = spans; + _bdaSpanMemory = memory; + _bdaSpanVersion = version; + _bdaSpanMapping = GuestBufferCache.MappingKey(spans); + return spans; } public void BindResources(IPreparedBindings prepared) diff --git a/src/SharpEmu.ShaderCompiler/Resources/ResourceMaterializer.cs b/src/SharpEmu.ShaderCompiler/Resources/ResourceMaterializer.cs index 6a48a5b..b65eb7e 100644 --- a/src/SharpEmu.ShaderCompiler/Resources/ResourceMaterializer.cs +++ b/src/SharpEmu.ShaderCompiler/Resources/ResourceMaterializer.cs @@ -473,6 +473,19 @@ public static class ResourceMaterializer return value.ToString(); } + private static readonly HashSet NulledSampledFormats = new(); + + private static void ReportNulledSampledFormat(uint format) + { + lock (NulledSampledFormats) + { + if (NulledSampledFormats.Add(format)) + { + Console.Error.WriteLine($"[GPU][WARN] A sampled image descriptor uses unsupported format {format}; it is bound as a null texture."); + } + } + } + private static bool NullImageDescriptor(ReadOnlySpan descriptor) => descriptor[0] == 0 && (descriptor[1] & 0xFF) == 0; @@ -1054,7 +1067,19 @@ public static class ResourceMaterializer numericClass = ImageNumericClass.Uint; } } - else if (numericClass == ImageNumericClass.Unsupported || (baseImage.DepthCompare && numericClass != ImageNumericClass.Float)) + else if (numericClass == ImageNumericClass.Unsupported) + { + ReportNulledSampledFormat(format); + Array.Clear(descriptor); + images[index] = image with + { + NumericClass = ImageNumericClass.Float, + Dimension = ImageDimension.Dim2D, + Cube = false, + }; + continue; + } + else if (baseImage.DepthCompare && numericClass != ImageNumericClass.Float) { return Fail($"sampled image descriptor {index} uses unsupported format {format}"); } diff --git a/tests/SharpEmu.Libs.Tests/Audio/Atrac9GaplessTests.cs b/tests/SharpEmu.Libs.Tests/Audio/Atrac9GaplessTests.cs new file mode 100644 index 0000000..2004c99 --- /dev/null +++ b/tests/SharpEmu.Libs.Tests/Audio/Atrac9GaplessTests.cs @@ -0,0 +1,113 @@ +// Copyright (C) 2026 SharpEmu Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +using SharpEmu.Libs.Audio; +using Xunit; + +namespace SharpEmu.Libs.Tests.Audio; + +public sealed class Atrac9GaplessTests +{ + private static readonly byte[] MonoConfig = [0xFE, 0x70, 0x17, 0xE0]; + private const int SuperframeBytes = 192; + private const int SuperframeSamples = 256; + private static readonly byte[] SilentFrame = [0x00, 0x84, 0x00, 0x40]; + + private static Atrac9DecodeState CreateDecoder() + { + var decoder = new Atrac9DecodeState(); + Assert.True(decoder.TryInitialize(MonoConfig)); + return decoder; + } + + private static Atrac9DecodeResult DecodeSuperframes(Atrac9DecodeState decoder, int superframes, int outputSamples) + { + var input = new byte[SuperframeBytes * superframes]; + for (var superframe = 0; superframe < superframes; superframe++) + { + SilentFrame.CopyTo(input, superframe * SuperframeBytes); + } + + var output = new byte[outputSamples * sizeof(short)]; + return decoder.Decode(input, output, Atrac9PcmEncoding.Signed16, requestedChannels: 0, multipleFrames: true); + } + + [Fact] + public void WithoutAWindowEverySuperframeSampleIsWritten() + { + var decoder = CreateDecoder(); + + var result = DecodeSuperframes(decoder, 2, SuperframeSamples * 2); + + Assert.Equal(SuperframeSamples * 2 * sizeof(short), result.OutputWritten); + Assert.Equal((ulong)(SuperframeSamples * 2), result.TotalDecodedSamples); + Assert.Equal(default, decoder.Gapless); + } + + [Fact] + public void TheWindowSkipsTheLeadInAndStopsAtTheTotal() + { + var decoder = CreateDecoder(); + decoder.SetGapless(totalSamples: 500, skipSamples: 100, reset: false); + + var first = DecodeSuperframes(decoder, 1, SuperframeSamples); + Assert.Equal(156 * sizeof(short), first.OutputWritten); + Assert.Equal(new Atrac9Gapless(344, 0, 100), decoder.Gapless); + + var rest = DecodeSuperframes(decoder, 3, SuperframeSamples * 3); + Assert.Equal((256 + 88) * sizeof(short), rest.OutputWritten); + Assert.Equal(SuperframeBytes * 2, rest.InputConsumed); + Assert.Equal(new Atrac9Gapless(0, 0, 100 + 256 - 88), decoder.Gapless); + Assert.Equal(500ul, rest.TotalDecodedSamples); + + var ended = DecodeSuperframes(decoder, 1, SuperframeSamples); + Assert.Equal(0, ended.OutputWritten); + Assert.Equal(0, ended.InputConsumed); + } + + [Fact] + public void ALeadInLongerThanASuperframeWritesNothingForIt() + { + var decoder = CreateDecoder(); + decoder.SetGapless(totalSamples: 0, skipSamples: 300, reset: false); + + var first = DecodeSuperframes(decoder, 1, 0); + Assert.Equal(0, first.OutputWritten); + Assert.Equal(SuperframeBytes, first.InputConsumed); + Assert.Equal(new Atrac9Gapless(0, 44, 256), decoder.Gapless); + + var second = DecodeSuperframes(decoder, 1, SuperframeSamples); + Assert.Equal((256 - 44) * sizeof(short), second.OutputWritten); + Assert.Equal(new Atrac9Gapless(0, 0, 300), decoder.Gapless); + } + + [Fact] + public void TheRoomCheckUsesTheTrimmedSize() + { + var decoder = CreateDecoder(); + decoder.SetGapless(totalSamples: 100, skipSamples: 0, reset: false); + + var result = DecodeSuperframes(decoder, 1, 100); + + Assert.Equal(100 * sizeof(short), result.OutputWritten); + Assert.Equal(0, result.Status & Atrac9DecodeState.ResultNotEnoughRoom); + } + + [Fact] + public void ResetRestoresTheWindowAndASetWithoutResetKeepsProgress() + { + var decoder = CreateDecoder(); + decoder.SetGapless(totalSamples: 500, skipSamples: 100, reset: false); + DecodeSuperframes(decoder, 1, SuperframeSamples); + + decoder.SetGapless(totalSamples: 500, skipSamples: 100, reset: false); + Assert.Equal(new Atrac9Gapless(344, 0, 100), decoder.Gapless); + + decoder.Reset(); + Assert.Equal(new Atrac9Gapless(500, 100, 0), decoder.Gapless); + + DecodeSuperframes(decoder, 1, SuperframeSamples); + decoder.SetGapless(totalSamples: 2000, skipSamples: 10, reset: true); + Assert.Equal(new Atrac9Gapless(2000, 10, 0), decoder.Gapless); + } +} diff --git a/tests/SharpEmu.Libs.Tests/Audio/AudioOut2MixTests.cs b/tests/SharpEmu.Libs.Tests/Audio/AudioOut2MixTests.cs new file mode 100644 index 0000000..a59626c --- /dev/null +++ b/tests/SharpEmu.Libs.Tests/Audio/AudioOut2MixTests.cs @@ -0,0 +1,158 @@ +// Copyright (C) 2026 SharpEmu Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +using System.Buffers.Binary; +using SharpEmu.HLE; +using SharpEmu.Libs.Audio; +using Xunit; + +namespace SharpEmu.Libs.Tests.Audio; + +public sealed class AudioOut2MixTests +{ + private const ulong MemoryBase = 0x1_0000_0000; + private const ushort ObjectMain = 0x100; + private const float HalfPower = 0.70710677f; + + [Theory] + [InlineData((ushort)0, true)] + [InlineData((ushort)1, true)] + [InlineData((ushort)2, true)] + [InlineData(ObjectMain, true)] + [InlineData((ushort)3, false)] + [InlineData((ushort)4, false)] + [InlineData((ushort)5, false)] + [InlineData((ushort)6, false)] + public void OnlyMainBgmAndVoicePortsReachTheSpeakers(ushort portType, bool expected) + { + Assert.Equal(expected, AudioOut2Exports.RoutesToSpeakers(portType)); + Assert.Equal(expected, AudioOut2Exports.TryGetStereoGains(portType, 2, 1f, AudioOut2Exports.AmbisonicsNone, 0, out _, out _)); + } + + [Theory] + [InlineData(64u, HalfPower, HalfPower)] + [InlineData(65u, HalfPower, -HalfPower)] + [InlineData(0u, 1f, 1f)] + [InlineData(2u, HalfPower, -HalfPower)] + public void FirstOrderAmbisonicsDecodeToStereoCardioids(uint ambisonics, float left, float right) + { + Assert.True(AudioOut2Exports.TryGetStereoGains(ObjectMain, 1, 0.5f, ambisonics, 0, out var leftGain, out var rightGain)); + Assert.Equal(left * 0.5f, leftGain, 5); + Assert.Equal(right * 0.5f, rightGain, 5); + } + + [Theory] + [InlineData(66u)] + [InlineData(67u)] + [InlineData(99u)] + [InlineData(1u)] + [InlineData(3u)] + [InlineData(15u)] + public void HigherAmbisonicComponentsDoNotFeedStereo(uint ambisonics) + { + Assert.False(AudioOut2Exports.TryGetStereoGains(ObjectMain, 1, 1f, ambisonics, 0, out _, out _)); + } + + [Fact] + public void PlainObjectsPlayCentredOrPassThroughToOneSide() + { + Assert.True(AudioOut2Exports.TryGetStereoGains(ObjectMain, 1, 1f, AudioOut2Exports.AmbisonicsNone, 0, out var left, out var right)); + Assert.Equal(HalfPower, left, 5); + Assert.Equal(HalfPower, right, 5); + Assert.True(AudioOut2Exports.TryGetStereoGains(ObjectMain, 1, 1f, AudioOut2Exports.AmbisonicsNone, AudioOut2Exports.PassthroughLeft, out left, out right)); + Assert.Equal((1f, 0f), (left, right)); + Assert.True(AudioOut2Exports.TryGetStereoGains(ObjectMain, 1, 1f, AudioOut2Exports.AmbisonicsNone, AudioOut2Exports.PassthroughRight, out left, out right)); + Assert.Equal((0f, 1f), (left, right)); + Assert.False(AudioOut2Exports.TryGetStereoGains(ObjectMain, 1, 0f, AudioOut2Exports.AmbisonicsNone, 0, out _, out _)); + } + + [Fact] + public void FifthOrderSceneDecodesASourceToItsSide() + { + const int frames = 4; + var leftSource = DecodeScene(frames, azimuthSine: 1f); + var centreSource = DecodeScene(frames, azimuthSine: 0f); + + Assert.Equal(2f * HalfPower, leftSource[0], 4); + Assert.Equal(0f, leftSource[1], 4); + Assert.Equal(HalfPower, centreSource[0], 4); + Assert.Equal(HalfPower, centreSource[1], 4); + } + + [Fact] + public void SetAttributesStoresGainAmbisonicsAndPassthrough() + { + var memory = new FakeCpuMemory(MemoryBase, 0x1000); + var ctx = new CpuContext(memory, Generation.Gen5); + var param = new byte[0x40]; + BinaryPrimitives.WriteUInt16LittleEndian(param, ObjectMain); + BinaryPrimitives.WriteUInt32LittleEndian(param.AsSpan(4), 0x100); + Assert.True(memory.TryWrite(MemoryBase, param)); + ctx[CpuRegister.Rdi] = 0; + ctx[CpuRegister.Rsi] = MemoryBase; + ctx[CpuRegister.Rdx] = MemoryBase + 0x80; + Assert.Equal(0, AudioOut2Exports.AudioOut2PortCreate(ctx)); + var handle = BinaryPrimitives.ReadUInt64LittleEndian(Read(memory, MemoryBase + 0x80, 8)); + + var values = new byte[12]; + BinaryPrimitives.WriteSingleLittleEndian(values, 0.25f); + BinaryPrimitives.WriteUInt32LittleEndian(values.AsSpan(4), 65); + BinaryPrimitives.WriteUInt32LittleEndian(values.AsSpan(8), AudioOut2Exports.PassthroughLeft); + Assert.True(memory.TryWrite(MemoryBase + 0x100, values)); + var attributes = new byte[0x18 * 3]; + WriteAttribute(attributes, 0, 1, MemoryBase + 0x100); + WriteAttribute(attributes, 1, 8, MemoryBase + 0x104); + WriteAttribute(attributes, 2, 5, MemoryBase + 0x108); + Assert.True(memory.TryWrite(MemoryBase + 0x200, attributes)); + ctx[CpuRegister.Rdi] = handle; + ctx[CpuRegister.Rsi] = MemoryBase + 0x200; + ctx[CpuRegister.Rdx] = 3; + + Assert.Equal(0, AudioOut2Exports.AudioOut2PortSetAttributes(ctx)); + + Assert.Equal((0.25f, 65u, AudioOut2Exports.PassthroughLeft), AudioOut2Exports.PortMixAttributes(handle)); + ctx[CpuRegister.Rdi] = handle; + AudioOut2Exports.AudioOut2PortDestroy(ctx); + } + + private static float[] DecodeScene(int frames, float azimuthSine) + { + var mix = new float[frames * 2]; + var source = new byte[frames * sizeof(float)]; + for (var acn = 0u; acn < 36; acn++) + { + var component = acn switch + { + 0 => 1f, + 1 => azimuthSine, + _ => 0.6f, + }; + for (var frame = 0; frame < frames; frame++) + { + BinaryPrimitives.WriteSingleLittleEndian(source.AsSpan(frame * sizeof(float)), component); + } + + if (AudioOut2Exports.TryGetStereoGains(ObjectMain, 1, 1f, AudioOut2Exports.AmbisonicsAcnBase + acn, 0, out var left, out var right)) + { + AudioOut2Exports.MixPortIntoStereo(source, mix, frames, 1, sizeof(float), true, left, right); + } + } + + return mix; + } + + private static void WriteAttribute(byte[] attributes, int index, uint id, ulong value) + { + var entry = attributes.AsSpan(index * 0x18, 0x18); + BinaryPrimitives.WriteUInt32LittleEndian(entry, id); + BinaryPrimitives.WriteUInt64LittleEndian(entry[0x08..], value); + BinaryPrimitives.WriteUInt64LittleEndian(entry[0x10..], sizeof(uint)); + } + + private static byte[] Read(FakeCpuMemory memory, ulong address, int length) + { + var bytes = new byte[length]; + Assert.True(memory.TryRead(address, bytes)); + return bytes; + } +} diff --git a/tests/SharpEmu.Libs.Tests/Cpu/GuestStackBoundsTests.cs b/tests/SharpEmu.Libs.Tests/Cpu/GuestStackBoundsTests.cs new file mode 100644 index 0000000..b8c3d4a --- /dev/null +++ b/tests/SharpEmu.Libs.Tests/Cpu/GuestStackBoundsTests.cs @@ -0,0 +1,44 @@ +// Copyright (C) 2026 SharpEmu Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +using SharpEmu.Core.Cpu.Native; +using SharpEmu.HLE; +using Xunit; + +namespace SharpEmu.Libs.Tests.Cpu; + +public sealed class GuestStackBoundsTests +{ + [Fact] + public unsafe void AllocationQueryHandlesProtectionSplitsAndFallsBackForPartialCommit() + { + if (!OperatingSystem.IsWindows()) return; + + var pageSize = (nuint)Environment.SystemPageSize; + var memory = (byte*)HostMemory.Alloc(null, pageSize * 3, + HostMemory.MEM_RESERVE | HostMemory.MEM_COMMIT, HostMemory.PAGE_READWRITE); + Assert.True(memory != null); + try + { + Assert.True(HostMemory.Protect(memory + pageSize, pageSize, HostMemory.PAGE_READONLY, out _)); + // VirtualQuery at the last page would return only that page, not the allocation. + Assert.True(DirectExecutionBackend.TryGetGuestStackBounds((ulong)(memory + pageSize * 2 + 16), + out var bottom, out var top)); + Assert.Equal((ulong)memory, bottom); + Assert.Equal((ulong)(memory + pageSize * 3), top); + + Assert.True(HostMemory.Free(memory, pageSize, 0x4000)); // MEM_DECOMMIT + Assert.True(DirectExecutionBackend.TryGetGuestStackBounds((ulong)(memory + pageSize * 2 + 16), + out bottom, out top)); + Assert.Equal((ulong)(memory + pageSize * 2), bottom); + Assert.Equal((ulong)(memory + pageSize * 3), top); + Assert.False(DirectExecutionBackend.TryGetGuestStackBounds((ulong)memory, out _, out _)); + } + finally + { + Assert.True(HostMemory.Free(memory, 0, HostMemory.MEM_RELEASE)); + } + + Assert.False(DirectExecutionBackend.TryGetGuestStackBounds(0, out _, out _)); + } +}