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..de77a4b 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 @@ -131,6 +139,9 @@ public static class AudioOut2Exports return ahead <= 0 ? 0 : (uint)Math.Min((ahead + GrainTicks - 1) / GrainTicks, QueueDepth); } + public double DeviceSlotFreeMilliseconds => + DeviceSlotFreeThreshold(GrainSamples, Frequency, QueueDepth); + // Grains the device holds beyond the cushion, at most QueueDepth. public uint DeviceQueuedGrains(int queuedMilliseconds) { @@ -197,6 +208,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; @@ -419,6 +433,14 @@ public static class AudioOut2Exports { context.DevicePaced = true; } + + if (submitted || advancedGrain == 1) + { + if (blocking != 0) + { + WaitForDeviceSlot(context); + } + } else { // Push is the blocking point of a grain: it waits for the grain an Advance claimed, or @@ -545,6 +567,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 +578,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; } @@ -1050,6 +1107,28 @@ public static class AudioOut2Exports } } + internal static double DeviceSlotFreeThreshold(uint grainSamples, uint frequency, uint queueDepth) => + DeviceCushionMilliseconds + (grainSamples * 1000.0 / frequency * Math.Max((long)queueDepth - 1, 0)); + + private static void WaitForDeviceSlot(ContextState context) + { + var backend = ResolveContextBackend(context, out _); + if (backend is null) + { + return; + } + + var limit = context.DeviceSlotFreeMilliseconds; + var deadline = Stopwatch.GetTimestamp() + (Stopwatch.Frequency / 4); + int queued; + while ((queued = backend.QueuedMilliseconds) > limit && + Stopwatch.GetTimestamp() < deadline && + !HostSessionControl.IsShutdownRequested) + { + Thread.Sleep((int)Math.Clamp(Math.Ceiling(queued - limit), 1, 50)); + } + } + private static uint QueuedGrains(ContextState context) { if (context.DevicePaced) @@ -1087,7 +1166,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 +1192,8 @@ public static class AudioOut2Exports ch, bps, isFloat, - additive: mixedPorts > 0); + leftGain, + rightGain); mixedPorts++; } @@ -1162,20 +1244,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 +1335,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/Diagnostics/DccWriterTrace.cs b/src/SharpEmu.Libs/Diagnostics/DccWriterTrace.cs index 88efe4f..ed00daf 100644 --- a/src/SharpEmu.Libs/Diagnostics/DccWriterTrace.cs +++ b/src/SharpEmu.Libs/Diagnostics/DccWriterTrace.cs @@ -9,15 +9,19 @@ namespace SharpEmu.Libs.Diagnostics; internal static class DccWriterTrace { - private const int MaxReports = 64; + private const int MaxReports = 256; private const int MaxFrames = 12; private static readonly bool DccEnabled = Environment.GetEnvironmentVariable("SHARPEMU_TRACE_DCC_WRITERS") == "1"; - private static readonly ulong TargetAddress = ParseAddress(Environment.GetEnvironmentVariable("SHARPEMU_TRACE_WRITER_ADDRESS")); + private static readonly ulong[] TargetAddresses = (Environment.GetEnvironmentVariable("SHARPEMU_TRACE_WRITER_ADDRESS") ?? string.Empty) + .Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) + .Select(ParseAddress) + .Where(address => address != 0) + .ToArray(); - public static readonly bool Enabled = DccEnabled || TargetAddress != 0; + public static readonly bool Enabled = DccEnabled || TargetAddresses.Length != 0; [ThreadStatic] public static ulong CurrentProgram; @@ -43,7 +47,7 @@ internal static class DccWriterTrace public static void Record(IGuestImageCache images, ulong address, ulong size) { - var targeted = TargetAddress != 0 && TargetAddress >= address && TargetAddress - address < size; + var targeted = TargetAddresses.Any(target => target >= address && target - address < size); if (_reported.Count >= MaxReports || (!targeted && !(DccEnabled && images.OverlapsDccMetadata(address, size))) || !_reported.Add((address, size, CurrentProgram))) { diff --git a/src/SharpEmu.Libs/Diagnostics/GpuReadTrace.cs b/src/SharpEmu.Libs/Diagnostics/GpuReadTrace.cs index 02b3ede..ebbf945 100644 --- a/src/SharpEmu.Libs/Diagnostics/GpuReadTrace.cs +++ b/src/SharpEmu.Libs/Diagnostics/GpuReadTrace.cs @@ -17,6 +17,30 @@ internal static class GpuReadTrace public static string? CurrentStage; private static readonly HashSet<(ulong Shader, ulong Page, bool Table)> _reported = new(); + private static readonly Dictionary _values = new(); + private static long _nextValueReport = System.Diagnostics.Stopwatch.GetTimestamp(); + + public static void RecordValue(ulong address, uint word) + { + lock (_values) + { + _values[address] = _values.TryGetValue(address, out var entry) + ? (entry.Reads + 1, entry.Changes + (entry.Last != word ? 1 : 0), word, entry.First, entry.Shader) + : (1, 0, word, word, CurrentShader); + var now = System.Diagnostics.Stopwatch.GetTimestamp(); + if (now < _nextValueReport) + { + return; + } + + _nextValueReport = now + System.Diagnostics.Stopwatch.Frequency * 5; + foreach (var (key, value) in _values.OrderByDescending(pair => pair.Value.Reads).Take(16)) + { + Console.Error.WriteLine( + $"[GPU][SYNC_READ_VALUE] address=0x{key:X16} reads={value.Reads} changes={value.Changes} first=0x{value.First:X8} last=0x{value.Last:X8} shader=0x{value.Shader:X16}"); + } + } + } public static void Record(ulong address, bool table) { diff --git a/src/SharpEmu.Libs/Gpu/Buffers/GuestBufferCache.cs b/src/SharpEmu.Libs/Gpu/Buffers/GuestBufferCache.cs index 8bd5cd9..183f607 100644 --- a/src/SharpEmu.Libs/Gpu/Buffers/GuestBufferCache.cs +++ b/src/SharpEmu.Libs/Gpu/Buffers/GuestBufferCache.cs @@ -460,6 +460,72 @@ public sealed unsafe class GuestBufferCache : IGuestBufferStore, IDisposable return true; } + public const ulong MaxHostCopyWords = 64 * 1024; + + public bool TryCopyWordsOnHost(ulong destination, ulong source, ulong sourceWords, ulong words) + { + if (destination == 0 || source == 0 || sourceWords == 0 || words == 0 || ((destination | source) & 3) != 0 || + words > MaxHostCopyWords || sourceWords > MaxHostCopyWords) + { + return false; + } + + var size = words * sizeof(uint); + var sourceSize = Math.Min(sourceWords, words) * sizeof(uint); + if (size > ulong.MaxValue - destination || sourceSize > ulong.MaxValue - source || + (source < destination + size && destination < source + sourceSize)) + { + return false; + } + + var images = RequireImageCache(); + var sourceRegion = images.QueryRegion(source, sourceSize); + var destinationRegion = images.QueryRegion(destination, size); + if (HasGpuDirtyBytes(source, sourceSize) || HasGpuDirtyBytes(destination, size) || + sourceRegion.GpuImageBytes || destinationRegion.GpuImageBytes) + { + return false; + } + + var pattern = System.Buffers.ArrayPool.Shared.Rent((int)sourceSize); + var chunk = System.Buffers.ArrayPool.Shared.Rent((int)Math.Min(size, 64UL * 1024)); + try + { + if (!_backing.TryReadBacking(source, pattern.AsSpan(0, (int)sourceSize))) + { + return false; + } + + if (destinationRegion.ImageBytes) + { + images.InvalidateMemory(destination, size); + } + + var chunkLength = (ulong)(chunk.Length - chunk.Length % sizeof(uint)); + for (ulong offset = 0; offset < size;) + { + var length = (int)Math.Min(size - offset, chunkLength); + for (var filled = 0; filled < length;) + { + var patternOffset = (int)((offset + (ulong)filled) % sourceSize); + var copied = Math.Min(length - filled, (int)sourceSize - patternOffset); + pattern.AsSpan(patternOffset, copied).CopyTo(chunk.AsSpan(filled, copied)); + filled += copied; + } + + WriteHostMemory(destination + offset, chunk.AsSpan(0, length)); + offset += (ulong)length; + } + + return true; + } + finally + { + System.Buffers.ArrayPool.Shared.Return(pattern); + System.Buffers.ArrayPool.Shared.Return(chunk); + } + } + public void CopyBuffer(ulong dstVaddr, ulong srcVaddr, ulong size, bool dstGds, bool srcGds) { var dstMemory = !dstGds; @@ -557,7 +623,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 +652,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/Gpu/Metal/MetalCommandStreamHost.Rendering.cs b/src/SharpEmu.Libs/Gpu/Metal/MetalCommandStreamHost.Rendering.cs index 79012a2..1e5b709 100644 --- a/src/SharpEmu.Libs/Gpu/Metal/MetalCommandStreamHost.Rendering.cs +++ b/src/SharpEmu.Libs/Gpu/Metal/MetalCommandStreamHost.Rendering.cs @@ -1196,4 +1196,10 @@ internal sealed partial class MetalCommandStreamHost : IRenderHost, IShaderPipel _ = (address, size, fillValue); return false; } + + bool IRenderHost.TryCopyWordsOnHost(ulong destination, ulong source, ulong sourceWords, ulong words) + { + _ = (destination, source, sourceWords, words); + return false; + } } diff --git a/src/SharpEmu.Libs/Gpu/Pipelines/BoundedFillDetector.cs b/src/SharpEmu.Libs/Gpu/Pipelines/BoundedFillDetector.cs index 327055f..bab392a 100644 --- a/src/SharpEmu.Libs/Gpu/Pipelines/BoundedFillDetector.cs +++ b/src/SharpEmu.Libs/Gpu/Pipelines/BoundedFillDetector.cs @@ -26,8 +26,104 @@ public sealed record BoundedFill( FillWord[]? Pattern = null, FillWord? PatternLength = null); +public sealed record BoundedCopy( + uint GroupScalarRegister, + FillWord Count, + FillWord Modulus, + FillWord[] Source, + FillWord[] Destination); + public static class BoundedFillDetector { + private static readonly string[] ModuloCopyOpcodes = + [ + "VLshlAddU32", "SBufferLoadDword", "VCmpxGtU32", "SCbranchExecz", "SBufferLoadDword", + "VCvtF32U32", "SCmpLgU32", "SCselectB64", "VRcpIflagF32", "VMulF32", "VCvtU32F32", "VMadU64U32", "VCmpNeU32", "VSubI32", + "VCndmaskB32", "VMulHiU32", "VSubI32", "VAddI32", "VCndmaskB32", "VMulHiU32", "VMulLoU32", "VSubI32", "VCmpGeU32", + "VCmpLeU32", "SAndB64", "VAddcU32", "VAddCoCiU32", "VCndmaskB32", "VMulLoU32", "VSubI32", + "BufferLoadFormatX", "BufferStoreFormatX", "SEndpgm", + ]; + + public static BoundedCopy? DetectCopy(Gen5ShaderProgram program) + { + var instructions = program.Instructions + .Where(instruction => instruction.Opcode is not ("SWaitcnt" or "STtraceData" or "SInstPrefetch") && + !(instruction.Opcode == "SMovB32" && instruction.Destinations is [{ Kind: Gen5OperandKind.ScalarRegister, Value: MarkerRegister }])) + .ToArray(); + if (!instructions.Select(instruction => instruction.Opcode).SequenceEqual(ModuloCopyOpcodes)) + { + return null; + } + + var countLoad = instructions[1]; + var modulusLoad = instructions[4]; + if (instructions[0].Destinations is not [{ Kind: Gen5OperandKind.VectorRegister } index] || + instructions[0].Sources is not [{ Kind: Gen5OperandKind.ScalarRegister } group, { Kind: Gen5OperandKind.EncodedConstant, Value: Shift64 }, + { Kind: Gen5OperandKind.VectorRegister, Value: ThreadIndexRegister }] || + countLoad.Destinations is not [{ Kind: Gen5OperandKind.ScalarRegister } count] || + modulusLoad.Destinations is not [{ Kind: Gen5OperandKind.ScalarRegister } modulus] || + countLoad.Sources is not [{ Kind: Gen5OperandKind.ScalarRegister } constants, _] || + modulusLoad.Sources is not [{ Kind: Gen5OperandKind.ScalarRegister } modulusConstants, _] || + modulusConstants.Value != constants.Value || + instructions[2].Sources is not [{ Kind: Gen5OperandKind.ScalarRegister } limit, { Kind: Gen5OperandKind.VectorRegister } compared] || + limit.Value != count.Value || compared.Value != index.Value || + instructions[5].Sources is not [{ Kind: Gen5OperandKind.ScalarRegister } converted] || converted.Value != modulus.Value || + instructions[6].Sources is not [{ Kind: Gen5OperandKind.EncodedConstant, Value: ZeroConstant }, { Kind: Gen5OperandKind.ScalarRegister } tested] || + tested.Value != modulus.Value) + { + return null; + } + + foreach (var at in new[] { 11, 20, 23, 28 }) + { + if (instructions[at].Sources is not [{ Kind: Gen5OperandKind.ScalarRegister } multiplier, ..] || multiplier.Value != modulus.Value) + { + return null; + } + } + + var load = instructions[30]; + var store = instructions[31]; + if (instructions[29].Destinations is not [{ Kind: Gen5OperandKind.VectorRegister } remainder] || + instructions[29].Sources is not [{ Kind: Gen5OperandKind.VectorRegister } dividend, { Kind: Gen5OperandKind.VectorRegister }] || + dividend.Value != index.Value || + load.Control is not Gen5BufferMemoryControl { DwordCount: 1, OffsetBytes: 0, IndexEnabled: true, OffsetEnabled: false, Typed: false } loadControl || + store.Control is not Gen5BufferMemoryControl { DwordCount: 1, OffsetBytes: 0, IndexEnabled: true, OffsetEnabled: false, Typed: false } storeControl || + load.Sources is not [_, _, { Kind: Gen5OperandKind.EncodedConstant, Value: ZeroConstant }] || + store.Sources is not [_, _, { Kind: Gen5OperandKind.EncodedConstant, Value: ZeroConstant }] || + loadControl.VectorAddress != remainder.Value || storeControl.VectorAddress != index.Value || + storeControl.VectorData != loadControl.VectorData) + { + return null; + } + + var branch = instructions[3]; + if (branch.Pc + sizeof(uint) + (uint)((short)(branch.Words[0] & 0xFFFF) * sizeof(uint)) != instructions[^1].Pc) + { + return null; + } + + var resourceRegisters = Enumerable.Range(0, 4) + .SelectMany(word => new[] { loadControl.ScalarResource + (uint)word, storeControl.ScalarResource + (uint)word }); + var constantRegisters = Enumerable.Range(0, 4).Select(word => constants.Value + (uint)word); + if (resourceRegisters.Any(WrittenScalars(instructions, instructions.Length).Contains) || + constantRegisters.Any(WrittenScalars(instructions, 4).Contains) || + WrittenScalars(instructions[5..29], 24).Contains(modulus.Value) || + countLoad.Control is not Gen5ScalarMemoryControl { DynamicOffsetRegister: null, ImmediateOffsetBytes: >= 0 } countControl || + modulusLoad.Control is not Gen5ScalarMemoryControl { DynamicOffsetRegister: null, ImmediateOffsetBytes: >= 0 } modulusControl) + { + return null; + } + + FillWord User(uint register) => new(FillWordSource.UserData, register, 0, 0); + return new BoundedCopy( + group.Value, + new FillWord(FillWordSource.BufferResource, constants.Value, 0, countControl.ImmediateOffsetBytes), + new FillWord(FillWordSource.BufferResource, constants.Value, 0, modulusControl.ImmediateOffsetBytes), + [.. Enumerable.Range(0, 4).Select(word => User(loadControl.ScalarResource + (uint)word))], + [.. Enumerable.Range(0, 4).Select(word => User(storeControl.ScalarResource + (uint)word))]); + } + private const uint Shift64 = 134; private const uint ZeroConstant = 128; private const uint NullScalar = 125; diff --git a/src/SharpEmu.Libs/Gpu/Pipelines/ShaderPrewarmList.cs b/src/SharpEmu.Libs/Gpu/Pipelines/ShaderPrewarmList.cs index 225d70e..980bcb1 100644 --- a/src/SharpEmu.Libs/Gpu/Pipelines/ShaderPrewarmList.cs +++ b/src/SharpEmu.Libs/Gpu/Pipelines/ShaderPrewarmList.cs @@ -1,6 +1,7 @@ // Copyright (C) 2026 SharpEmu Emulator Project // SPDX-License-Identifier: GPL-2.0-or-later +using System.Globalization; using System.IO.Hashing; using System.Text; using SharpEmu.HLE; @@ -141,6 +142,7 @@ internal sealed class ShaderPrewarmList : IDisposable { public const string FileName = "shader-prewarm.bin"; public const string StampFileName = "shader-prewarm.stamp"; + public const string ProgressFileName = "shader-prewarm.progress"; private const uint Magic = 0x57504553; private const uint FormatVersion = 1; @@ -151,16 +153,19 @@ internal sealed class ShaderPrewarmList : IDisposable private readonly object _gate = new(); private readonly FileStream _file; private readonly string _stampPath; + private readonly string _progressPath; + private readonly object _progressGate = new(); private readonly HashSet<(ulong Hash, uint CodeSize, ulong Address)> _codes = []; private readonly HashSet _computes = []; private readonly Dictionary<(ulong Hash, uint CodeSize, ulong Address), ShaderCodeCapture> _loadedCodes = new(); private readonly List _loadedComputes = []; private bool _failed; - private ShaderPrewarmList(FileStream file, string stampPath) + private ShaderPrewarmList(FileStream file, string stampPath, string progressPath) { _file = file; _stampPath = stampPath; + _progressPath = progressPath; } public int LoadedComputeCount => _loadedComputes.Count; @@ -172,7 +177,7 @@ internal sealed class ShaderPrewarmList : IDisposable Directory.CreateDirectory(directory); var path = Path.Combine(directory, FileName); var file = new FileStream(path, FileMode.OpenOrCreate, FileAccess.ReadWrite, FileShare.Read); - var list = new ShaderPrewarmList(file, Path.Combine(directory, StampFileName)); + var list = new ShaderPrewarmList(file, Path.Combine(directory, StampFileName), Path.Combine(directory, ProgressFileName)); try { list.Load(); @@ -230,6 +235,93 @@ internal sealed class ShaderPrewarmList : IDisposable } } + public static ulong Identity(ComputePrewarmRecord record) => XxHash3.HashToUInt64(SerializeCompute(record)); + + public HashSet ReadProgress(string stamp) + { + var completed = new HashSet(); + lock (_progressGate) + { + try + { + if (!File.Exists(_progressPath)) + { + return completed; + } + + var lines = File.ReadAllLines(_progressPath); + if (lines.Length == 0 || lines[0] != ProgressKey(stamp)) + { + File.Delete(_progressPath); + return completed; + } + + for (var index = 1; index < lines.Length; index++) + { + if (ulong.TryParse(lines[index], NumberStyles.HexNumber, CultureInfo.InvariantCulture, out var identity)) + { + completed.Add(identity); + } + } + } + catch (Exception exception) when (exception is IOException or UnauthorizedAccessException) + { + completed.Clear(); + } + } + + return completed; + } + + public void AppendProgress(string stamp, IReadOnlyCollection identities) + { + if (identities.Count == 0) + { + return; + } + + lock (_progressGate) + { + try + { + var lines = new List(identities.Count + 1); + if (!File.Exists(_progressPath)) + { + lines.Add(ProgressKey(stamp)); + } + + foreach (var identity in identities) + { + lines.Add(identity.ToString("X16", CultureInfo.InvariantCulture)); + } + + File.AppendAllLines(_progressPath, lines); + } + catch (Exception exception) when (exception is IOException or UnauthorizedAccessException) + { + Console.Error.WriteLine($"[LOADER][WARN] Shader prewarm progress write failed: {exception.Message}"); + } + } + } + + public void ClearProgress() + { + lock (_progressGate) + { + try + { + File.Delete(_progressPath); + } + catch (Exception exception) when (exception is IOException or UnauthorizedAccessException) + { + Console.Error.WriteLine($"[LOADER][WARN] Shader prewarm progress clear failed: {exception.Message}"); + } + } + } + + private static string ProgressKey(string stamp) => + XxHash3.HashToUInt64(Encoding.UTF8.GetBytes(stamp)).ToString("X16", CultureInfo.InvariantCulture); + public void RecordCompute(ShaderCodeCapture code, ComputePrewarmRecord record) { var payload = SerializeCompute(record); diff --git a/src/SharpEmu.Libs/Gpu/Pipelines/ShaderProgramCache.cs b/src/SharpEmu.Libs/Gpu/Pipelines/ShaderProgramCache.cs index cf1853f..715b647 100644 --- a/src/SharpEmu.Libs/Gpu/Pipelines/ShaderProgramCache.cs +++ b/src/SharpEmu.Libs/Gpu/Pipelines/ShaderProgramCache.cs @@ -87,6 +87,7 @@ internal sealed class ProgramSourceEntry public required bool HasBitwiseExclusiveOr { get; init; } public ConstantFill? ConstantFill { get; init; } public BoundedFill? BoundedFill { get; init; } + public BoundedCopy? BoundedCopy { get; init; } public EmbeddedVertexFetchPlan? EmbeddedFetch { get; init; } public ShaderVertexInput[] VertexInputs { get; init; } = []; public List Permutations { get; } = new(8); @@ -383,6 +384,7 @@ internal sealed class ShaderProgramCache HasBitwiseExclusiveOr = exclusiveOr, ConstantFill = source.Stage == ShaderStage.Compute ? ConstantFillDetector.Detect(program) : null, BoundedFill = source.Stage == ShaderStage.Compute ? BoundedFillDetector.Detect(program) : null, + BoundedCopy = source.Stage == ShaderStage.Compute ? BoundedFillDetector.DetectCopy(program) : null, EmbeddedFetch = fetch, VertexInputs = vertexInputs, }; @@ -837,6 +839,7 @@ internal sealed class ShaderProgramCache HasBitwiseExclusiveOr = entry.HasBitwiseExclusiveOr, ConstantFill = entry.ConstantFill, BoundedFill = entry.BoundedFill, + BoundedCopy = entry.BoundedCopy, Buffers = buffers, Images = images, SamplerCount = info.Samplers.Count, diff --git a/src/SharpEmu.Libs/Gpu/Rendering/IRenderHost.cs b/src/SharpEmu.Libs/Gpu/Rendering/IRenderHost.cs index 20077ec..a4ca4e3 100644 --- a/src/SharpEmu.Libs/Gpu/Rendering/IRenderHost.cs +++ b/src/SharpEmu.Libs/Gpu/Rendering/IRenderHost.cs @@ -188,5 +188,7 @@ public interface IRenderHost bool TryFillDccMetadata(ulong address, ulong size, uint fillValue); + bool TryCopyWordsOnHost(ulong destination, ulong source, ulong sourceWords, ulong words); + Exception Fatal(string message); } diff --git a/src/SharpEmu.Libs/Gpu/Rendering/RenderExecutor.Compute.cs b/src/SharpEmu.Libs/Gpu/Rendering/RenderExecutor.Compute.cs index ebb8cb0..5b1fab5 100644 --- a/src/SharpEmu.Libs/Gpu/Rendering/RenderExecutor.Compute.cs +++ b/src/SharpEmu.Libs/Gpu/Rendering/RenderExecutor.Compute.cs @@ -101,6 +101,12 @@ public sealed partial class RenderExecutor return; } + if (indirectArgumentsAddress == 0 && TryConsumeBoundedCopy(input, groupsX, groupsY, groupsZ, dispatchInitiator)) + { + _host.ResetBindings(); + return; + } + if (indirectArgumentsAddress == 0 && TryConsumeImageClear(input, groupsX, groupsY, groupsZ, dispatchInitiator)) { _host.ResetBindings(); @@ -552,6 +558,71 @@ public sealed partial class RenderExecutor return consumed; } + private bool TryConsumeBoundedCopy(ComputeInputInfo input, uint groupsX, uint groupsY, uint groupsZ, uint dispatchInitiator) + { + var program = input.Stage.Program!; + if (program.BoundedCopy is not { } copy || !BoundedCopyEnabled) + { + return false; + } + + if (program.UserDataBase != 0 || copy.GroupScalarRegister != (uint)input.WorkgroupRegister || !input.GroupIdX || input.GroupIdY || input.GroupIdZ || + input.ThreadIdCount < 1 || input.ThreadsX != ImageClearWaveSize || input.ThreadsY != 1 || input.ThreadsZ != 1 || + groupsX == 0 || groupsY != 1 || groupsZ != 1) + { + return RefuseBoundedFill(program, $"copy shape dispatch={groupsX}x{groupsY}x{groupsZ} local={input.ThreadsX}x{input.ThreadsY}x{input.ThreadsZ}"); + } + + var userData = input.Stage.Resources.UserData; + Span sourceWords = stackalloc uint[4]; + Span destinationWords = stackalloc uint[4]; + for (var word = 0; word < 4; word++) + { + if (!TryResolveFillWord(copy.Source[word], userData, out sourceWords[word]) || + !TryResolveFillWord(copy.Destination[word], userData, out destinationWords[word])) + { + return RefuseBoundedFill(program, $"copy descriptor word {word} unreadable"); + } + } + + if (!TryResolveFillWord(copy.Count, userData, out var count) || !TryResolveFillWord(copy.Modulus, userData, out var modulus)) + { + return RefuseBoundedFill(program, "copy range unreadable"); + } + + var source = BufferDescriptorWords.From(sourceWords); + var destination = BufferDescriptorWords.From(destinationWords); + if (!IsPlainWordBuffer(source) || !IsPlainWordBuffer(destination) || source.Format != destination.Format || + modulus == 0 || modulus > source.RecordCount) + { + return RefuseBoundedFill(program, + $"copy src=0x{source.Address:X}/{source.Format}/{source.Stride}/{source.OutOfBounds} dst=0x{destination.Address:X}/{destination.Format}/{destination.Stride}/{destination.OutOfBounds} " + + $"modulus={modulus} records={source.RecordCount}"); + } + + var threads = (dispatchInitiator & DispatchInitiatorUseThreadDimensions) != 0 ? groupsX : (ulong)groupsX * input.ThreadsX; + var words = Math.Min(Math.Min((ulong)count, threads), destination.RecordCount); + if (words == 0) + { + return false; + } + + var consumed = _host.TryCopyWordsOnHost(destination.Address, source.Address, modulus, words); + if (RenderTrace.Enabled) + { + RenderTrace.Write( + $"Bounded copy: shader=0x{program.Hash:X16} src=0x{source.Address:X16} dst=0x{destination.Address:X16} words={words} modulus={modulus} consumed={consumed}"); + } + + return consumed || RefuseBoundedFill(program, $"copy refused by the host dst=0x{destination.Address:X} words={words} modulus={modulus}"); + } + + private static readonly bool BoundedCopyEnabled = Environment.GetEnvironmentVariable("SHARPEMU_HOST_BOUNDED_COPY") != "0"; + + private static bool IsPlainWordBuffer(BufferDescriptorWords descriptor) => + descriptor.Stride == sizeof(uint) && !descriptor.SwizzleEnabled && !descriptor.AddThreadId && descriptor.OutOfBounds == 0 && + descriptor.Type == 0 && (descriptor.Address & 3) == 0 && descriptor.Format is Format32UInt or Format32SInt or Format32Float; + private static bool RefuseBoundedFill(ShaderProgramInfo program, string reason) { Diagnostics.DccWriterTrace.Refuse(program.Hash, reason); diff --git a/src/SharpEmu.Libs/Gpu/Rendering/ShaderStageResources.cs b/src/SharpEmu.Libs/Gpu/Rendering/ShaderStageResources.cs index 97814f2..fa55ad3 100644 --- a/src/SharpEmu.Libs/Gpu/Rendering/ShaderStageResources.cs +++ b/src/SharpEmu.Libs/Gpu/Rendering/ShaderStageResources.cs @@ -58,6 +58,7 @@ public class ShaderProgramInfo public bool HasBitwiseExclusiveOr { get; init; } public Pipelines.ConstantFill? ConstantFill { get; init; } public Pipelines.BoundedFill? BoundedFill { get; init; } + public Pipelines.BoundedCopy? BoundedCopy { get; init; } public BufferResourceInfo[] Buffers { get; init; } = []; public ImageResourceInfo[] Images { get; init; } = []; public int SamplerCount { get; init; } diff --git a/src/SharpEmu.Libs/VideoOut/VulkanVideoPresenter.CommandStream.cs b/src/SharpEmu.Libs/VideoOut/VulkanVideoPresenter.CommandStream.cs index 08e3553..194b952 100644 --- a/src/SharpEmu.Libs/VideoOut/VulkanVideoPresenter.CommandStream.cs +++ b/src/SharpEmu.Libs/VideoOut/VulkanVideoPresenter.CommandStream.cs @@ -40,6 +40,7 @@ internal static unsafe partial class VulkanVideoPresenter } EnsureStarted(1280, 720); + WaitForShaderPrewarm("first GPU submission"); // Queue publication precedes device initialization; only the render thread consumes it. lock (_gate) { @@ -117,6 +118,7 @@ internal static unsafe partial class VulkanVideoPresenter _ = width; _ = height; _ = pitchInPixel; + WaitForShaderPrewarm("first flip"); if (!IsKnownDisplayBuffer(address) || !TryGetActivePresenter(out var presenter)) { return false; 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.Libs/VideoOut/VulkanVideoPresenter.Pipelines.cs b/src/SharpEmu.Libs/VideoOut/VulkanVideoPresenter.Pipelines.cs index 2ff726a..e380648 100644 --- a/src/SharpEmu.Libs/VideoOut/VulkanVideoPresenter.Pipelines.cs +++ b/src/SharpEmu.Libs/VideoOut/VulkanVideoPresenter.Pipelines.cs @@ -100,6 +100,7 @@ internal static unsafe partial class VulkanVideoPresenter { using var profile = ResourceMaterializationProfile.Measure(ResourceMaterializationProfile.Phase.GuestRead); word = 0; + var synchronized = false; if (IsCleanReadPage(address, sizeof(uint))) { if (TryGetAliasPointer(address, sizeof(uint), out var alias)) @@ -132,6 +133,8 @@ internal static unsafe partial class VulkanVideoPresenter { return false; } + + synchronized = true; } else { @@ -146,6 +149,11 @@ internal static unsafe partial class VulkanVideoPresenter } word = System.Buffers.Binary.BinaryPrimitives.ReadUInt32LittleEndian(bytes); + if (Diagnostics.GpuReadTrace.Enabled && synchronized) + { + Diagnostics.GpuReadTrace.RecordValue(address, word); + } + return true; } diff --git a/src/SharpEmu.Libs/VideoOut/VulkanVideoPresenter.Present.cs b/src/SharpEmu.Libs/VideoOut/VulkanVideoPresenter.Present.cs index 3b16464..e89fc5b 100644 --- a/src/SharpEmu.Libs/VideoOut/VulkanVideoPresenter.Present.cs +++ b/src/SharpEmu.Libs/VideoOut/VulkanVideoPresenter.Present.cs @@ -157,8 +157,73 @@ internal static unsafe partial class VulkanVideoPresenter _guestFlipVersionSequence = 0; } + private static readonly bool _shaderPrewarmWaitDisabled = + Environment.GetEnvironmentVariable("SHARPEMU_SHADER_PREWARM_WAIT") == "0"; + private static bool _shaderPrewarmDecided; + private static bool _shaderPrewarmRunning; + private static bool _shaderPrewarmReleased; + private static int _shaderPrewarmHoldReported; + private static int _shaderPrewarmProgress; + private static int _shaderPrewarmTotal; + + private static void SetShaderPrewarmState(bool running) + { + lock (_gate) + { + _shaderPrewarmDecided = true; + _shaderPrewarmRunning = running; + Volatile.Write(ref _shaderPrewarmReleased, !running); + System.Threading.Monitor.PulseAll(_gate); + } + } + + internal static void WaitForShaderPrewarm(string holder) + { + if (_shaderPrewarmWaitDisabled || Volatile.Read(ref _shaderPrewarmReleased)) + { + return; + } + + var started = System.Diagnostics.Stopwatch.GetTimestamp(); + var reported = started; + var reporter = false; + lock (_gate) + { + while (_thread is not null && (!_shaderPrewarmDecided || _shaderPrewarmRunning) && + !_closed && _presenterStartupFailure is null && + !HostSessionControl.IsShutdownRequested && !Volatile.Read(ref _presenterCloseRequested)) + { + if (!reporter && _shaderPrewarmRunning && + Interlocked.Exchange(ref _shaderPrewarmHoldReported, 1) == 0) + { + reporter = true; + Console.Error.WriteLine( + $"[LOADER][INFO] Shader prewarm: holding the game at its {holder} until it finishes."); + } + + System.Threading.Monitor.Wait(_gate, 1000); + if (reporter && System.Diagnostics.Stopwatch.GetElapsedTime(reported) >= TimeSpan.FromSeconds(5)) + { + reported = System.Diagnostics.Stopwatch.GetTimestamp(); + var total = Volatile.Read(ref _shaderPrewarmTotal); + var done = Volatile.Read(ref _shaderPrewarmProgress); + Console.Error.WriteLine( + $"[LOADER][INFO] Shader prewarm: {done}/{total} ({(total == 0 ? 0 : done * 100 / total)}%)"); + } + } + } + + if (reporter) + { + Console.Error.WriteLine(string.Create( + System.Globalization.CultureInfo.InvariantCulture, + $"[LOADER][INFO] Shader prewarm: game released after {System.Diagnostics.Stopwatch.GetElapsedTime(started).TotalSeconds:F1}s.")); + } + } + public static void HideSplashScreen() { + WaitForShaderPrewarm("splash screen"); lock (_gate) { _splashHidden = true; diff --git a/src/SharpEmu.Libs/VideoOut/VulkanVideoPresenter.Rendering.cs b/src/SharpEmu.Libs/VideoOut/VulkanVideoPresenter.Rendering.cs index b782b8c..bde967f 100644 --- a/src/SharpEmu.Libs/VideoOut/VulkanVideoPresenter.Rendering.cs +++ b/src/SharpEmu.Libs/VideoOut/VulkanVideoPresenter.Rendering.cs @@ -1200,5 +1200,8 @@ internal static unsafe partial class VulkanVideoPresenter _bufferCache.FillDccMetadata(address, size, fillValue); return true; } + + public bool TryCopyWordsOnHost(ulong destination, ulong source, ulong sourceWords, ulong words) => + _bufferCache.TryCopyWordsOnHost(destination, source, sourceWords, words); } } diff --git a/src/SharpEmu.Libs/VideoOut/VulkanVideoPresenter.ShaderPrewarm.cs b/src/SharpEmu.Libs/VideoOut/VulkanVideoPresenter.ShaderPrewarm.cs index ce603a1..43a040b 100644 --- a/src/SharpEmu.Libs/VideoOut/VulkanVideoPresenter.ShaderPrewarm.cs +++ b/src/SharpEmu.Libs/VideoOut/VulkanVideoPresenter.ShaderPrewarm.cs @@ -17,6 +17,7 @@ internal static unsafe partial class VulkanVideoPresenter private sealed partial class Presenter { private const int MaxLoggedPrewarmFailures = 8; + private const int PrewarmProgressBatch = 256; private ShaderPrewarmList? _shaderPrewarmList; private ConcurrentDictionary? _prewarmedShaderIdentities; @@ -27,16 +28,25 @@ internal static unsafe partial class VulkanVideoPresenter private int _shaderPrewarmCompiled; private int _shaderPrewarmFailed; private int _shaderPrewarmRuntimeHits; + private readonly ConcurrentQueue _shaderPrewarmCompleted = new(); ShaderPrewarmList? IShaderPipelineHost.ShaderPrewarm => Volatile.Read(ref _shaderPrewarmList); private void StartShaderPrewarm() + { + if (!StartShaderPrewarmCore()) + { + SetShaderPrewarmState(false); + } + } + + private bool StartShaderPrewarmCore() { if (_shaderPrewarmList is not null || _pipelineCacheShardDirectory is not null || Environment.GetEnvironmentVariable("SHARPEMU_SHADER_PREWARM") == "0") { - return; + return false; } var directory = Path.GetDirectoryName(VulkanPipelineCacheStorage.ResolvePath( @@ -44,7 +54,7 @@ internal static unsafe partial class VulkanVideoPresenter Environment.GetEnvironmentVariable("SHARPEMU_VK_PIPELINE_CACHE_PATH"))); if (string.IsNullOrEmpty(directory) || ShaderPrewarmList.Open(directory) is not { } list) { - return; + return false; } _prewarmedShaderIdentities = new ConcurrentDictionary(StringComparer.Ordinal); @@ -57,7 +67,7 @@ internal static unsafe partial class VulkanVideoPresenter { list.WriteStamp(stamp); _shaderPrewarmFinished = true; - return; + return false; } if (list.IsStampCurrent(stamp)) @@ -65,18 +75,31 @@ internal static unsafe partial class VulkanVideoPresenter _shaderPrewarmFinished = true; Console.Error.WriteLine( $"[LOADER][INFO] Shader prewarm skipped: compute={list.LoadedComputeCount} reason=same-build-and-driver"); - return; + return false; } - var work = new ConcurrentQueue<(ComputePrewarmRecord Record, ShaderCodeCapture Code)>(list.LoadedComputes()); + var completed = list.ReadProgress(stamp); + var pending = list.LoadedComputes() + .Where(item => !completed.Contains(ShaderPrewarmList.Identity(item.Record))) + .ToList(); + var started = Stopwatch.GetTimestamp(); + if (pending.Count == 0) + { + FinishShaderPrewarm(list, stamp, started); + return false; + } + + var work = new ConcurrentQueue<(ComputePrewarmRecord Record, ShaderCodeCapture Code)>(pending); var workerCount = Math.Clamp(Environment.ProcessorCount / 4, 1, 4); var remaining = workerCount; - var started = Stopwatch.GetTimestamp(); var host = (IShaderPipelineHost)this; var sharedInt64Atomics = host.SharedInt64AtomicsEnabled; var execGuardElision = host.ExecGuardElisionEnabled; + Volatile.Write(ref _shaderPrewarmTotal, pending.Count); + Volatile.Write(ref _shaderPrewarmProgress, 0); Console.Error.WriteLine( - $"[LOADER][INFO] Shader prewarm started: compute={work.Count} workers={workerCount}"); + $"[LOADER][INFO] Shader prewarm started: compute={pending.Count} resumed={completed.Count} workers={workerCount}"); + SetShaderPrewarmState(true); _shaderPrewarmThreads = new Thread[workerCount]; for (var index = 0; index < workerCount; index++) { @@ -85,8 +108,14 @@ internal static unsafe partial class VulkanVideoPresenter while (!_shaderPrewarmStopping && work.TryDequeue(out var item)) { PrewarmComputePipeline(item.Record, item.Code, compiler, sharedInt64Atomics, execGuardElision); + _shaderPrewarmCompleted.Enqueue(ShaderPrewarmList.Identity(item.Record)); + if (Interlocked.Increment(ref _shaderPrewarmProgress) % PrewarmProgressBatch == 0) + { + FlushShaderPrewarmProgress(list, stamp); + } } + FlushShaderPrewarmProgress(list, stamp); if (Interlocked.Decrement(ref remaining) == 0) { FinishShaderPrewarm(list, stamp, started); @@ -99,6 +128,19 @@ internal static unsafe partial class VulkanVideoPresenter }; _shaderPrewarmThreads[index].Start(); } + + return true; + } + + private void FlushShaderPrewarmProgress(ShaderPrewarmList list, string stamp) + { + var batch = new List(); + while (_shaderPrewarmCompleted.TryDequeue(out var identity)) + { + batch.Add(identity); + } + + list.AppendProgress(stamp, batch); } private static string ShaderPrewarmStampPart(Type type) => @@ -109,8 +151,7 @@ internal static unsafe partial class VulkanVideoPresenter '|', ShaderPrewarmStampPart(typeof(Gen5ShaderTranslator)), ShaderPrewarmStampPart(typeof(Gen5SpirvTranslator)), - ShaderPrewarmStampPart(compiler.GetType()), - ShaderPrewarmStampPart(typeof(ShaderProgramCache)), + compiler.GetType().FullName, DriverCacheSignature().TrimEnd('\n')); private void PrewarmComputePipeline( @@ -182,11 +223,14 @@ internal static unsafe partial class VulkanVideoPresenter var matched = runtime.Keys.Count(prewarmed.ContainsKey); if (_shaderPrewarmStopping) { + SetShaderPrewarmState(false); return; } list.WriteStamp(stamp); + list.ClearProgress(); _shaderPrewarmFinished = true; + SetShaderPrewarmState(false); _pipelineCacheDirty = true; Console.Error.WriteLine( $"[LOADER][INFO] Shader prewarm done: compiled={Volatile.Read(ref _shaderPrewarmCompiled)} " + @@ -218,6 +262,7 @@ internal static unsafe partial class VulkanVideoPresenter private void StopShaderPrewarm() { _shaderPrewarmStopping = true; + SetShaderPrewarmState(false); foreach (var thread in _shaderPrewarmThreads) { thread.Join(TimeSpan.FromSeconds(10)); 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/Audio/AudioOut2PacingTests.cs b/tests/SharpEmu.Libs.Tests/Audio/AudioOut2PacingTests.cs new file mode 100644 index 0000000..e3dce5a --- /dev/null +++ b/tests/SharpEmu.Libs.Tests/Audio/AudioOut2PacingTests.cs @@ -0,0 +1,28 @@ +// 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 AudioOut2PacingTests +{ + [Theory] + [InlineData(256u, 48000u, 1u, 40.0)] + [InlineData(256u, 48000u, 2u, 45.333)] + [InlineData(256u, 48000u, 4u, 56.0)] + [InlineData(1024u, 48000u, 4u, 104.0)] + public void ABlockingPushWaitsUntilTheDeviceHoldsTheCushionAndOneSlotLessThanTheQueue( + uint grainSamples, uint frequency, uint queueDepth, double expected) + { + Assert.Equal(expected, AudioOut2Exports.DeviceSlotFreeThreshold(grainSamples, frequency, queueDepth), 3); + } + + [Fact] + public void TheThresholdStaysFarBelowTheHostQueueCap() + { + const double hostCapMilliseconds = 128 * 1024 * 1000.0 / (48000 * 4); + Assert.True(AudioOut2Exports.DeviceSlotFreeThreshold(256, 48000, 2) < hostCapMilliseconds / 10); + } +} 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 _)); + } +} diff --git a/tests/SharpEmu.Libs.Tests/Gpu/Buffers/GuestBufferCacheTests.cs b/tests/SharpEmu.Libs.Tests/Gpu/Buffers/GuestBufferCacheTests.cs index 1ffa0c2..5546b62 100644 --- a/tests/SharpEmu.Libs.Tests/Gpu/Buffers/GuestBufferCacheTests.cs +++ b/tests/SharpEmu.Libs.Tests/Gpu/Buffers/GuestBufferCacheTests.cs @@ -104,6 +104,43 @@ public sealed class GuestBufferCacheTests : IClassFixture Assert.Equal(new byte[] { 0x78, 0x56, 0x34, 0x12 }, harness.Read(address, 4)); } + [Fact] + public void AHostWordCopyRepeatsTheSourceAndReachesARegisteredBuffer() + { + if (_vulkan is null) return; + using var harness = new CacheHarness(_vulkan); + var address = harness.MapBacked(Page, ReadWrite); + var destination = address + 0x100; + harness.Write(address, Bytes(1u, 2u, 3u)); + var (before, beforeOffset) = harness.Worker.Run(() => harness.Cache.ObtainBuffer(destination, 28, isWritten: false)); + Assert.Equal(new byte[28], harness.ReadBack(before, beforeOffset, 28)); + + Assert.True(harness.Worker.Run(() => harness.Cache.TryCopyWordsOnHost(destination, address, 3, 7))); + + var expected = Bytes(1u, 2u, 3u, 1u, 2u, 3u, 1u); + Assert.Equal(expected, harness.Read(destination, 28)); + Assert.False(harness.Cache.HasGpuDirtyBytes(destination, 28)); + var (buffer, offset) = harness.Worker.Run(() => harness.Cache.ObtainBuffer(destination, 28, isWritten: false)); + Assert.Equal(expected, harness.ReadBack(buffer, offset, 28)); + harness.Shutdown(); + } + + [Fact] + public void AHostWordCopyIsRefusedForGpuWrittenOrOverlappingRanges() + { + if (_vulkan is null) return; + using var harness = new CacheHarness(_vulkan); + var address = harness.MapBacked(Page, ReadWrite); + harness.Write(address, Bytes(1u, 2u, 3u, 4u)); + + Assert.False(harness.Worker.Run(() => harness.Cache.TryCopyWordsOnHost(address + 8, address, 4, 4))); + harness.Worker.Run(() => harness.Cache.ObtainBuffer(address + 0x200, 4, isWritten: true)); + Assert.False(harness.Worker.Run(() => harness.Cache.TryCopyWordsOnHost(address + 0x100, address + 0x200, 1, 1))); + Assert.False(harness.Worker.Run(() => harness.Cache.TryCopyWordsOnHost(address + 0x200, address, 1, 1))); + Assert.True(harness.Worker.Run(() => harness.Cache.TryCopyWordsOnHost(address + 0x100, address, 4, 4))); + harness.Shutdown(); + } + [Theory] [InlineData(false)] [InlineData(true)] diff --git a/tests/SharpEmu.Libs.Tests/Gpu/Pipelines/BoundedFillDetectorTests.cs b/tests/SharpEmu.Libs.Tests/Gpu/Pipelines/BoundedFillDetectorTests.cs index 73871ae..c48839a 100644 --- a/tests/SharpEmu.Libs.Tests/Gpu/Pipelines/BoundedFillDetectorTests.cs +++ b/tests/SharpEmu.Libs.Tests/Gpu/Pipelines/BoundedFillDetectorTests.cs @@ -127,6 +127,48 @@ public sealed class BoundedFillDetectorTests Assert.True(fill.Formatted); } + private static readonly uint[] DemonsSoulsModuloCopy = + [ + 0xBEFC03FF, 0xA65CD91E, 0xBF960000, 0xBFA00003, 0xD7460002, 0x04010C0C, 0xF4201A84, 0xFA000000, + 0xBF8CC07F, 0x7DA8046A, 0xBF88002A, 0xF4200304, 0xFA000004, 0xBF8CC07F, 0x7E000C0C, 0xBF070C80, + 0x8588807E, 0x7E005700, 0x100000FF, 0x4F800000, 0x7E060F00, 0xD5766A00, 0x0202060C, 0x7D8A0280, + 0x4C020080, 0x02000101, 0xD56A0001, 0x00020700, 0x4C000303, 0x4A020303, 0x02000101, 0xD56A0000, + 0x00020500, 0xD5690001, 0x0002000C, 0x4C060302, 0x7D8C02F9, 0x06068A02, 0x7D86060C, 0x87EA6A0A, + 0x50000080, 0xD5286A00, 0x002A00C1, 0xD5010000, 0x002200C1, 0xD5690000, 0x0002000C, 0x4C000102, + 0xE0002000, 0x80000000, 0xBF8C3F70, 0xE0102000, 0x80010002, 0xBF810000, + ]; + + [Fact] + public void TheDemonsSoulsModuloCopyIsRecognized() + { + var program = Decode(DemonsSoulsModuloCopy); + var copy = BoundedFillDetector.DetectCopy(program); + + Assert.NotNull(copy); + static FillWord User(uint register) => new(FillWordSource.UserData, register, 0, 0); + Assert.Equal(12u, copy.GroupScalarRegister); + Assert.Equal(new FillWord(FillWordSource.BufferResource, 8, 0, 0), copy.Count); + Assert.Equal(new FillWord(FillWordSource.BufferResource, 8, 0, 4), copy.Modulus); + Assert.Equal([User(0), User(1), User(2), User(3)], copy.Source); + Assert.Equal([User(4), User(5), User(6), User(7)], copy.Destination); + Assert.Null(BoundedFillDetector.Detect(program)); + } + + [Fact] + public void AModuloCopyStoringAtTheRemainderIsRejected() + { + var words = DemonsSoulsModuloCopy.ToArray(); + words[Array.IndexOf(words, 0x80010002u)] = 0x80010000u; + Assert.Null(BoundedFillDetector.DetectCopy(Decode(words))); + } + + [Fact] + public void FillsAreNotCopies() + { + Assert.Null(BoundedFillDetector.DetectCopy(Decode(PatternFromUserData))); + Assert.Null(BoundedFillDetector.DetectCopy(Decode(ZeroRangeFromConstantBuffer))); + } + [Fact] public void APatternFillWithAnotherBranchTargetIsRejected() { diff --git a/tests/SharpEmu.Libs.Tests/Gpu/Pipelines/ShaderPrewarmListTests.cs b/tests/SharpEmu.Libs.Tests/Gpu/Pipelines/ShaderPrewarmListTests.cs index 1bdfea7..cacde02 100644 --- a/tests/SharpEmu.Libs.Tests/Gpu/Pipelines/ShaderPrewarmListTests.cs +++ b/tests/SharpEmu.Libs.Tests/Gpu/Pipelines/ShaderPrewarmListTests.cs @@ -119,6 +119,52 @@ public sealed class ShaderPrewarmListTests : IDisposable Assert.False(list.IsStampCurrent("build-b")); } + [Fact] + public void ProgressResumesUnderTheSameStampAndClears() + { + using (var list = Open()) + { + Assert.Empty(list.ReadProgress("build-a")); + list.AppendProgress("build-a", [1UL, 2UL]); + list.AppendProgress("build-a", [0xFEDC_BA98_7654_3210UL]); + } + + using (var reopened = Open()) + { + Assert.Equal(new HashSet { 1UL, 2UL, 0xFEDC_BA98_7654_3210UL }, reopened.ReadProgress("build-a")); + reopened.ClearProgress(); + Assert.Empty(reopened.ReadProgress("build-a")); + } + } + + [Fact] + public void ProgressOfAnotherStampIsDiscarded() + { + using var list = Open(); + list.AppendProgress("build-a", [7UL]); + + Assert.Empty(list.ReadProgress("build-b")); + Assert.Empty(list.ReadProgress("build-a")); + list.AppendProgress("build-b", [8UL]); + Assert.Equal(new HashSet { 8UL }, list.ReadProgress("build-b")); + } + + [Fact] + public void ARecordIdentityDependsOnItsSpecialization() + { + using (var list = Open()) + { + _ = CompileAtRuntime(list, BufferDescriptorWords.Format32UInt); + _ = CompileAtRuntime(list, BufferDescriptorWords.Format32x4UInt); + } + + using var reloaded = Open(); + var identities = reloaded.LoadedComputes().Select(item => ShaderPrewarmList.Identity(item.Record)).ToArray(); + Assert.Equal(2, identities.Length); + Assert.NotEqual(identities[0], identities[1]); + Assert.Equal(identities[0], ShaderPrewarmList.Identity(reloaded.LoadedComputes()[0].Record)); + } + [Fact] public void RecordedReadsReplayAsMergedRanges() { diff --git a/tests/SharpEmu.Libs.Tests/Gpu/Rendering/RenderExecutorComputeTests.cs b/tests/SharpEmu.Libs.Tests/Gpu/Rendering/RenderExecutorComputeTests.cs index e2a1b46..b05928c 100644 --- a/tests/SharpEmu.Libs.Tests/Gpu/Rendering/RenderExecutorComputeTests.cs +++ b/tests/SharpEmu.Libs.Tests/Gpu/Rendering/RenderExecutorComputeTests.cs @@ -416,6 +416,83 @@ public sealed class RenderExecutorComputeTests : IDisposable AssertNotDispatched(); } + private const ulong CopySource = RecordingRenderHost.MemoryBase + 0x71_0000; + + private void ConfigureBoundedCopy(uint count, uint modulus, uint sourceRecords, uint destinationRecords, uint sourceFormat = Format32UInt) + { + static SharpEmu.Libs.Gpu.Pipelines.FillWord User(uint register) => + new(SharpEmu.Libs.Gpu.Pipelines.FillWordSource.UserData, register, 0, 0); + _host.WriteGuest(FillParameters, [.. BitConverter.GetBytes(count), .. BitConverter.GetBytes(modulus)]); + var copy = new SharpEmu.Libs.Gpu.Pipelines.BoundedCopy( + 12, + new SharpEmu.Libs.Gpu.Pipelines.FillWord(SharpEmu.Libs.Gpu.Pipelines.FillWordSource.BufferResource, 8, 0, 0), + new SharpEmu.Libs.Gpu.Pipelines.FillWord(SharpEmu.Libs.Gpu.Pipelines.FillWordSource.BufferResource, 8, 0, 4), + [User(0), User(1), User(2), User(3)], + [User(4), User(5), User(6), User(7)]); + var program = new ShaderProgramInfo { Stage = ShaderStageKind.Compute, Hash = 0xF333, BoundedCopy = copy }; + var source = BufferDescriptor(CopySource, 4, sourceRecords, format: sourceFormat); + var destination = BufferDescriptor(MetadataAddress, 4, destinationRecords, format: Format32UInt); + var parameters = BufferDescriptor(FillParameters, 16, 1, format: 0); + var input = ComputeProgram(Stage(program, userData: [.. source, .. destination, .. parameters])).Input; + _pipelines.Compute = new ComputeProgram + { + Program = new ShaderProgram(0x33), + Input = new ComputeInputInfo + { + ThreadsX = 64, ThreadsY = 1, ThreadsZ = 1, GroupIdX = true, ThreadIdCount = 1, WaveSize = 64, + WorkgroupRegister = 12, Stage = input.Stage, + }, + }; + } + + [Fact] + public void BoundedCopy_BecomesAHostCopyAndRecordsNothing() + { + ConfigureBoundedCopy(count: 0x98, modulus: 0x98, sourceRecords: 0x98, destinationRecords: 0x98); + _executor.Dispatch(1, Banks(), 3, 1, 1, 0x41); + + Assert.Contains($"host_copy {MetadataAddress:X} {CopySource:X} 152 152", _host.Calls); + AssertNotDispatched(); + Assert.Contains("reset_bindings", _host.Calls); + } + + [Fact] + public void BoundedCopy_IsClampedToTheThreadsAndTheDestinationRecords() + { + ConfigureBoundedCopy(count: 0x1000, modulus: 4, sourceRecords: 4, destinationRecords: 0x400); + _executor.Dispatch(1, Banks(), 4, 1, 1, 0x41); + Assert.Contains($"host_copy {MetadataAddress:X} {CopySource:X} 4 256", _host.Calls); + + ConfigureBoundedCopy(count: 0x1000, modulus: 4, sourceRecords: 4, destinationRecords: 0x20); + _executor.Dispatch(1, Banks(), 4, 1, 1, 0x41); + Assert.Contains($"host_copy {MetadataAddress:X} {CopySource:X} 4 32", _host.Calls); + AssertNotDispatched(); + } + + [Theory] + [InlineData(0u, 4u, Format32UInt)] + [InlineData(8u, 4u, Format32UInt)] + [InlineData(4u, 4u, 22u)] + public void BoundedCopyThatIsNotAPlainWordCopy_IsDispatched(uint modulus, uint sourceRecords, uint sourceFormat) + { + ConfigureBoundedCopy(count: 0x40, modulus: modulus, sourceRecords: sourceRecords, destinationRecords: 0x40, sourceFormat: sourceFormat); + _executor.Dispatch(1, Banks(), 1, 1, 1, 0x41); + + Assert.DoesNotContain(_host.Calls, call => call.StartsWith("host_copy", StringComparison.Ordinal)); + AssertDispatched(1, 1, 1); + } + + [Fact] + public void BoundedCopyTheHostRefuses_IsDispatched() + { + _host.HostCopyAccepted = false; + ConfigureBoundedCopy(count: 0x40, modulus: 0x40, sourceRecords: 0x40, destinationRecords: 0x40); + _executor.Dispatch(1, Banks(), 1, 1, 1, 0x41); + + Assert.Contains($"host_copy {MetadataAddress:X} {CopySource:X} 64 64", _host.Calls); + AssertDispatched(1, 1, 1); + } + private void ConfigurePatternFill(uint count, uint length, uint[] pattern) { static SharpEmu.Libs.Gpu.Pipelines.FillWord User(uint register) => diff --git a/tests/SharpEmu.Libs.Tests/Gpu/Rendering/RenderExecutorTestSupport.cs b/tests/SharpEmu.Libs.Tests/Gpu/Rendering/RenderExecutorTestSupport.cs index b0dd732..c50241a 100644 --- a/tests/SharpEmu.Libs.Tests/Gpu/Rendering/RenderExecutorTestSupport.cs +++ b/tests/SharpEmu.Libs.Tests/Gpu/Rendering/RenderExecutorTestSupport.cs @@ -394,6 +394,14 @@ internal sealed class RecordingRenderHost : IRenderHost return RegisteredDcc.Contains(address); } + public bool HostCopyAccepted { get; set; } = true; + + public bool TryCopyWordsOnHost(ulong destination, ulong source, ulong sourceWords, ulong words) + { + Calls.Add($"host_copy {destination:X} {source:X} {sourceWords} {words}"); + return HostCopyAccepted; + } + public Exception Fatal(string message) => new RenderExecutorFatalException(message); } diff --git a/tests/SharpEmu.Libs.Tests/VideoOut/PresenterStartupTests.cs b/tests/SharpEmu.Libs.Tests/VideoOut/PresenterStartupTests.cs index ab3c171..51452fa 100644 --- a/tests/SharpEmu.Libs.Tests/VideoOut/PresenterStartupTests.cs +++ b/tests/SharpEmu.Libs.Tests/VideoOut/PresenterStartupTests.cs @@ -30,6 +30,10 @@ public sealed class PresenterStartupTests Set("_presenterStartupFailure", null); Set("_activePresenter", null); Set("_thread", Thread.CurrentThread); + Set("_shaderPrewarmDecided", true); + Set("_shaderPrewarmRunning", false); + Set("_shaderPrewarmReleased", false); + Set("_shaderPrewarmHoldReported", 0); } private static FieldInfo Field(string name) => PresenterType.GetField(name, BindingFlags.Static | BindingFlags.NonPublic)!; @@ -106,6 +110,45 @@ public sealed class PresenterStartupTests } } + [Theory] + [InlineData(false)] + [InlineData(true)] + public void ASubmissionWaitsUntilTheShaderPrewarmIsDecidedAndDone(bool undecided) + { + var runner = new StreamRunner(); + var queue = new CommandStreamQueue(runner.Host); + Assert.True(runner.Host.Memory.TryWrite(StreamRunner.CommandAddress, new byte[] { 0, 16, 0, 192, 0, 0, 0, 0 })); + using var state = new StartupState(); + state.Set(undecided ? "_shaderPrewarmDecided" : "_shaderPrewarmRunning", !undecided); + state.Publish(queue); + Exception? failure = null; + var thread = new Thread(() => + { + try { VulkanVideoPresenter.SubmitCommandStream(runner.Host.Memory, 0, StreamRunner.CommandAddress, 2, 1, null); } + catch (Exception exception) { failure = exception; } + }); + thread.Start(); + try + { + Assert.True(SpinWait.SpinUntil(() => (thread.ThreadState & ThreadState.WaitSleepJoin) != 0, 5000)); + Assert.False(thread.Join(200)); + Assert.Equal(0, queue.PendingSubmissionCount); + state.Set("_shaderPrewarmDecided", true); + state.Set("_shaderPrewarmRunning", false); + Assert.True(thread.Join(5000)); + Assert.Null(failure); + Assert.Equal(1, queue.PendingSubmissionCount); + } + finally + { + if (thread.IsAlive) + { + VulkanVideoPresenter.RequestClose(); + Assert.True(thread.Join(5000)); + } + } + } + [Fact] public void StartupFailureRejectsTheSubmission() {