diff --git a/tests/SharpEmu.Libs.Tests/GUI/PerGameSettingsTests.cs b/tests/SharpEmu.Libs.Tests/GUI/PerGameSettingsTests.cs index 3237d57..ce6eb1b 100644 --- a/tests/SharpEmu.Libs.Tests/GUI/PerGameSettingsTests.cs +++ b/tests/SharpEmu.Libs.Tests/GUI/PerGameSettingsTests.cs @@ -151,6 +151,40 @@ public sealed class PerGameSettingsTests Assert.Equal(["SHARPEMU_VK_VALIDATION"], perGame.EnvironmentToggles); } + [Fact] + public void ShaderCache_FollowsTheGlobalChoiceUnlessTheGameOverridesIt() + { + var global = new GuiSettings { ShaderCache = true }; + var inherited = new PerGameSettings { ShaderCache = true }; + inherited.RemoveInheritedValues(global); + Assert.Null(inherited.ShaderCache); + Assert.True(inherited.IsEmpty); + Assert.True(EffectiveLaunchSettings.Resolve(global, inherited).ShaderCache); + + var disabled = new PerGameSettings { ShaderCache = false }; + disabled.RemoveInheritedValues(global); + Assert.False(disabled.ShaderCache); + Assert.False(EffectiveLaunchSettings.Resolve(global, disabled).ShaderCache); + Assert.False(EffectiveLaunchSettings.Resolve(new GuiSettings { ShaderCache = false }, null).ShaderCache); + } + + [Fact] + public void ShaderLearn_IsOffByDefaultAndFollowsTheGameOverride() + { + var global = new GuiSettings(); + Assert.False(EffectiveLaunchSettings.Resolve(global, null).ShaderLearn); + + var inherited = new PerGameSettings { ShaderLearn = false }; + inherited.RemoveInheritedValues(global); + Assert.Null(inherited.ShaderLearn); + Assert.True(inherited.IsEmpty); + + var enabled = new PerGameSettings { ShaderLearn = true }; + enabled.RemoveInheritedValues(global); + Assert.True(enabled.ShaderLearn); + Assert.True(EffectiveLaunchSettings.Resolve(global, enabled).ShaderLearn); + } + [Fact] public void RemoveInheritedValues_DisabledEnvironmentEntry_MatchesMissingEntry() { diff --git a/tests/SharpEmu.Libs.Tests/Gpu/Pipelines/PipelineTestSupport.cs b/tests/SharpEmu.Libs.Tests/Gpu/Pipelines/PipelineTestSupport.cs index f3742b7..010c96e 100644 --- a/tests/SharpEmu.Libs.Tests/Gpu/Pipelines/PipelineTestSupport.cs +++ b/tests/SharpEmu.Libs.Tests/Gpu/Pipelines/PipelineTestSupport.cs @@ -46,7 +46,9 @@ internal sealed class FakePipelineHost(ICpuMemory memory) : IShaderPipelineHost public bool SharedInt64AtomicsEnabled => false; - public ShaderPrewarmList? ShaderPrewarm { get; set; } + public bool ShaderSignedZeroInfNanPreserveFloat32Supported { get; set; } + + public SharpEmu.Libs.Gpu.ShaderCache.ShaderCacheFile? ShaderCache { get; set; } public RenderHostLimits Limits => new(16384, 16384, 16384, 16384); diff --git a/tests/SharpEmu.Libs.Tests/Gpu/Pipelines/ShaderPrewarmListTests.cs b/tests/SharpEmu.Libs.Tests/Gpu/Pipelines/ShaderPrewarmListTests.cs deleted file mode 100644 index 7629dce..0000000 --- a/tests/SharpEmu.Libs.Tests/Gpu/Pipelines/ShaderPrewarmListTests.cs +++ /dev/null @@ -1,188 +0,0 @@ -// Copyright (C) 2026 SharpEmu Emulator Project -// SPDX-License-Identifier: GPL-2.0-or-later - -using SharpEmu.Libs.Gpu.Pipelines; -using SharpEmu.Libs.Gpu.Rendering; -using SharpEmu.ShaderCompiler; -using SharpEmu.ShaderCompiler.Resources; -using SharpEmu.ShaderCompiler.Vulkan; -using Xunit; - -namespace SharpEmu.Libs.Tests.Gpu.Pipelines; - -public sealed class ShaderPrewarmListTests : IDisposable -{ - private const ulong CodeAddress = PipelineTestGuest.MemoryBase + 0x1_0000; - private const ulong HeaderAddress = PipelineTestGuest.MemoryBase + 0x8000; - private const ulong BufferAddress = PipelineTestGuest.MemoryBase + 0x4_0000; - - private readonly string _directory = Path.Combine(Path.GetTempPath(), "SharpEmuTests", Guid.NewGuid().ToString("N")); - - public void Dispose() - { - if (Directory.Exists(_directory)) - { - Directory.Delete(_directory, recursive: true); - } - } - - private static byte[] Compile(ShaderCompileRequest request) - { - Assert.True(Gen5SpirvTranslator.TryCompileProgram(request, out var shader, out var error), error); - return shader.Spirv; - } - - private static (IShaderPipelineHost Host, byte[] Spirv) CompileAtRuntime(ShaderPrewarmList list, uint format) - { - var guest = new PipelineTestGuest(Compile); - guest.Host.ShaderPrewarm = list; - guest.RegisterProgram(CodeAddress, HeaderAddress, PipelineTestGuest.FormatLoadProgram); - var userData = PipelineTestGuest.BufferDescriptor(BufferAddress, 4, 64, format); - var cursor = 0u; - guest.Programs.GetOrCompile( - guest.Source(CodeAddress, ShaderStage.Compute, userData), PipelineTestGuest.ComputeOptions(threadsX: 64), ref cursor, out _); - return (guest.Host, Assert.Single(guest.Compiler.Shaders).Spirv); - } - - private ShaderPrewarmList Open() => ShaderPrewarmList.Open(_directory) ?? throw new InvalidOperationException("The list did not open."); - - [Fact] - public void ARecordedComputeProgramCompilesToTheSameSpirvAfterReload() - { - byte[] runtime; - IShaderPipelineHost host; - using (var list = Open()) - { - (host, runtime) = CompileAtRuntime(list, BufferDescriptorWords.Format32UInt); - } - - using var reloaded = Open(); - var (record, code) = Assert.Single(reloaded.LoadedComputes()); - Assert.True( - ShaderProgramCache.TryCompilePrewarm( - record, code, new FakeShaderCompiler(Compile), host.SharedInt64AtomicsEnabled, host.ExecGuardElisionEnabled, - host.ShaderSignedZeroInfNanPreserveFloat32Supported, out var compiled, out var layout, out var error), - error); - Assert.NotNull(layout); - Assert.Equal(runtime, Assert.IsType(compiled).Spirv); - } - - [Fact] - public void TheSameCompileIsRecordedOnceAndAnotherSpecializationAddsARecord() - { - using (var list = Open()) - { - CompileAtRuntime(list, BufferDescriptorWords.Format32UInt); - CompileAtRuntime(list, BufferDescriptorWords.Format32UInt); - CompileAtRuntime(list, BufferDescriptorWords.Format32x4UInt); - } - - using var reloaded = Open(); - var loaded = reloaded.LoadedComputes(); - Assert.Equal(2, loaded.Count); - Assert.Same(loaded[0].Code.Ranges, loaded[1].Code.Ranges); - } - - [Fact] - public void ATruncatedLastRecordIsDroppedAndTheListKeepsAppending() - { - using (var list = Open()) - { - CompileAtRuntime(list, BufferDescriptorWords.Format32UInt); - } - - var path = Path.Combine(_directory, ShaderPrewarmList.FileName); - var intactLength = new FileInfo(path).Length; - using (var stream = new FileStream(path, FileMode.Append)) - { - stream.Write([0x40, 0, 0, 0, 1, 2, 3, 4, 5, 6, 7, 8, 2, 9, 9]); - } - - using (var list = Open()) - { - Assert.Equal(1, list.LoadedComputeCount); - Assert.Equal(intactLength, new FileInfo(path).Length); - CompileAtRuntime(list, BufferDescriptorWords.Format32x4UInt); - } - - using var reloaded = Open(); - Assert.Equal(2, reloaded.LoadedComputeCount); - } - - [Fact] - public void TheStampMatchesOnlyTheTextLastWritten() - { - using var list = Open(); - Assert.False(list.IsStampCurrent("build-a")); - list.WriteStamp("build-a"); - Assert.True(list.IsStampCurrent("build-a")); - 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() - { - var memory = new FakeCpuMemory(PipelineTestGuest.MemoryBase, 0x1000); - Assert.True(memory.TryWrite(PipelineTestGuest.MemoryBase + 0x100, [1, 2, 3, 4, 5, 6, 7, 8])); - var recording = new RecordingCpuMemory(memory); - Span word = stackalloc byte[4]; - Assert.True(recording.TryRead(PipelineTestGuest.MemoryBase + 0x104, word)); - Assert.True(recording.TryRead(PipelineTestGuest.MemoryBase + 0x100, word)); - Assert.True(recording.TryRead(PipelineTestGuest.MemoryBase + 0x102, word)); - - var range = Assert.Single(recording.TakeRanges()); - Assert.Equal(PipelineTestGuest.MemoryBase + 0x100, range.Address); - Assert.Equal(new byte[] { 1, 2, 3, 4, 5, 6, 7, 8 }, range.Bytes); - - var replay = new ReplayCpuMemory([range]); - Assert.True(replay.TryRead(PipelineTestGuest.MemoryBase + 0x102, word)); - Assert.Equal(new byte[] { 3, 4, 5, 6 }, word.ToArray()); - Assert.False(replay.TryRead(PipelineTestGuest.MemoryBase + 0x106, word)); - } -} diff --git a/tests/SharpEmu.Libs.Tests/Gpu/ShaderCache/AgcHeaderBuilder.cs b/tests/SharpEmu.Libs.Tests/Gpu/ShaderCache/AgcHeaderBuilder.cs new file mode 100644 index 0000000..5149366 --- /dev/null +++ b/tests/SharpEmu.Libs.Tests/Gpu/ShaderCache/AgcHeaderBuilder.cs @@ -0,0 +1,110 @@ +// Copyright (C) 2026 SharpEmu Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +using System.Buffers.Binary; +using SharpEmu.Libs.Gpu.ShaderCache; + +namespace SharpEmu.Libs.Tests.Gpu.ShaderCache; + +internal static class AgcHeaderBuilder +{ + private const int SpecialsBytes = 48; + + public static readonly int[] PointerFields = [24, 32, 40, 48, 56]; + + public static byte[] Build( + byte type, + int codeSize, + (uint Offset, uint Value)[]? shaderRegisters = null, + (uint Offset, uint Value)[]? contextRegisters = null, + uint[]? inputSemantics = null, + uint[]? outputSemantics = null, + uint dispatchModifier = 0, + ushort scratchDwords = 0) + { + shaderRegisters ??= []; + contextRegisters ??= []; + inputSemantics ??= []; + outputSemantics ??= []; + var shaderStart = AgcShaderHeader.FixedBytes; + var contextStart = shaderStart + shaderRegisters.Length * 8; + var specialsStart = contextStart + contextRegisters.Length * 8; + var inputStart = specialsStart + SpecialsBytes; + var outputStart = inputStart + inputSemantics.Length * 4; + var size = outputStart + outputSemantics.Length * 4; + var header = new byte[size]; + AgcShaderHeader.Signature.CopyTo(header); + BinaryPrimitives.WriteUInt32LittleEndian(header.AsSpan(64), (uint)size); + BinaryPrimitives.WriteUInt32LittleEndian(header.AsSpan(68), (uint)codeSize); + BinaryPrimitives.WriteUInt32LittleEndian(header.AsSpan(80), (uint)inputSemantics.Length); + BinaryPrimitives.WriteUInt16LittleEndian(header.AsSpan(84), scratchDwords); + BinaryPrimitives.WriteUInt16LittleEndian(header.AsSpan(86), (ushort)outputSemantics.Length); + BinaryPrimitives.WriteUInt16LittleEndian(header.AsSpan(88), SpecialsBytes); + header[90] = type; + header[91] = (byte)contextRegisters.Length; + header[92] = (byte)shaderRegisters.Length; + void Pointer(int field, int target, bool present) + { + if (present) + { + BinaryPrimitives.WriteInt64LittleEndian(header.AsSpan(field), target - field); + } + } + + Pointer(32, shaderStart, shaderRegisters.Length != 0); + Pointer(24, contextStart, contextRegisters.Length != 0); + Pointer(40, specialsStart, true); + Pointer(48, inputStart, inputSemantics.Length != 0); + Pointer(56, outputStart, outputSemantics.Length != 0); + for (var index = 0; index < shaderRegisters.Length; index++) + { + BinaryPrimitives.WriteUInt32LittleEndian(header.AsSpan(shaderStart + index * 8), shaderRegisters[index].Offset); + BinaryPrimitives.WriteUInt32LittleEndian(header.AsSpan(shaderStart + index * 8 + 4), shaderRegisters[index].Value); + } + + for (var index = 0; index < contextRegisters.Length; index++) + { + BinaryPrimitives.WriteUInt32LittleEndian(header.AsSpan(contextStart + index * 8), contextRegisters[index].Offset); + BinaryPrimitives.WriteUInt32LittleEndian(header.AsSpan(contextStart + index * 8 + 4), contextRegisters[index].Value); + } + + BinaryPrimitives.WriteUInt32LittleEndian(header.AsSpan(specialsStart + 16), dispatchModifier); + for (var index = 0; index < inputSemantics.Length; index++) + { + BinaryPrimitives.WriteUInt32LittleEndian(header.AsSpan(inputStart + index * 4), inputSemantics[index]); + } + + for (var index = 0; index < outputSemantics.Length; index++) + { + BinaryPrimitives.WriteUInt32LittleEndian(header.AsSpan(outputStart + index * 4), outputSemantics[index]); + } + + return header; + } + + public static byte[] WithAbsolutePointers(byte[] header, ulong headerAddress) + { + var absolute = (byte[])header.Clone(); + foreach (var field in PointerFields) + { + var relative = BinaryPrimitives.ReadInt64LittleEndian(absolute.AsSpan(field)); + if (relative != 0) + { + BinaryPrimitives.WriteUInt64LittleEndian(absolute.AsSpan(field), headerAddress + (ulong)(field + relative)); + } + } + + return absolute; + } + + public static byte[] Code(uint[] words) + { + var code = new byte[words.Length * sizeof(uint)]; + for (var index = 0; index < words.Length; index++) + { + BinaryPrimitives.WriteUInt32LittleEndian(code.AsSpan(index * sizeof(uint)), words[index]); + } + + return code; + } +} diff --git a/tests/SharpEmu.Libs.Tests/Gpu/ShaderCache/ShaderCacheFileTests.cs b/tests/SharpEmu.Libs.Tests/Gpu/ShaderCache/ShaderCacheFileTests.cs new file mode 100644 index 0000000..51ccfd1 --- /dev/null +++ b/tests/SharpEmu.Libs.Tests/Gpu/ShaderCache/ShaderCacheFileTests.cs @@ -0,0 +1,441 @@ +// Copyright (C) 2026 SharpEmu Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +using SharpEmu.Libs.Gpu.Pipelines; +using SharpEmu.Libs.Gpu.Rendering; +using SharpEmu.Libs.Gpu.ShaderCache; +using SharpEmu.Libs.Tests.Gpu.Pipelines; +using SharpEmu.ShaderCompiler; +using SharpEmu.ShaderCompiler.Resources; +using SharpEmu.ShaderCompiler.Vulkan; +using Silk.NET.Vulkan; +using Xunit; + +namespace SharpEmu.Libs.Tests.Gpu.ShaderCache; + +public sealed class ShaderCacheFileTests : IDisposable +{ + private const ulong CodeAddress = PipelineTestGuest.MemoryBase + 0x1_0000; + private const ulong HeaderAddress = PipelineTestGuest.MemoryBase + 0x8000; + private const ulong VertexCodeAddress = PipelineTestGuest.MemoryBase + 0x2_0000; + private const ulong VertexHeaderAddress = PipelineTestGuest.MemoryBase + 0x9000; + private const ulong PixelCodeAddress = PipelineTestGuest.MemoryBase + 0x3_0000; + private const ulong PixelHeaderAddress = PipelineTestGuest.MemoryBase + 0xA000; + private const ulong BufferAddress = PipelineTestGuest.MemoryBase + 0x4_0000; + private const uint Format32x4Float = 77; + + private static readonly uint[] EmbeddedFetchVertexProgram = [0xF4080303, 0xFA000000, 0x02020B08, 0xE00C2000, 0x80030401, 0xBF810000]; + + private readonly string _directory = Path.Combine(Path.GetTempPath(), "SharpEmuTests", Guid.NewGuid().ToString("N")); + + public void Dispose() + { + if (Directory.Exists(_directory)) + { + Directory.Delete(_directory, recursive: true); + } + } + + private static byte[] Compile(ShaderCompileRequest request) + { + Assert.True(Gen5SpirvTranslator.TryCompileProgram(request, out var shader, out var error), error); + return shader.Spirv; + } + + private ShaderCacheFile Open() => ShaderCacheFile.Open(_directory) ?? throw new InvalidOperationException("The shader cache did not open."); + + private static (PipelineTestGuest Guest, byte[] Spirv) CompileCompute(ShaderCacheFile file, uint format) + { + var guest = new PipelineTestGuest(Compile); + guest.Host.ShaderCache = file; + guest.RegisterProgram(CodeAddress, HeaderAddress, PipelineTestGuest.FormatLoadProgram); + var cursor = 0u; + guest.Programs.GetOrCompile( + guest.Source(CodeAddress, ShaderStage.Compute, PipelineTestGuest.BufferDescriptor(BufferAddress, 4, 64, format)), + PipelineTestGuest.ComputeOptions(threadsX: 64), + ref cursor, + out _); + return (guest, Assert.Single(guest.Compiler.Shaders).Spirv); + } + + private static byte[] Replay(StageRecord record, ShaderCodeCapture code, IShaderPipelineHost host) + { + Assert.True( + ShaderProgramCache.TryReplay( + record, code, new FakeShaderCompiler(Compile), ShaderCompileHostFlags.From(host), out var compiled, out var info, out var error), + error); + Assert.NotNull(info); + return Assert.IsType(compiled).Spirv; + } + + [Fact] + public void ARecordedComputeProgramReplaysToTheSameSpirv() + { + PipelineTestGuest guest; + byte[] runtime; + using (var file = Open()) + { + (guest, runtime) = CompileCompute(file, BufferDescriptorWords.Format32UInt); + } + + using var reloaded = Open(); + var item = Assert.Single(reloaded.Computes); + Assert.Equal(runtime, Replay(item.Record, item.Code, guest.Host)); + } + + [Fact] + public void ARecordedGraphicsPipelineReplaysBothStagesToTheSameSpirv() + { + var descriptor = PipelineTestGuest.BufferDescriptor(BufferAddress, 16, 4, Format32x4Float); + var descriptorWords = new BufferDescriptorWords(descriptor[0], descriptor[1], descriptor[2], descriptor[3]); + PipelineTestGuest guest; + byte[] vertexRuntime; + byte[] pixelRuntime; + using (var file = Open()) + { + guest = new PipelineTestGuest(Compile); + guest.Host.ShaderCache = file; + guest.RegisterProgram(VertexCodeAddress, VertexHeaderAddress, EmbeddedFetchVertexProgram); + guest.RegisterProgram(PixelCodeAddress, PixelHeaderAddress, PipelineTestGuest.EndProgram); + var vertexOptions = new StageCompileOptions + { + VertexInfo = new VertexInputInfo + { + FetchEmbedded = true, + FetchAttributeRegister = 4, + FetchBufferRegister = 6, + Attributes = [new VertexAttributeResource(descriptorWords, 4, 4, 0, 0, 0, 0)], + Buffers = [new VertexInputBuffer(BufferAddress, 16, 4)], + }, + }; + var cursor = 0u; + var vertex = guest.Programs.GetOrCompile( + guest.Source(VertexCodeAddress, ShaderStage.Vertex, new uint[8]), vertexOptions, ref cursor, out _); + var pixel = guest.Programs.GetOrCompile( + guest.Source(PixelCodeAddress, ShaderStage.Pixel, []), new StageCompileOptions { PixelInfo = new PixelInputInfo() }, ref cursor, out _); + vertexRuntime = guest.Compiler.Shaders[0].Spirv; + pixelRuntime = guest.Compiler.Shaders[1].Spirv; + + Assert.True(guest.Programs.TryGetStageRecord(vertex.Id, out var vertexRecord, out var vertexCode)); + Assert.True(guest.Programs.TryGetStageRecord(pixel.Id, out var pixelRecord, out var pixelCode)); + var rendering = new PipelineRenderingState { ColorCount = 1, DepthFormat = Format.D32Sfloat }; + rendering.ColorFormats[0] = Format.R8G8B8A8Unorm; + var vertexInput = new PipelineVertexInputState { BindingCount = 1, AttributeCount = 1 }; + vertexInput.Bindings[0] = new PipelineVertexBinding(16, false); + vertexInput.Attributes[0] = new PipelineVertexAttribute(0, 0); + var parameters = new PipelineStaticParameters { Topology = PrimitiveTopology.TriangleList, WithDepth = true }; + file.RecordGraphics( + new GraphicsPipelineRecord + { + Vertex = vertexRecord, + Pixel = pixelRecord, + Rendering = rendering, + VertexInput = vertexInput, + StaticParameters = parameters, + Attributes = [new VertexAttributeFormat(descriptorWords, 4)], + }, + vertexCode, + pixelCode); + } + + using var reloaded = Open(); + var item = Assert.Single(reloaded.Graphics); + Assert.Equal(vertexRuntime, Replay(item.Record.Vertex, item.VertexCode, guest.Host)); + Assert.Equal(pixelRuntime, Replay(item.Record.Pixel!, item.PixelCode!, guest.Host)); + Assert.Equal(Format.R8G8B8A8Unorm, item.Record.Rendering.ColorFormats[0]); + Assert.Equal(Format.D32Sfloat, item.Record.Rendering.DepthFormat); + Assert.Equal(16u, item.Record.VertexInput.Bindings[0].Stride); + Assert.True(item.Record.StaticParameters.WithDepth); + Assert.Equal(descriptorWords, Assert.Single(item.Record.Attributes).Descriptor); + Assert.Equal(ShaderCacheFile.StageIdentity(item.Record.Vertex), item.VertexIdentity); + } + + [Fact] + public void TheSameCompileIsRecordedOnceAndAnotherSpecializationAddsARecord() + { + using (var file = Open()) + { + CompileCompute(file, BufferDescriptorWords.Format32UInt); + CompileCompute(file, BufferDescriptorWords.Format32UInt); + CompileCompute(file, BufferDescriptorWords.Format32x4UInt); + } + + using var reloaded = Open(); + Assert.Equal(2, reloaded.Computes.Count); + Assert.Same(reloaded.Computes[0].Code, reloaded.Computes[1].Code); + } + + [Fact] + public void ATruncatedLastRecordIsDroppedAndTheFileKeepsAppending() + { + using (var file = Open()) + { + CompileCompute(file, BufferDescriptorWords.Format32UInt); + } + + var path = Path.Combine(_directory, ShaderCacheFile.FileName); + var intactLength = new FileInfo(path).Length; + using (var stream = new FileStream(path, FileMode.Append)) + { + stream.Write([0x40, 0, 0, 0, 1, 2, 3, 4, 5, 6, 7, 8, 2, 9, 9]); + } + + using (var file = Open()) + { + Assert.Single(file.Computes); + Assert.Equal(intactLength, new FileInfo(path).Length); + CompileCompute(file, BufferDescriptorWords.Format32x4UInt); + } + + using var reloaded = Open(); + Assert.Equal(2, reloaded.Computes.Count); + } + + [Fact] + public void CompiledStagesAndBinariesAreFoundOnlyUnderTheirKeys() + { + var stage = new CompiledStageRecord + { + Spirv = [1, 2, 3, 4], + Bindings = [new LayoutBindingRecord(3, 7, 1, 32)], + VertexFetchComponents = new byte[VertexInputInfo.MaxBuffers], + }; + byte[] driver = [0xAA, 0xBB]; + using (var file = Open()) + { + file.AddCompiledStage(11, 100, stage); + file.AddBinary(driver, 42, [new PipelineBinaryPart([9, 9], [5, 6, 7])]); + } + + using var reloaded = Open(); + Assert.True(reloaded.TryGetCompiledStage(11, 100, out var loaded)); + Assert.Equal(stage.Spirv, loaded.Spirv); + Assert.Equal(stage.Bindings, loaded.Bindings); + Assert.False(reloaded.TryGetCompiledStage(11, 101, out _)); + Assert.True(reloaded.TryReadBinary(driver, 42, out var parts)); + Assert.Equal(new byte[] { 5, 6, 7 }, Assert.Single(parts).Data); + Assert.False(reloaded.TryReadBinary([0xAA, 0xBC], 42, out _)); + Assert.False(reloaded.TryReadBinary(driver, 43, out _)); + } + + [Fact] + public void CompactionDropsStagesForeignBinariesAndStaleDoneMarkersAndKeepsPipelineRecords() + { + byte[] driver = [1]; + var stage = new CompiledStageRecord { Spirv = [1], Bindings = [], VertexFetchComponents = [] }; + using (var file = Open()) + { + CompileCompute(file, BufferDescriptorWords.Format32UInt); + file.AddCompiledStage(1, 100, stage); + file.AddCompiledStage(2, 99, stage); + file.AddBinary(driver, 10, [new PipelineBinaryPart([1], [1])]); + file.AddBinary(driver, 11, [new PipelineBinaryPart([1], [1])]); + file.AddBinary([2], 10, [new PipelineBinaryPart([1], [1])]); + file.AddDone(5, 7); + file.AddDone(6, 8); + + Assert.Null(file.Compact(100, driver, 7)); + var result = file.Compact(100, driver, 7, force: true); + + Assert.Equal(4, result?.RecordsDropped); + Assert.True(result?.BytesAfter < result?.BytesBefore); + Assert.Null(file.Compact(100, driver, 7, force: true)); + } + + using var reloaded = Open(); + Assert.Single(reloaded.Computes); + Assert.False(reloaded.TryGetCompiledStage(1, 100, out _)); + Assert.False(reloaded.TryGetCompiledStage(2, 99, out _)); + Assert.True(reloaded.TryReadBinary(driver, 10, out _)); + Assert.True(reloaded.TryReadBinary(driver, 11, out _)); + Assert.False(reloaded.TryReadBinary([2], 10, out _)); + Assert.Equal(2, reloaded.BinaryCount); + Assert.True(reloaded.IsDone(5, 7)); + Assert.False(reloaded.IsDone(6, 8)); + } + + [Fact] + public void UnreferencedBinariesAreDroppedOnlyWhenTheCompactionAsksForIt() + { + byte[] driver = [1]; + using (var file = Open()) + { + file.AddBinary(driver, 10, [new PipelineBinaryPart([1], [1])]); + file.AddBinary(driver, 11, [new PipelineBinaryPart([1], [1])]); + file.AddBinary(driver, 12, [new PipelineBinaryPart([1], [1])]); + file.AddDone(5, 7, [10]); + file.AddDone(6, 8, [11]); + + Assert.Equal(1, file.Compact(100, driver, 7, force: true)?.RecordsDropped); + Assert.Equal(3, file.BinaryCount); + } + + using (var reopened = Open()) + { + Assert.Equal(2, reopened.Compact(100, driver, 7, force: true, dropUnreferencedBinaries: true)?.RecordsDropped); + } + + using var reloaded = Open(); + Assert.True(reloaded.TryReadBinary(driver, 10, out _)); + Assert.False(reloaded.TryReadBinary(driver, 11, out _)); + Assert.False(reloaded.TryReadBinary(driver, 12, out _)); + Assert.True(reloaded.IsDone(5, 7)); + Assert.Null(reloaded.Compact(100, driver, 7, force: true, dropUnreferencedBinaries: true)); + } + + [Fact] + public void AShaderSeedCarriesThePlayedPipelinesWithoutTheirAddresses() + { + var seedPath = Path.Combine(_directory, "shader_seeds", "PPSA00001.seed"); + StageRecord recorded; + using (var file = Open()) + { + CompileCompute(file, BufferDescriptorWords.Format32UInt); + recorded = Assert.Single(file.Computes).Record; + var learned = ShaderSeed.Learn(file); + Assert.Equal(0ul, Assert.Single(learned.Computes).Address); + Assert.True(learned.TrySave(seedPath)); + var items = ShaderWorkList.Build(file, true, ShaderSeed.Load(seedPath)).Items; + Assert.DoesNotContain(items, static item => item.Kind == ShaderWorkKind.SeedCompute); + } + + var loaded = Assert.Single(ShaderSeed.Load(seedPath).Computes); + Assert.NotEqual(0ul, recorded.Address); + Assert.Equal(ShaderSeed.ComputeIdentity(recorded), ShaderSeed.ComputeIdentity(loaded)); + Assert.Equal(recorded.Specialization, loaded.Specialization); + } + + [Fact] + public void ADifferenceNamesTheFirstChangedFieldAndItsCategory() + { + var recorded = new ResourceSpecialization + { + Images = [new ImageSpecialization(ImageNumericClass.Float, ImageDimension.Dim2D, 1, 0, 0xA04, 0, 0, 0, false)], + }; + var derived = new ResourceSpecialization + { + Images = [new ImageSpecialization(ImageNumericClass.Float, ImageDimension.Dim2D, 1, 0, 0xFAC, 0, 0, 0, false)], + }; + + var difference = new RecordDifference().Compare("Specialization", recorded, derived).ToString(); + + Assert.Equal("Specialization.Images[0].ShaderSwizzle 0xA04 -> 0xFAC", difference); + Assert.Equal("Specialization.Images[*].ShaderSwizzle", RecordDifference.Category(difference)); + Assert.False(new RecordDifference().Compare("Specialization", recorded, recorded.Clone()).Any); + } + + [Fact] + public void AFileWithAnotherHeaderStartsEmpty() + { + Directory.CreateDirectory(_directory); + File.WriteAllBytes(Path.Combine(_directory, ShaderCacheFile.FileName), [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13]); + + using var file = Open(); + Assert.Empty(file.Computes); + Assert.Equal(12, file.Length); + } + + [Fact] + public void AnOldPrewarmListIsImportedOnceAndRemoved() + { + var source = Path.Combine(_directory, "source"); + PipelineTestGuest guest; + byte[] runtime; + using (var file = ShaderCacheFile.Open(source)!) + { + (guest, runtime) = CompileCompute(file, BufferDescriptorWords.Format32UInt); + } + + CachedCompute recorded; + using (var file = ShaderCacheFile.Open(source)!) + { + recorded = Assert.Single(file.Computes); + } + + var legacy = Path.Combine(_directory, "shader-prewarm.bin"); + var stamp = Path.Combine(_directory, "shader-prewarm.stamp"); + File.WriteAllBytes(legacy, LegacyPrewarmList(recorded.Record, recorded.Code)); + File.WriteAllText(stamp, "build"); + + using (var file = Open()) + { + Assert.Equal(1, file.ImportedLegacyComputes); + var imported = Assert.Single(file.Computes); + Assert.Equal(recorded.Identity, imported.Identity); + Assert.Equal(runtime, Replay(imported.Record, imported.Code, guest.Host)); + } + + Assert.False(File.Exists(legacy)); + Assert.False(File.Exists(stamp)); + using var reloaded = Open(); + Assert.Equal(0, reloaded.ImportedLegacyComputes); + Assert.Equal(recorded.Identity, Assert.Single(reloaded.Computes).Identity); + } + + [Fact] + public void AnOldPrewarmListWithAnotherLayoutIsRemovedWithoutImporting() + { + Directory.CreateDirectory(_directory); + var legacy = Path.Combine(_directory, "shader-prewarm.bin"); + File.WriteAllBytes(legacy, [0x53, 0x45, 0x50, 0x57, 1, 0, 0, 0, 0, 0, 0, 0, 1]); + + using var file = Open(); + Assert.Equal(0, file.ImportedLegacyComputes); + Assert.Empty(file.Computes); + Assert.False(File.Exists(legacy)); + } + + private static byte[] LegacyPrewarmList(StageRecord record, ShaderCodeCapture code) + { + using var stream = new MemoryStream(); + using var writer = new BinaryWriter(stream); + writer.Write(0x57504553u); + writer.Write(1u); + writer.Write(LegacySchemaFingerprint()); + WriteLegacyRecord(writer, 1, ShaderCacheSerializer.Serialize(body => ShaderCacheSerializer.WriteCode(body, code))); + WriteLegacyRecord(writer, 2, ShaderCacheSerializer.Serialize(body => + { + body.Write(record.Hash); + body.Write(record.CodeSize); + body.Write(record.Address); + body.Write(record.UserDataBase); + body.Write(record.UserDataCount); + body.Write(record.PushDataCursor); + ShaderCacheSerializer.WriteCompute(body, record.Compute!, record.ComputeSystemRegisters); + ShaderCacheSerializer.WriteSpecialization(body, record.Specialization!); + })); + writer.Flush(); + return stream.ToArray(); + } + + private static void WriteLegacyRecord(BinaryWriter writer, byte kind, byte[] payload) + { + var body = new byte[payload.Length + 1]; + body[0] = kind; + payload.CopyTo(body, 1); + writer.Write((uint)body.Length); + writer.Write(System.IO.Hashing.XxHash3.HashToUInt64(body)); + writer.Write(body); + } + + private static uint LegacySchemaFingerprint() + { + var builder = new System.Text.StringBuilder(); + foreach (var type in new[] + { + typeof(ComputeInputInfo), typeof(Gen5ComputeSystemRegisters), typeof(ResourceSpecialization), + typeof(BufferSpecialization), typeof(ImageSpecialization), typeof(BufferCandidateTableSpecialization), + }) + { + builder.Append(type.FullName).Append('{'); + foreach (var property in type.GetProperties().OrderBy(static property => property.Name, StringComparer.Ordinal)) + { + builder.Append(property.PropertyType.FullName).Append(' ').Append(property.Name).Append(';'); + } + + builder.Append('}'); + } + + return (uint)System.IO.Hashing.XxHash3.HashToUInt64(System.Text.Encoding.UTF8.GetBytes(builder.ToString())); + } +} diff --git a/tests/SharpEmu.Libs.Tests/Gpu/ShaderCache/ShaderCacheSettingsTests.cs b/tests/SharpEmu.Libs.Tests/Gpu/ShaderCache/ShaderCacheSettingsTests.cs new file mode 100644 index 0000000..25c4c3e --- /dev/null +++ b/tests/SharpEmu.Libs.Tests/Gpu/ShaderCache/ShaderCacheSettingsTests.cs @@ -0,0 +1,53 @@ +// Copyright (C) 2026 SharpEmu Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +using SharpEmu.Libs.Gpu.ShaderCache; +using Xunit; + +namespace SharpEmu.Libs.Tests.Gpu.ShaderCache; + +public sealed class ShaderCacheSettingsTests +{ + [Theory] + [InlineData("on", true)] + [InlineData("1", true)] + [InlineData("True", true)] + [InlineData("OFF", false)] + [InlineData("0", false)] + [InlineData("disabled", false)] + public void KnownValuesParse(string value, bool expected) + { + Assert.True(ShaderCacheSettings.TryParse(value, out var enabled)); + Assert.Equal(expected, enabled); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData("sometimes")] + public void UnknownValuesDoNotParse(string? value) => Assert.False(ShaderCacheSettings.TryParse(value, out _)); + + [Fact] + public void TheCacheIsOnUnlessAValueOrTheLegacySwitchTurnsItOff() + { + Assert.True(ShaderCacheSettings.Resolve(null)); + Assert.False(ShaderCacheSettings.Resolve(null, "0")); + Assert.True(ShaderCacheSettings.Resolve("on", "0")); + Assert.False(ShaderCacheSettings.Resolve("off")); + } + + [Fact] + public void TheFormatRoundTrips() + { + Assert.True(ShaderCacheSettings.TryParse(ShaderCacheSettings.Format(true), out var on) && on); + Assert.True(ShaderCacheSettings.TryParse(ShaderCacheSettings.Format(false), out var off) && !off); + } + + [Fact] + public void TheCacheFileLivesUnderTheTitleFolder() + { + var path = ShaderCacheSettings.CacheFilePath("PPSA01341"); + Assert.Equal(ShaderCacheFile.FileName, Path.GetFileName(path)); + Assert.Equal("PPSA01341", Path.GetFileName(Path.GetDirectoryName(path))); + } +} diff --git a/tests/SharpEmu.Libs.Tests/Gpu/ShaderCache/ShaderPrecompileTests.cs b/tests/SharpEmu.Libs.Tests/Gpu/ShaderCache/ShaderPrecompileTests.cs new file mode 100644 index 0000000..f5d9c26 --- /dev/null +++ b/tests/SharpEmu.Libs.Tests/Gpu/ShaderCache/ShaderPrecompileTests.cs @@ -0,0 +1,345 @@ +// Copyright (C) 2026 SharpEmu Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +using SharpEmu.HLE; +using SharpEmu.Libs.Gpu.Pipelines; +using SharpEmu.Libs.Gpu.Rendering; +using SharpEmu.Libs.Gpu.ShaderCache; +using SharpEmu.Libs.Tests.Gpu.Pipelines; +using SharpEmu.ShaderCompiler; +using SharpEmu.ShaderCompiler.Resources; +using Xunit; + +namespace SharpEmu.Libs.Tests.Gpu.ShaderCache; + +public sealed class ShaderPrecompileTests : IDisposable +{ + private const byte ComputeType = 0; + private const byte PixelType = 1; + private const byte GeometryType = 2; + + private readonly string _directory = Path.Combine(Path.GetTempPath(), "SharpEmuTests", Guid.NewGuid().ToString("N")); + + public void Dispose() + { + if (Directory.Exists(_directory)) + { + Directory.Delete(_directory, recursive: true); + } + } + + private ShaderCacheFile Open() => ShaderCacheFile.Open(_directory) ?? throw new InvalidOperationException("The shader cache did not open."); + + private static byte[] ProgramCode => AgcHeaderBuilder.Code(PipelineTestGuest.FormatLoadProgram); + + private static CachedProgram Program(byte[] header, ulong address = GameShaderScanner.ScannedCodeAddress) + { + Assert.True(ShaderInventory.TryCreateCapture(ProgramCode, (uint)ProgramCode.Length, address, out var capture)); + Assert.True(AgcShaderHeader.TryParse(header, 0, out var parsed)); + var program = new InventoryProgram { Code = capture.Key, Source = InventorySource.Scanned, Header = parsed.Bytes }; + return new CachedProgram(program, capture, ShaderCacheFile.ProgramIdentity(program)); + } + + [Fact] + public void AnAgcHeaderExposesItsRegistersSemanticsAndDispatchModifier() + { + var bytes = AgcHeaderBuilder.Build( + ComputeType, 12, + shaderRegisters: [(0x207, 64), (0x213, 0x90)], + contextRegisters: [(0x1B3, 2)], + inputSemantics: [0x11, 0x22], + outputSemantics: [0x33], + dispatchModifier: 0x8000); + + Assert.True(AgcShaderHeader.TryParse(bytes, 0, out var header)); + Assert.Equal(AgcShaderType.Compute, header.Type); + Assert.Equal(12u, header.CodeSize); + Assert.Equal([new AgcRegister(0x207, 64), new AgcRegister(0x213, 0x90)], header.ShaderRegisters); + Assert.Equal([new AgcRegister(0x1B3, 2)], header.ContextRegisters); + Assert.Equal(new uint[] { 0x11, 0x22 }, header.InputSemantics); + Assert.Equal(new uint[] { 0x33 }, header.OutputSemantics); + Assert.Equal(0x8000u, header.DispatchModifier); + } + + [Fact] + public void ARelocatedHeaderNormalizesToTheFileLayout() + { + const ulong headerAddress = 0x7_0000_1000; + var bytes = AgcHeaderBuilder.Build(PixelType, 12, contextRegisters: [(0x1B3, 2)], inputSemantics: [0x11]); + var relocated = AgcHeaderBuilder.WithAbsolutePointers(bytes, headerAddress); + + Assert.True(AgcShaderHeader.TryParse(relocated, headerAddress, out var header)); + Assert.True(AgcShaderHeader.TryParse(bytes, 0, out var original)); + Assert.Equal(original.Bytes, header.Bytes); + } + + [Fact] + public void AHeaderWithAForeignSignatureOrSizeIsRejected() + { + var bytes = AgcHeaderBuilder.Build(ComputeType, 12); + bytes[0] = 0; + Assert.False(AgcShaderHeader.TryParse(bytes, 0, out _)); + var wrongSize = AgcHeaderBuilder.Build(ComputeType, 12); + Assert.False(AgcShaderHeader.TryParse(wrongSize.AsSpan(0, wrongSize.Length - 4), 0, out _)); + } + + [Fact] + public void AStaticComputeRecordReadsTheDispatchShapeFromTheHeader() + { + var resource2 = (4u << 1) | (1u << 7) | (2u << 11) | (3u << 15); + var program = Program(AgcHeaderBuilder.Build( + ComputeType, ProgramCode.Length, + shaderRegisters: [(0x207, 64), (0x208, 2), (0x209, 1), (0x213, resource2)], + dispatchModifier: 1u << 15, + scratchDwords: 5)); + + Assert.True(StaticStageInputs.TryCompute(program, computeWave64Supported: false, out var record)); + + var info = Assert.IsType(record.Compute); + Assert.Equal(ShaderStage.Compute, record.Stage); + Assert.Equal(4u, record.UserDataCount); + Assert.Null(record.Specialization); + Assert.Equal((64u, 2u, 1u), (info.ThreadsX, info.ThreadsY, info.ThreadsZ)); + Assert.Equal(32u, info.WaveSize); + Assert.Equal(3u * 128u, info.LocalDataShareDwords); + Assert.Equal(5u, info.ScratchDwords); + Assert.Equal(3, info.ThreadIdCount); + Assert.True(info.GroupIdX); + Assert.True(info.NeedsLocalDataShareBarriers); + Assert.Equal(4u, record.ComputeSystemRegisters!.Value.WorkGroupXRegister); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public void AStaticComputeRecordReplaysWithTheDefaultSpecialization(bool preserveFloat32) + { + var program = Program(AgcHeaderBuilder.Build(ComputeType, ProgramCode.Length, shaderRegisters: [(0x207, 64), (0x213, 4u << 1)])); + Assert.True(StaticStageInputs.TryCompute(program, computeWave64Supported: false, out var record)); + + var host = new FakePipelineHost(new ReplayCpuMemory([])) + { + ShaderSignedZeroInfNanPreserveFloat32Supported = preserveFloat32, + }; + Assert.True(ShaderProgramCache.TryReplay( + record, program.Code, new FakeShaderCompiler(static _ => new byte[] { 1, 2, 3, 4 }), ShaderCompileHostFlags.From(host), + out var compiled, out var info, out var error), error); + Assert.Equal(preserveFloat32, Assert.IsType(compiled).Request.ShaderSignedZeroInfNanPreserveFloat32Supported); + Assert.Single(info!.Buffers); + } + + [Fact] + public void TheScannerFindsCodeBeforeEachHeaderAndPairsAdjacentStages() + { + var game = Path.Combine(_directory, "game"); + Directory.CreateDirectory(game); + var code = ProgramCode; + var bundle = new MemoryStream(); + foreach (var (type, semantics) in new[] { (ComputeType, 0u), (GeometryType, 1u), (PixelType, 2u) }) + { + bundle.Write(code); + bundle.Write(AgcHeaderBuilder.Build(type, code.Length, inputSemantics: type == PixelType ? [semantics] : [], outputSemantics: type == GeometryType ? [semantics] : [])); + } + + File.WriteAllBytes(Path.Combine(game, "shaders.csdr"), bundle.ToArray()); + File.WriteAllBytes(Path.Combine(game, "movie.mp4"), bundle.ToArray()); + using (var file = Open()) + { + var result = GameShaderScanner.Scan(game, file, null, CancellationToken.None); + Assert.Equal(1, result.FilesScanned); + Assert.Equal(3, result.Programs); + Assert.Equal(3, file.Programs.Count); + var (vertex, pixel) = Assert.Single(file.Pairs); + Assert.True(file.TryGetProgram(vertex, out var vertexProgram)); + Assert.True(file.TryGetProgram(pixel, out var pixelProgram)); + Assert.True(StaticStageInputs.TryParse(vertexProgram, out var vertexHeader) && vertexHeader.Type == AgcShaderType.Geometry); + Assert.True(StaticStageInputs.TryParse(pixelProgram, out var pixelHeader) && pixelHeader.Type == AgcShaderType.Pixel); + } + + using var reopened = Open(); + var again = GameShaderScanner.Scan(game, reopened, null, CancellationToken.None); + Assert.Equal(0, again.FilesScanned); + Assert.Equal(1, again.FilesSkipped); + Assert.Equal(3, reopened.Programs.Count); + } + + [Fact] + public void InventoryPairsScansAndDoneMarkersSurviveAReopen() + { + var compute = Program(AgcHeaderBuilder.Build(ComputeType, ProgramCode.Length, shaderRegisters: [(0x207, 64)])); + using (var file = Open()) + { + Assert.Equal(compute.Identity, file.RecordProgram(compute.Program, compute.Code)); + Assert.Null(file.RecordProgram(compute.Program, compute.Code)); + file.RecordPair(1, 2); + file.RecordPair(1, 2); + file.RecordScannedFile(new ScannedFile("a/b.csdr", 10, 20, 1, 1)); + file.RecordScannedFile(new ScannedFile("a/b.csdr", 11, 21, 2, 1)); + file.AddDone(7, 9); + } + + using var reopened = Open(); + Assert.True(reopened.TryGetProgram(compute.Identity, out var program)); + Assert.Equal(compute.Program.Header, program.Program.Header); + Assert.Equal([(1ul, 2ul)], reopened.Pairs); + Assert.True(reopened.TryGetScannedFile("a/b.csdr", out var scanned)); + Assert.Equal(new ScannedFile("a/b.csdr", 11, 21, 2, 1), scanned); + Assert.True(reopened.IsDone(7, 9)); + Assert.False(reopened.IsDone(7, 10)); + } + + [Fact] + public void ASnapshotReadsUpToItsLengthAndNeverWrites() + { + long length; + string path; + using (var file = Open()) + { + file.AddDone(1, 5); + length = file.Length; + file.AddDone(2, 5); + path = file.Path; + } + + using (var snapshot = ShaderCacheFile.OpenSnapshot(path, length)!) + { + Assert.True(snapshot.IsDone(1, 5)); + Assert.False(snapshot.IsDone(2, 5)); + snapshot.AddDone(3, 5); + Assert.False(snapshot.IsDone(3, 5)); + } + + using var reopened = Open(); + Assert.True(reopened.IsDone(2, 5)); + Assert.False(reopened.IsDone(3, 5)); + } + + [Fact] + public void AWorkerPartMergesItsStagesBinariesAndDoneMarkers() + { + var stage = new CompiledStageRecord { Spirv = [1, 2], Bindings = [], VertexFetchComponents = [], PushDataEnd = 6 }; + var partPath = Path.Combine(_directory, "worker.part"); + Directory.CreateDirectory(_directory); + using (var part = ShaderCacheFile.OpenPart(partPath)!) + { + part.AddCompiledStage(3, 4, stage); + part.AddBinary([9], 42, [new PipelineBinaryPart([1], [7, 7])]); + part.AddDone(5, 6); + } + + using (var file = Open()) + { + Assert.Equal(3, file.MergePart(partPath)); + Assert.Equal(0, file.MergePart(partPath)); + } + + using var reopened = Open(); + Assert.True(reopened.TryGetCompiledStage(3, 4, out var merged)); + Assert.Equal(6u, merged.PushDataEnd); + Assert.True(reopened.HasBinary([9], 42)); + Assert.True(reopened.TryReadBinary([9], 42, out var parts)); + Assert.Equal(new byte[] { 7, 7 }, Assert.Single(parts).Data); + Assert.True(reopened.IsDone(5, 6)); + } + + [Fact] + public void ADamagedBinaryIsNotHandedOut() + { + string path; + using (var file = Open()) + { + file.AddBinary([9], 42, [new PipelineBinaryPart([1], [7, 7, 7, 7])]); + path = file.Path; + } + + var bytes = File.ReadAllBytes(path); + bytes[^1] ^= 0xFF; + File.WriteAllBytes(path, bytes); + using var reopened = Open(); + Assert.True(reopened.HasBinary([9], 42)); + Assert.False(reopened.TryReadBinary([9], 42, out _)); + } + + [Fact] + public void APixelExportTargetSurvivesTheStageRecord() + { + var record = new StageRecord + { + Stage = ShaderStage.Pixel, + Hash = 1, + CodeSize = 12, + Address = 0x1000, + UserDataBase = 0, + UserDataCount = 0, + PushDataCursor = 0, + Pixel = new PixelStageInputs(0, [new Gen5PixelOutputBinding(2, 0, Gen5PixelOutputKind.Float) { ExportTarget = 0 }], 2, 0, 2, [0]), + }; + + var payload = ShaderCacheSerializer.Serialize(writer => ShaderCacheSerializer.WriteStage(writer, record)); + var read = ShaderCacheSerializer.Deserialize(payload, ShaderCacheSerializer.ReadStage); + var output = Assert.Single(read.Pixel!.Outputs); + Assert.Equal(2u, output.GuestSlot); + Assert.Equal(0u, output.ExportTarget); + } + + [Fact] + public void AShaderSeedKeepsStatesAndPairsWithoutShaderCode() + { + var state = ShaderSeed.CreateState( + new PipelineRenderingState { ColorCount = 1 }, + new PipelineStaticParameters { ColorCount = 1 }, + [new Gen5PixelOutputBinding(0, 0, Gen5PixelOutputKind.Float)], + default); + var seedPath = Path.Combine(_directory, "shader_seeds", "PPSA00001.seed"); + var pixel = Program(AgcHeaderBuilder.Build(PixelType, ProgramCode.Length, contextRegisters: [(0x1C5, 4)])); + Assert.True(StaticStageInputs.TryParse(pixel, out var pixelHeader)); + var empty = new ShaderSeed(); + Assert.True(empty.IsEmpty); + + using (var file = Open()) + { + file.RecordPair(11, 22); + var learned = ShaderSeed.Learn(file); + Assert.Equal(new[] { (11ul, 22ul) }, learned.Pairs); + Assert.True(learned.TrySave(seedPath)); + } + + var loaded = ShaderSeed.Load(seedPath); + Assert.Equal(new[] { (11ul, 22ul) }, loaded.Pairs); + Assert.Empty(loaded.Palette(StaticStageInputs.PixelSignature(pixelHeader), 8)); + Assert.NotEqual(0ul, state.Identity); + Assert.Equal(StaticStageInputs.PixelSignature(pixelHeader), StaticStageInputs.PixelSignature(pixelHeader)); + } + + [Fact] + public void PortableWordsCarryTheStrideAndFormatAndDestinationSelect() + { + var descriptor = PipelineTestGuest.BufferDescriptor(0x1000, 48, 4, BufferDescriptorWords.Format32x4UInt); + var word = PortableBufferWord.Pack(descriptor); + Assert.Equal(48u, word.Stride); + Assert.Equal(BufferDescriptorWords.Format32x4UInt, (word.DescriptorWord3 >> 12) & 0x7F); + Assert.Equal(descriptor[3] & 0xFFF, word.DescriptorWord3 & 0xFFF); + descriptor[3] |= 1u << 30; + Assert.Equal((0u, 0u), PortableBufferWord.Pack(descriptor)); + } + + [Fact] + public void APortableLayoutPlacesTwoWordsPerBufferAfterTheMemoryOffsets() + { + var info = new ShaderResourceInfo(); + for (var index = 0; index < 5; index++) + { + info.Buffers.Add(new BufferResource { Read = true }); + } + + var plain = BindingLayout.Allocate(info, [], false, false, false, usesDispatchThreadLimits: true); + var portable = BindingLayout.Allocate(info, [], false, false, false, usesDispatchThreadLimits: true, portableBuffers: true); + + Assert.Equal(0u, plain.BufferWordCount); + Assert.Equal(10u, portable.BufferWordCount); + Assert.Equal(plain.DispatchThreadLimitsDword, portable.BufferWordDword); + Assert.Equal(portable.BufferWordDword + 10, portable.DispatchThreadLimitsDword); + Assert.Equal(plain.ShaderDataDwordCount + 10, portable.ShaderDataDwordCount); + Assert.NotEqual(plain, portable); + } +} diff --git a/tests/SharpEmu.Libs.Tests/Gpu/Vulkan/LayoutComputeRunner.cs b/tests/SharpEmu.Libs.Tests/Gpu/Vulkan/LayoutComputeRunner.cs index 708cba0..79fd062 100644 --- a/tests/SharpEmu.Libs.Tests/Gpu/Vulkan/LayoutComputeRunner.cs +++ b/tests/SharpEmu.Libs.Tests/Gpu/Vulkan/LayoutComputeRunner.cs @@ -181,7 +181,8 @@ internal sealed unsafe class LayoutComputeRunner : IDisposable uint[]? flattenedTable = null, IReadOnlyDictionary? boundImages = null, ulong shaderBase = 0, - uint[]? dispatchThreadLimits = null) + uint[]? dispatchThreadLimits = null, + uint[]? bufferWords = null) { var vk = _harness.Vk; var device = _harness.Device.Device; @@ -199,6 +200,12 @@ internal sealed unsafe class LayoutComputeRunner : IDisposable shaderData[layout.ShaderBaseDword + 1] = (uint)(shaderBase >> 32); } + if (bufferWords is not null) + { + if (bufferWords.Length != layout.BufferWordCount) throw new InvalidOperationException($"The dispatch requires {layout.BufferWordCount} buffer words."); + bufferWords.CopyTo(shaderData, (int)layout.BufferWordDword); + } + if (layout.UsesDispatchThreadLimits) { if (dispatchThreadLimits is not { Length: 3 }) throw new InvalidOperationException("The dispatch requires three thread limits."); diff --git a/tests/SharpEmu.Libs.Tests/Gpu/Vulkan/PortableBufferFormatDeviceTests.cs b/tests/SharpEmu.Libs.Tests/Gpu/Vulkan/PortableBufferFormatDeviceTests.cs new file mode 100644 index 0000000..e05566a --- /dev/null +++ b/tests/SharpEmu.Libs.Tests/Gpu/Vulkan/PortableBufferFormatDeviceTests.cs @@ -0,0 +1,99 @@ +// Copyright (C) 2026 SharpEmu Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +using System.Buffers.Binary; +using SharpEmu.Libs.Gpu.Buffers; +using SharpEmu.Libs.Tests.Gpu.Images; +using SharpEmu.ShaderCompiler; +using SharpEmu.ShaderCompiler.Resources; +using SharpEmu.ShaderCompiler.Vulkan; +using Xunit; +using static SharpEmu.ShaderCompiler.Tests.Resources.ResourceTestProgram; + +namespace SharpEmu.Libs.Tests.Gpu.Vulkan; + +public sealed class PortableBufferFormatDeviceTests(HeadlessVulkanFixture fixture) : IClassFixture +{ + private const uint MaxFloatUlps = 4; + private static readonly uint[] Swizzles = [0xFAC, 0x3AC, 0xA04, 0xF2E, 0x249]; + + [Fact] + public void RuntimeDescriptorWordsReadEveryFormatLikeABakedDescriptor() + { + var vulkan = fixture.Vulkan; + if (!GatePrerequisites.Ready(vulkan)) return; + + var program = Program(BufferLoad(0, 0, dwords: 4, formatted: true), BufferStore(8, 4, dwords: 4), EndProgram(16)); + var plan = Extract(program); + var portable = Compile(plan, program, ResourceSpecialization.Default(plan.Info), portableBuffers: true); + var source = Enumerable.Range(0, 64).Select(static index => (byte)(index * 37 + 11)).ToArray(); + using var harness = new ImageTestHarness(vulkan!); + for (uint format = 1; format < 128; format++) + { + if (!Gfx10UnifiedFormat.TryDecode(format, out var dataFormat, out var numberFormat) || dataFormat == 0) + { + continue; + } + + foreach (var swizzle in Swizzles) + { + var specialization = ResourceSpecialization.Default(plan.Info); + specialization.Buffers[0] = new BufferSpecialization(0, format, swizzle); + var baked = Run(harness, Compile(plan, program, specialization, portableBuffers: false), source, null); + var runtime = Run(harness, portable, source, [0, (format << 12) | swizzle, 0, 0]); + for (var component = 0; component < 4; component++) + { + var expected = BinaryPrimitives.ReadUInt32LittleEndian(baked.AsSpan(component * sizeof(uint))); + var actual = BinaryPrimitives.ReadUInt32LittleEndian(runtime.AsSpan(component * sizeof(uint))); + Assert.True(Matches(expected, actual, numberFormat), + $"format {format} swizzle 0x{swizzle:X3} component {component}: baked 0x{expected:X8}, runtime 0x{actual:X8}"); + } + } + } + + harness.AssertNoValidationMessages(); + } + + private static bool Matches(uint expected, uint actual, uint numberFormat) + { + if (expected == actual) + { + return true; + } + + if (numberFormat is 4 or 5 || float.IsNaN(BitConverter.UInt32BitsToSingle(expected)) != float.IsNaN(BitConverter.UInt32BitsToSingle(actual))) + { + return false; + } + + static long Ordered(uint bits) => (bits & 0x8000_0000) != 0 ? -(long)(bits & 0x7FFF_FFFF) : bits; + return Math.Abs(Ordered(expected) - Ordered(actual)) <= MaxFloatUlps; + } + + private static (ShaderCompileRequest Request, byte[] Spirv) Compile( + ShaderResourcePlan plan, Gen5ShaderProgram program, ResourceSpecialization specialization, bool portableBuffers) + { + var resources = ResourceMaterializer.ApplyTo(plan, specialization); + var layout = BindingLayout.Allocate( + resources.Info, + BindingLayout.CollectUserDataRegisters(program, 0, 64), + BindingLayout.UsesGlobalDataShare(program), + ShaderCompileRequest.RequiresFlattenedTable(plan, resources), + BindingLayout.ReadsShaderBase(program), + portableBuffers: portableBuffers); + var request = new ShaderCompileRequest(plan, resources, layout) { LocalSizeX = 1, ThreadCountX = 1, WaveSize = 32 }; + Assert.True(Gen5SpirvTranslator.TryCompileProgram(request, out var shader, out var error), error); + return (request, shader.Spirv); + } + + private static byte[] Run(ImageTestHarness harness, (ShaderCompileRequest Request, byte[] Spirv) shader, byte[] source, uint[]? bufferWords) + { + using var runner = new LayoutComputeRunner(harness, shader.Request, shader.Spirv); + var input = runner.CreateBuffer(source); + var output = runner.CreateBuffer(16); + harness.Run(() => runner.Dispatch(new uint[64], + new Dictionary { [DescriptorBindingKind.Buffers] = [input, output] }, 1, + bufferWords: bufferWords)); + return runner.ReadBack(output, 0, 16); + } +} diff --git a/tests/SharpEmu.Libs.Tests/VideoOut/PresenterUnderTest.cs b/tests/SharpEmu.Libs.Tests/VideoOut/PresenterUnderTest.cs index 6548b68..1d09870 100644 --- a/tests/SharpEmu.Libs.Tests/VideoOut/PresenterUnderTest.cs +++ b/tests/SharpEmu.Libs.Tests/VideoOut/PresenterUnderTest.cs @@ -10,6 +10,7 @@ using SharpEmu.Libs.Gpu.Images; using SharpEmu.Libs.Gpu.Pipelines; using SharpEmu.Libs.Gpu.Rendering; using SharpEmu.Libs.Gpu.Scheduling; +using SharpEmu.Libs.Gpu.ShaderCache; using SharpEmu.Libs.Tests.Gpu.Buffers; using SharpEmu.Libs.Tests.Gpu.Vulkan; using SharpEmu.Libs.VideoOut; @@ -79,6 +80,8 @@ internal sealed class PresenterUnderTest : IDisposable "_deferredGuestImageVersionDestroys", "_pipelineEntries", "_shaderModules", "_shaderModuleSpirvBytes", "_preparedTextures", "_barriersAfterRendering", "_feedbackSnapshotPool", + "_warmStages", "_shaderCacheWorkers", + "_pipelineOptimizationGate", "_pipelineOptimizationQueue", "_pipelineOptimizations", }) { var field = PresenterType.GetField(name, InstanceMembers)!; @@ -86,6 +89,8 @@ internal sealed class PresenterUnderTest : IDisposable } SetField("_shaderModuleCacheIdentities", new Dictionary()); + SetField("_shaderCacheCancel", new CancellationTokenSource()); + SetField("_pipelineStoreSources", Array.Empty()); var cacheShards = PresenterType.GetField("_pipelineCacheShards", InstanceMembers)!; cacheShards.SetValue(Instance, Activator.CreateInstance(cacheShards.FieldType, nonPublic: true)); forwarder.Target = this; @@ -156,6 +161,7 @@ internal sealed class PresenterUnderTest : IDisposable private void DestroyPipelineResources() { + InvokeMethod("StopPipelineOptimization"); InvokeMethod("DestroyRenderPipelines"); GetField("_descriptorHeap").Dispose(); } diff --git a/tests/SharpEmu.Libs.Tests/VideoOut/ShaderCachePresenterTests.cs b/tests/SharpEmu.Libs.Tests/VideoOut/ShaderCachePresenterTests.cs new file mode 100644 index 0000000..600eee9 --- /dev/null +++ b/tests/SharpEmu.Libs.Tests/VideoOut/ShaderCachePresenterTests.cs @@ -0,0 +1,140 @@ +// Copyright (C) 2026 SharpEmu Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +using SharpEmu.Libs.Gpu.Pipelines; +using SharpEmu.Libs.Gpu.Rendering; +using SharpEmu.Libs.Gpu.ShaderCache; +using SharpEmu.Libs.Tests.Gpu.Images; +using SharpEmu.Libs.Tests.Gpu.ShaderCache; +using SharpEmu.Libs.Tests.Gpu.Pipelines; +using SharpEmu.Libs.Tests.Gpu.Vulkan; +using SharpEmu.Libs.VideoOut; +using SharpEmu.ShaderCompiler; +using SharpEmu.ShaderCompiler.Resources; +using SharpEmu.ShaderCompiler.Vulkan; +using System.Collections.Concurrent; +using Silk.NET.Vulkan; +using Xunit; + +namespace SharpEmu.Libs.Tests.VideoOut; + +public sealed class ShaderCachePresenterTests(HeadlessVulkanFixture fixture) : IClassFixture, IDisposable +{ + private const ulong CodeAddress = PipelineTestGuest.MemoryBase + 0x1_0000; + private const ulong HeaderAddress = PipelineTestGuest.MemoryBase + 0x8000; + private const ulong BufferAddress = PipelineTestGuest.MemoryBase + 0x4_0000; + private const ulong Stamp = 42; + + private readonly string _directory = Path.Combine(Path.GetTempPath(), "SharpEmuTests", Guid.NewGuid().ToString("N")); + + public void Dispose() + { + if (Directory.Exists(_directory)) + { + Directory.Delete(_directory, recursive: true); + } + } + + private static byte[] Compile(ShaderCompileRequest request) + { + Assert.True(Gen5SpirvTranslator.TryCompileProgram(request, out var shader, out var error), error); + return shader.Spirv; + } + + private PipelineTestGuest RecordCompute() + { + using var file = ShaderCacheFile.Open(_directory)!; + var guest = new PipelineTestGuest(Compile); + guest.Host.ShaderCache = file; + guest.RegisterProgram(CodeAddress, HeaderAddress, PipelineTestGuest.FormatLoadProgram); + var cursor = 0u; + guest.Programs.GetOrCompile( + guest.Source(CodeAddress, ShaderStage.Compute, + PipelineTestGuest.BufferDescriptor(BufferAddress, 4, 64, BufferDescriptorWords.Format32UInt)), + PipelineTestGuest.ComputeOptions(threadsX: 64), ref cursor, out _); + return guest; + } + + private static PresenterUnderTest Presenter(HeadlessVulkan vulkan, ShaderCacheFile file, PipelineTestGuest guest) + { + var presenter = new PresenterUnderTest(vulkan); + presenter.SetField("_shaderCacheStamp", Stamp); + presenter.SetField("_shaderCacheFlags", ShaderCompileHostFlags.From(guest.Host)); + presenter.InvokeMethod("UseShaderCacheStores", file, new[] { file }); + return presenter; + } + + [Fact] + public async Task AFastComputeBuildIsReplacedByACompletedNativeOptimization() + { + var vulkan = fixture.Vulkan; + if (!GatePrerequisites.Ready(vulkan)) return; + + var guest = RecordCompute(); + using var file = ShaderCacheFile.Open(_directory)!; + var item = Assert.Single(file.Computes); + using var presenter = Presenter(vulkan!, file, guest); + presenter.SetField("_physicalDeviceVendorId", 0x10DEu); + presenter.SetField("_fastPipelineBuild", true); + Assert.True(ShaderProgramCache.TryReplay(item.Record, item.Code, guest.Compiler, ShaderCompileHostFlags.From(guest.Host), + out var compiled, out var info, out var error), error); + presenter.Run(() => + { + var host = (IShaderPipelineHost)presenter.Instance; + var module = host.CreateShaderModule(compiled!, ShaderStage.Compute, item.Record.Hash, 1); + host.CreateComputePipeline(new ComputePipelineDescription + { + Input = item.Record.Compute!, + Program = new ShaderProgram(1, module), + Stage = info!, + }); + }); + + var pending = Assert.Single(presenter.GetField>>("_pipelineOptimizations")); + var optimized = await pending.Value.WaitAsync(TimeSpan.FromSeconds(10)); + Assert.NotEqual(0ul, optimized.Handle); + Assert.NotEqual(pending.Key, optimized.Handle); + Assert.Equal(optimized.Handle, ((Pipeline)presenter.InvokeMethod("ResolveOptimizedPipeline", new Pipeline(pending.Key))!).Handle); + vulkan!.AssertNoValidationMessages(); + } + + [Fact] + public void ARecordedComputeIsPrecompiledWithoutGrowingTheFile() + { + var vulkan = fixture.Vulkan; + if (!GatePrerequisites.Ready(vulkan)) return; + + var guest = RecordCompute(); + using var file = ShaderCacheFile.Open(_directory)!; + var item = Assert.Single(file.Computes); + var lengthBefore = file.Length; + using var presenter = Presenter(vulkan!, file, guest); + + Assert.Equal(ShaderCacheOutcome.Compiled, presenter.InvokeMethod("CompileComputeItem", item.Record, item.Code)); + Assert.Equal(ShaderCacheOutcome.Compiled, presenter.InvokeMethod("CompileComputeItem", item.Record, item.Code)); + Assert.Equal(lengthBefore, file.Length); + presenter.InvokeMethod("StopShaderCache"); + vulkan!.AssertNoValidationMessages(); + } + + [Fact] + public void AStaticComputeFromItsHeaderCompilesOnTheDevice() + { + var vulkan = fixture.Vulkan; + if (!GatePrerequisites.Ready(vulkan)) return; + + var guest = RecordCompute(); + using var file = ShaderCacheFile.Open(_directory)!; + var code = AgcHeaderBuilder.Code(PipelineTestGuest.FormatLoadProgram); + Assert.True(ShaderInventory.TryCreateCapture(code, (uint)code.Length, GameShaderScanner.ScannedCodeAddress, out var capture)); + var header = AgcHeaderBuilder.Build(0, code.Length, shaderRegisters: [(0x207, 64), (0x213, 4u << 1)]); + var program = new InventoryProgram { Code = capture.Key, Source = InventorySource.Scanned, Header = header }; + var identity = file.RecordProgram(program, capture)!.Value; + Assert.True(file.TryGetProgram(identity, out var cached)); + Assert.True(StaticStageInputs.TryCompute(cached, computeWave64Supported: false, out var record)); + using var presenter = Presenter(vulkan!, file, guest); + + Assert.Equal(ShaderCacheOutcome.Compiled, presenter.InvokeMethod("CompileComputeItem", record, capture)); + vulkan!.AssertNoValidationMessages(); + } +} diff --git a/tests/SharpEmu.Libs.Tests/VideoOut/ShaderPrewarmPresenterTests.cs b/tests/SharpEmu.Libs.Tests/VideoOut/ShaderPrewarmPresenterTests.cs deleted file mode 100644 index b3de976..0000000 --- a/tests/SharpEmu.Libs.Tests/VideoOut/ShaderPrewarmPresenterTests.cs +++ /dev/null @@ -1,72 +0,0 @@ -// Copyright (C) 2026 SharpEmu Emulator Project -// SPDX-License-Identifier: GPL-2.0-or-later - -using System.Collections.Concurrent; -using SharpEmu.Libs.Gpu.Pipelines; -using SharpEmu.Libs.Gpu.Rendering; -using SharpEmu.Libs.Tests.Gpu.Images; -using SharpEmu.Libs.Tests.Gpu.Pipelines; -using SharpEmu.Libs.Tests.Gpu.Vulkan; -using SharpEmu.ShaderCompiler; -using SharpEmu.ShaderCompiler.Resources; -using SharpEmu.ShaderCompiler.Vulkan; -using Xunit; - -namespace SharpEmu.Libs.Tests.VideoOut; - -public sealed class ShaderPrewarmPresenterTests(HeadlessVulkanFixture fixture) : IClassFixture, IDisposable -{ - private const ulong CodeAddress = PipelineTestGuest.MemoryBase + 0x1_0000; - private const ulong HeaderAddress = PipelineTestGuest.MemoryBase + 0x8000; - private const ulong BufferAddress = PipelineTestGuest.MemoryBase + 0x4_0000; - - private readonly string _directory = Path.Combine(Path.GetTempPath(), "SharpEmuTests", Guid.NewGuid().ToString("N")); - - public void Dispose() - { - if (Directory.Exists(_directory)) - { - Directory.Delete(_directory, recursive: true); - } - } - - private static byte[] Compile(ShaderCompileRequest request) - { - Assert.True(Gen5SpirvTranslator.TryCompileProgram(request, out var shader, out var error), error); - return shader.Spirv; - } - - [Fact] - public void APrewarmRecordBuildsTheDevicePipeline() - { - var vulkan = fixture.Vulkan; - if (!GatePrerequisites.Ready(vulkan)) return; - - byte[] runtime; - using (var list = ShaderPrewarmList.Open(_directory)!) - { - var guest = new PipelineTestGuest(Compile); - guest.Host.ShaderPrewarm = list; - guest.RegisterProgram(CodeAddress, HeaderAddress, PipelineTestGuest.FormatLoadProgram); - var cursor = 0u; - guest.Programs.GetOrCompile( - guest.Source(CodeAddress, ShaderStage.Compute, - PipelineTestGuest.BufferDescriptor(BufferAddress, 4, 64, BufferDescriptorWords.Format32UInt)), - PipelineTestGuest.ComputeOptions(threadsX: 64), ref cursor, out _); - runtime = Assert.Single(guest.Compiler.Shaders).Spirv; - } - - using var reloaded = ShaderPrewarmList.Open(_directory)!; - var (record, code) = Assert.Single(reloaded.LoadedComputes()); - using var presenter = new PresenterUnderTest(vulkan); - var prewarmed = new ConcurrentDictionary(StringComparer.Ordinal); - presenter.SetField("_prewarmedShaderIdentities", prewarmed); - - presenter.InvokeMethod("PrewarmComputePipeline", record, code, new FakeShaderCompiler(Compile), false, true); - - Assert.Equal(0, presenter.GetField("_shaderPrewarmFailed")); - Assert.Equal(1, presenter.GetField("_shaderPrewarmCompiled")); - Assert.True(prewarmed.ContainsKey(SharpEmu.Libs.VideoOut.VulkanPipelineCacheStorage.CompiledShaderIdentity(runtime))); - vulkan.AssertNoValidationMessages(); - } -} diff --git a/tests/SharpEmu.Libs.Tests/VideoOut/VulkanPipelineOptimizationTests.cs b/tests/SharpEmu.Libs.Tests/VideoOut/VulkanPipelineOptimizationTests.cs new file mode 100644 index 0000000..0317c67 --- /dev/null +++ b/tests/SharpEmu.Libs.Tests/VideoOut/VulkanPipelineOptimizationTests.cs @@ -0,0 +1,59 @@ +// Copyright (C) 2026 SharpEmu Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +using System.Collections.Concurrent; +using System.Reflection; +using System.Runtime.CompilerServices; +using Silk.NET.Vulkan; +using Xunit; + +namespace SharpEmu.Libs.Tests.VideoOut; + +public sealed class VulkanPipelineOptimizationTests +{ + [Fact] + public async Task PendingAndFailedBuildsKeepTheInitialPipelineWhileCompletedBuildsArePromoted() + { + var type = PresenterUnderTest.PresenterType; + var presenter = RuntimeHelpers.GetUninitializedObject(type); + foreach (var name in new[] { "_pipelineOptimizationGate", "_pipelineOptimizationQueue", "_pipelineOptimizations" }) + { + var field = type.GetField(name, PresenterUnderTest.InstanceMembers)!; + field.SetValue(presenter, Activator.CreateInstance(field.FieldType)); + } + var tasks = (ConcurrentDictionary>)type.GetField("_pipelineOptimizations", PresenterUnderTest.InstanceMembers)!.GetValue(presenter)!; + object? Invoke(string name, params object[] arguments) => type.GetMethod(name, PresenterUnderTest.InstanceMembers)!.Invoke(presenter, arguments); + + using var release = new ManualResetEventSlim(); + var entered = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var initial = new Pipeline(11); + var optimized = new Pipeline(22); + try + { + Invoke("QueuePipelineOptimization", initial, (Func)(() => + { + entered.SetResult(); + if (!release.Wait(TimeSpan.FromSeconds(10))) throw new TimeoutException(); + return optimized; + })); + await entered.Task.WaitAsync(TimeSpan.FromSeconds(10)); + Assert.Equal(initial.Handle, ((Pipeline)Invoke("ResolveOptimizedPipeline", initial)!).Handle); + + release.Set(); + Assert.Equal(optimized.Handle, (await tasks[initial.Handle].WaitAsync(TimeSpan.FromSeconds(10))).Handle); + Assert.Equal(optimized.Handle, ((Pipeline)Invoke("ResolveOptimizedPipeline", initial)!).Handle); + + var failed = new Pipeline(33); + Invoke("QueuePipelineOptimization", failed, (Func)(() => throw new InvalidOperationException("test compile failure"))); + await tasks[failed.Handle].WaitAsync(TimeSpan.FromSeconds(10)); + Assert.Equal(failed.Handle, ((Pipeline)Invoke("ResolveOptimizedPipeline", failed)!).Handle); + } + finally + { + release.Set(); + if (tasks.TryGetValue(initial.Handle, out var pending)) await pending.WaitAsync(TimeSpan.FromSeconds(10)); + tasks.TryRemove(initial.Handle, out _); + Invoke("StopPipelineOptimization"); + } + } +} diff --git a/tests/SharpEmu.ShaderCompiler.Tests/Resources/PortableBufferWordTests.cs b/tests/SharpEmu.ShaderCompiler.Tests/Resources/PortableBufferWordTests.cs new file mode 100644 index 0000000..506223f --- /dev/null +++ b/tests/SharpEmu.ShaderCompiler.Tests/Resources/PortableBufferWordTests.cs @@ -0,0 +1,153 @@ +// Copyright (C) 2026 SharpEmu Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +using System.Runtime.InteropServices; +using SharpEmu.ShaderCompiler.Resources; +using SharpEmu.ShaderCompiler.Vulkan; +using Xunit; +using static SharpEmu.ShaderCompiler.Tests.Resources.ResourceTestProgram; + +namespace SharpEmu.ShaderCompiler.Tests.Resources; + +public sealed class PortableBufferWordTests +{ + [Theory] + [InlineData(0x3FFFu, 4012u)] + [InlineData(0x2000u, 940u)] + [InlineData(0x1000u, 556u)] + public void PackingPreservesFullStrideAndDescriptorWord3(uint stride, uint swizzle) + { + uint[] descriptor = [0x1000, 0xC0000000 | (stride << 16), 1, (22u << 12) | swizzle | (1u << 23)]; + var packed = PortableBufferWord.Pack(descriptor); + Assert.Equal(2, PortableBufferWord.DwordCount); + Assert.Equal(stride, packed.Stride); + Assert.Equal(descriptor[3], packed.DescriptorWord3); + Assert.Equal(swizzle, packed.DescriptorWord3 & 0xFFF); + } + + [Fact] + public void NullAndInvalidDescriptorsPackAsZero() + { + uint[][] descriptors = [[], [0, 0, 0], [0, 0, 0, 0], [0x1000, 0x3FFF0000, 1, 0xC0016FAC], [0, 0, 0, 0, 0]]; + foreach (var descriptor in descriptors) + Assert.Equal((0u, 0u), PortableBufferWord.Pack(descriptor)); + } + + [Theory] + [InlineData(0u)] + [InlineData(20u)] + public void TwoDwordsPerBufferPrecedeDispatchLimitsAndAffectAllocation(uint cursor) + { + var info = new ShaderResourceInfo { Buffers = Enumerable.Range(0, 5).Select(_ => new BufferResource { Read = true }).ToList() }; + var layout = BindingLayout.Allocate(info, [0u], false, false, true, cursor, usesDispatchThreadLimits: true, portableBuffers: true); + Assert.Equal(10u, layout.BufferWordCount); + Assert.Equal(5u, layout.BufferWordDword); + Assert.Equal(15u, layout.DispatchThreadLimitsDword); + Assert.Equal(18u, layout.ShaderDataDwordCount); + Assert.Equal(cursor == 0, layout.UsesPushData); + Assert.Equal(cursor != 0, layout.Find(DescriptorBindingKind.ShaderData) is not null); + Assert.NotEqual(layout, BindingLayout.Allocate(info, [0u], false, false, true, cursor, usesDispatchThreadLimits: true)); + BindingLayoutValidator.Validate(layout, info, [0u], false, false, true, Hash, ShaderStage.Compute); + } + + [Theory] + [InlineData(true, false)] + [InlineData(true, true)] + [InlineData(false, false)] + [InlineData(false, true)] + public void OnlyReadOnlyPortableBuffersShareSwizzleSpecializationsAndModules(bool portable, bool written) + { + var program = written + ? Program(BufferLoad(0, 0, dwords: 4, formatted: true), BufferStore(8, 0, formatted: true), EndProgram(16)) + : Program(BufferLoad(0, 0, dwords: 4, formatted: true), EndProgram(8)); + var plan = Extract(program); + var runtime = portable && !written; + var specializations = new List(); + var modules = new List(); + foreach (var swizzle in new uint[] { 4012, 940, 556 }) + { + uint[] descriptor = [0x1000, 0x3FFF0000, 1, (22u << 12) | swizzle]; + var snapshot = new ResourceSnapshot(); + var specialization = new ResourceSpecialization(); + Assert.True(ResourceMaterializer.Materialize(plan, new ResourceRuntimeInputs { UserData = descriptor, PortableBuffers = portable }, + ref snapshot, ref specialization)); + Assert.Equal(descriptor, Assert.Single(snapshot.Buffers)); + var buffer = Assert.Single(specialization.Buffers); + Assert.Equal(portable ? 0u : 0x3FFFu, buffer.PackedStride); + Assert.Equal(runtime ? DescriptorConstants.InvalidFormat : 22u, buffer.DescriptorFormat); + Assert.Equal(runtime ? DescriptorConstants.IdentityDestinationSelect : swizzle, buffer.DescriptorSwizzle); + specializations.Add(specialization); + modules.Add(Compile(specialization)); + + if (runtime) + { + var recorded = specialization.Clone(); + recorded.Buffers[0] = new BufferSpecialization(0x3FFF, 22, swizzle); + Assert.Equal(modules[^1], Compile(recorded)); + } + } + + if (runtime) + { + Assert.All(specializations, specialization => Assert.Equal(ResourceSpecialization.Default(plan.Info), specialization)); + Assert.All(modules, module => Assert.Equal(Compile(ResourceSpecialization.Default(plan.Info)), module)); + AssertPortableDwordsLoaded(modules[0]); + } + else + { + Assert.NotEqual(specializations[0], specializations[1]); + Assert.NotEqual(modules[0], modules[1]); + } + + byte[] Compile(ResourceSpecialization specialization) + { + var resources = ResourceMaterializer.ApplyTo(plan, specialization); + var layout = BindingLayout.Allocate(resources.Info, BindingLayout.CollectUserDataRegisters(program, 0, 64), + false, false, false, portableBuffers: portable); + Assert.True(Gen5SpirvTranslator.TryCompileProgram(new ShaderCompileRequest(plan, resources, layout), out var shader, out var error), error); + return shader.Spirv; + } + } + + [Fact] + public void MaterializationCacheSeparatesPortableAndBakedModes() + { + var plan = Extract(Program(BufferLoad(0, 0, formatted: true), EndProgram(8))); + var cache = new ResourceMaterializationCache(); + var snapshot = new ResourceSnapshot(); + var specialization = new ResourceSpecialization(); + foreach (var portable in new[] { false, true, false, true }) + { + var inputs = new ResourceRuntimeInputs { UserData = [0x1000, 16u << 16, 1, (22u << 12) | 940], PortableBuffers = portable }; + Assert.True(cache.Materialize(plan, inputs, (ulong _, Span _, bool _) => false, ref snapshot, ref specialization, out _)); + Assert.Equal(portable ? DescriptorConstants.IdentityDestinationSelect : 940u, specialization.Buffers[0].DescriptorSwizzle); + } + Assert.Equal(2, cache.Misses); + Assert.Equal(2, cache.Hits); + } + + private static void AssertPortableDwordsLoaded(byte[] spirv) + { + var array = new SpirvModuleInspector(spirv).Names.Single(pair => pair.Value == "guestBufferWords").Key; + var words = MemoryMarshal.Cast(spirv).ToArray(); + var constants = new Dictionary(); + var pointers = new Dictionary(); + var loads = new HashSet(); + for (var offset = 5; offset < words.Length; offset += (int)(words[offset] >> 16)) + { + switch ((SpirvOp)(words[offset] & 0xFFFF)) + { + case SpirvOp.Constant when (words[offset] >> 16) == 4: + constants[words[offset + 2]] = words[offset + 3]; + break; + case SpirvOp.AccessChain when words[offset + 3] == array: + pointers[words[offset + 2]] = constants[words[offset + 4]]; + break; + case SpirvOp.Load when pointers.TryGetValue(words[offset + 3], out var dword): + loads.Add(dword); + break; + } + } + Assert.True(loads.SetEquals([0u, 1u]), "Stride and descriptor word3 must both be loaded at runtime."); + } +}