From a02e8636ef4a83420dce7cdf16e7d969010fe361 Mon Sep 17 00:00:00 2001 From: kuba Date: Tue, 29 Sep 2026 23:19:26 +0200 Subject: [PATCH 1/4] [SpirvTranslator] Follow guest NaN and saturation rules for float ops v_min_f32 and v_max_f32 return the other operand when one of them is NaN, and v_cvt_u32_f32 and v_cvt_i32_f32 saturate an out-of-range value to the target type's limit and turn NaN into zero. The GLSL FMin and FMax instructions and SPIR-V's ConvertFToU and ConvertFToS all leave those cases undefined, so a host driver is free to hand back the NaN or an arbitrary integer, and Metal's fast math does return the NaN. The minimum and maximum now select the other operand when either side is NaN, and the two conversions clamp at the type's limits and return zero for NaN. NaN is tested on the value's bits rather than with an ordered comparison, so fast math cannot assume the test away. Device tests cover this and the wave-wide maximum that Unreal's motion blur computes with DPP row_shr, v_permlanex16_b32 and v_readlane_b32 across a 64-lane wave. --- .../Gen5SpirvTranslator.Alu.cs | 68 ++++++++++--- .../Vulkan/FloatNanSemanticsDeviceTests.cs | 67 +++++++++++++ .../Gpu/Vulkan/WaveMaxReductionDeviceTests.cs | 95 +++++++++++++++++++ 3 files changed, 218 insertions(+), 12 deletions(-) create mode 100644 tests/SharpEmu.Libs.Tests/Gpu/Vulkan/FloatNanSemanticsDeviceTests.cs create mode 100644 tests/SharpEmu.Libs.Tests/Gpu/Vulkan/WaveMaxReductionDeviceTests.cs diff --git a/src/SharpEmu.ShaderCompiler.Vulkan/Gen5SpirvTranslator.Alu.cs b/src/SharpEmu.ShaderCompiler.Vulkan/Gen5SpirvTranslator.Alu.cs index 3a41b8d..ae770cd 100644 --- a/src/SharpEmu.ShaderCompiler.Vulkan/Gen5SpirvTranslator.Alu.cs +++ b/src/SharpEmu.ShaderCompiler.Vulkan/Gen5SpirvTranslator.Alu.cs @@ -147,10 +147,7 @@ public static partial class Gen5SpirvTranslator break; } case "VCvtU32F32": - result = _module.AddInstruction( - SpirvOp.ConvertFToU, - _uintType, - GetFloatSource(instruction, 0)); + result = ConvertFloatToUnsignedSaturated(GetFloatSource(instruction, 0)); break; case "VCvtU16F16": { @@ -185,9 +182,7 @@ public static partial class Gen5SpirvTranslator source = Ext(8, _floatType, source); } - result = Bitcast( - _uintType, - _module.AddInstruction(SpirvOp.ConvertFToS, _intType, source)); + result = ConvertFloatToSignedSaturated(source); break; } case "VCvtF32I32": @@ -4440,14 +4435,15 @@ public static partial class Gen5SpirvTranslator _module.AddInstruction(operation, _floatType, left, right)); } + // v_min_f32 and v_max_f32 return the other operand when one is NaN. GLSL FMin and FMax + // leave that undefined, and Metal's fast math returns the NaN. private uint EmitFloatExtBinary( Gen5ShaderInstruction instruction, uint operation) => EmitFloatResult( instruction, - Ext( + NanIgnoringMinMax( operation, - _floatType, GetFloatSource(instruction, 0), GetFloatSource(instruction, 1))); @@ -4455,14 +4451,62 @@ public static partial class Gen5SpirvTranslator Gen5ShaderInstruction instruction, uint operation) { - var first = Ext( + var first = NanIgnoringMinMax( operation, - _floatType, GetFloatSource(instruction, 0), GetFloatSource(instruction, 1)); return EmitFloatResult( instruction, - Ext(operation, _floatType, first, GetFloatSource(instruction, 2))); + NanIgnoringMinMax(operation, first, GetFloatSource(instruction, 2))); + } + + private uint NanIgnoringMinMax(uint operation, uint left, uint right) + { + var result = Ext(operation, _floatType, left, right); + result = _module.AddInstruction(SpirvOp.Select, _floatType, IsNanBits(right), left, result); + return _module.AddInstruction(SpirvOp.Select, _floatType, IsNanBits(left), right, result); + } + + // NaN tested on the bits, which fast math cannot assume away. + private uint IsNanBits(uint value) => + _module.AddInstruction( + SpirvOp.UGreaterThan, + _boolType, + BitwiseAnd(Bitcast(_uintType, value), UInt(0x7FFF_FFFF)), + UInt(0x7F80_0000)); + + // v_cvt_u32_f32 and v_cvt_i32_f32 saturate and convert NaN to zero; SPIR-V leaves + // out-of-range conversions undefined. + private uint ConvertFloatToUnsignedSaturated(uint value) + { + var bits = Bitcast(_uintType, value); + var converted = _module.AddInstruction(SpirvOp.ConvertFToU, _uintType, value); + converted = _module.AddInstruction( + SpirvOp.Select, _uintType, + _module.AddInstruction(SpirvOp.UGreaterThanEqual, _boolType, bits, UInt(0x4F80_0000)), + UInt(uint.MaxValue), + converted); + // Negative values, including negative NaN, have the sign bit set. + converted = _module.AddInstruction( + SpirvOp.Select, _uintType, + _module.AddInstruction(SpirvOp.UGreaterThanEqual, _boolType, bits, UInt(0x8000_0000)), + UInt(0), + converted); + return _module.AddInstruction(SpirvOp.Select, _uintType, IsNanBits(value), UInt(0), converted); + } + + private uint ConvertFloatToSignedSaturated(uint value) + { + var bits = Bitcast(_uintType, value); + var magnitude = BitwiseAnd(bits, UInt(0x7FFF_FFFF)); + var converted = Bitcast(_uintType, _module.AddInstruction(SpirvOp.ConvertFToS, _intType, value)); + var negative = _module.AddInstruction(SpirvOp.UGreaterThanEqual, _boolType, bits, UInt(0x8000_0000)); + converted = _module.AddInstruction( + SpirvOp.Select, _uintType, + _module.AddInstruction(SpirvOp.UGreaterThanEqual, _boolType, magnitude, UInt(0x4F00_0000)), + _module.AddInstruction(SpirvOp.Select, _uintType, negative, UInt(0x8000_0000), UInt(0x7FFF_FFFF)), + converted); + return _module.AddInstruction(SpirvOp.Select, _uintType, IsNanBits(value), UInt(0), converted); } private uint EmitIntegerBinary( diff --git a/tests/SharpEmu.Libs.Tests/Gpu/Vulkan/FloatNanSemanticsDeviceTests.cs b/tests/SharpEmu.Libs.Tests/Gpu/Vulkan/FloatNanSemanticsDeviceTests.cs new file mode 100644 index 0000000..3c5345d --- /dev/null +++ b/tests/SharpEmu.Libs.Tests/Gpu/Vulkan/FloatNanSemanticsDeviceTests.cs @@ -0,0 +1,67 @@ +// 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; + +// v_cvt_u32_f32 and v_cvt_i32_f32 saturate and turn NaN into zero, and v_min_f32 and +// v_max_f32 return the other operand when one is NaN. UE's motion blur turns a NaN velocity +// into a sample loop bound this way; with host-defined results the loop never ends. +public sealed class FloatNanSemanticsDeviceTests(HeadlessVulkanFixture fixture) : IClassFixture +{ + private const uint QuietNan = 0x7FC0_0000; + private const uint NegativeNan = 0xFFC0_0000; + private const uint PositiveInfinity = 0x7F80_0000; + + [Theory] + [InlineData(QuietNan, 0x4000_0000u, 0u, 0u, 0x4000_0000u, 0x4000_0000u)] + [InlineData(NegativeNan, 0x4080_0000u, 0u, 0u, 0x4080_0000u, 0x4080_0000u)] + [InlineData(0x4000_0000u, QuietNan, 2u, 2u, 0x4000_0000u, 0x4000_0000u)] + [InlineData(PositiveInfinity, 0x3F80_0000u, uint.MaxValue, 0x7FFF_FFFFu, 0x3F80_0000u, PositiveInfinity)] + [InlineData(0xBF80_0000u, 0x3F80_0000u, 0u, 0xFFFF_FFFFu, 0xBF80_0000u, 0x3F80_0000u)] + [InlineData(0x4F95_02F9u, 0x3F80_0000u, uint.MaxValue, 0x7FFF_FFFFu, 0x3F80_0000u, 0x4F95_02F9u)] + [InlineData(0xCF32_D05Eu, 0x3F80_0000u, 0u, 0x8000_0000u, 0xCF32_D05Eu, 0x3F80_0000u)] + [InlineData(0x406C_CCCDu, 0x3F80_0000u, 3u, 3u, 0x3F80_0000u, 0x406C_CCCDu)] + public void ConversionsAndMinMaxFollowTheGuestNanRules( + uint value, uint other, uint unsignedResult, uint signedResult, uint minimum, uint maximum) + { + var vulkan = fixture.Vulkan; + if (!GatePrerequisites.Ready(vulkan, shaderInt64: true)) return; + var program = Program( + MoveVectorFromScalar(0, 1, 9), + Vop1(4, "VCvtU32F32", 2, Gen5Operand.Scalar(8)), + Vop1(8, "VCvtI32F32", 3, Gen5Operand.Scalar(8)), + Vop2(12, "VMinF32", 5, Gen5Operand.Scalar(8), Gen5Operand.Vector(1)), + Vop2(16, "VMaxF32", 6, Gen5Operand.Scalar(8), Gen5Operand.Vector(1)), + BufferAccess(20, "BufferStoreDword", 4, offset: 0, vectorData: 2), + BufferAccess(28, "BufferStoreDword", 4, offset: 4, vectorData: 3), + BufferAccess(36, "BufferStoreDword", 4, offset: 8, vectorData: 5), + BufferAccess(44, "BufferStoreDword", 4, offset: 12, vectorData: 6), + EndProgram(52)); + var (plan, resources, layout) = Prepare(program); + var request = new ShaderCompileRequest(plan, resources, layout) { LocalSizeX = 1, ThreadCountX = 1 }; + Assert.True(Gen5SpirvTranslator.TryCompileProgram(request, out var shader, out var error), error); + using var harness = new ImageTestHarness(vulkan); + using var runner = new LayoutComputeRunner(harness, request, shader.Spirv); + var result = runner.CreateBuffer(64); + var registers = new uint[256]; + registers[6] = 64; + registers[8] = value; + registers[9] = other; + harness.Run(() => runner.Dispatch(registers, + new Dictionary { [DescriptorBindingKind.Buffers] = [result] }, 1)); + var bytes = runner.ReadBack(result, 0, 4 * sizeof(uint)); + Assert.Equal( + [unsignedResult, signedResult, minimum, maximum], + Enumerable.Range(0, 4).Select(index => BinaryPrimitives.ReadUInt32LittleEndian(bytes.AsSpan(index * sizeof(uint)))).ToArray()); + harness.AssertNoValidationMessages(); + } +} diff --git a/tests/SharpEmu.Libs.Tests/Gpu/Vulkan/WaveMaxReductionDeviceTests.cs b/tests/SharpEmu.Libs.Tests/Gpu/Vulkan/WaveMaxReductionDeviceTests.cs new file mode 100644 index 0000000..b97025d --- /dev/null +++ b/tests/SharpEmu.Libs.Tests/Gpu/Vulkan/WaveMaxReductionDeviceTests.cs @@ -0,0 +1,95 @@ +// Copyright (C) 2026 SharpEmu Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +using System.Buffers.Binary; +using SharpEmu.HLE; +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; + +// The wave-wide maximum UE's motion blur takes of its per-lane sample counts: row_shr 1, 2, +// 4 and 8 through DPP, v_permlanex16_b32 across the row pairs, then v_readlane_b32 of lanes +// 31 and 63. The result bounds a sample loop, so a wrong maximum can hang the GPU. +public sealed class WaveMaxReductionDeviceTests(HeadlessVulkanFixture fixture) : IClassFixture +{ + private static readonly uint[] Reduction = + [ + 0x280404FA, 0xFF011102, // v_max_u32_dpp v2, v2, v2 row_shr:1 + 0x280404FA, 0xFF011202, // row_shr:2 + 0x280404FA, 0xFF011402, // row_shr:4 + 0x280404FA, 0xFF011802, // row_shr:8 + 0xD7781004, 0x03058302, // v_permlanex16_b32 v4, v2, -1, -1 + 0x28040902, // v_max_u32 v2, v2, v4 + 0xD760000A, 0x00013F02, // v_readlane_b32 s10, v2, 31 + 0xD760000B, 0x00017F02, // v_readlane_b32 s11, v2, 63 + 0x84940B0A, // s_max_u32 s20, s10, s11 + ]; + + [Theory] + [InlineData(0x7E040300u, 63u)] // v_mov_b32 v2, v0: the maximum is in the last lane + [InlineData(0x4C0400BFu, 63u)] // v_sub_nc_u32 v2, 63, v0: the maximum is in lane 0 + public void ReductionYieldsTheWaveMaximum(uint prepareValue, uint expected) + { + var vulkan = fixture.Vulkan; + if (!GatePrerequisites.Ready(vulkan, shaderInt64: true)) return; + uint[] words = + [ + prepareValue, + .. Reduction, + 0x7E060214, // v_mov_b32 v3, s20 + 0x340A0082, // v_lshlrev_b32 v5, 2, v0 + 0xE0701000, 0x80010305, // buffer_store_dword v3, v5, s[4:7], 0 offen + 0xBF810000, // s_endpgm + ]; + var bytes = new byte[words.Length * sizeof(uint)]; + for (var index = 0; index < words.Length; index++) + { + BinaryPrimitives.WriteUInt32LittleEndian(bytes.AsSpan(index * sizeof(uint)), words[index]); + } + + var context = new CpuContext(new InstructionMemory(bytes), Generation.Gen5); + Assert.True(Gen5ShaderTranslator.TryDecodeProgram(context, 0x1000, out var program, out var decodeError), decodeError); + var (plan, resources, layout) = Prepare(program); + var request = new ShaderCompileRequest(plan, resources, layout) + { + LocalSizeX = 64, ThreadCountX = 64, WaveSize = 64, + }; + Assert.True(Gen5SpirvTranslator.TryCompileProgram(request, out var shader, out var error), error); + using var harness = new ImageTestHarness(vulkan); + using var runner = new LayoutComputeRunner(harness, request, shader.Spirv); + var output = runner.CreateBuffer(256); + var registers = new uint[256]; + registers[6] = 256; + harness.Run(() => runner.Dispatch(registers, + new Dictionary { [DescriptorBindingKind.Buffers] = [output] }, 1)); + var result = runner.ReadBack(output, 0, 256); + for (var lane = 0; lane < 64; lane++) + { + Assert.Equal(expected, BinaryPrimitives.ReadUInt32LittleEndian(result.AsSpan(lane * sizeof(uint)))); + } + + harness.AssertNoValidationMessages(); + } + + private sealed class InstructionMemory(byte[] bytes) : ICpuMemory + { + public bool TryRead(ulong address, Span destination) + { + if (address < 0x1000 || address - 0x1000 + (ulong)destination.Length > (ulong)bytes.Length) + { + return false; + } + + bytes.AsSpan((int)(address - 0x1000), destination.Length).CopyTo(destination); + return true; + } + + public bool TryWrite(ulong address, ReadOnlySpan source) => false; + } +} From 0368b76d6cfe63f038def4211828aa10ad8bc983 Mon Sep 17 00:00:00 2001 From: kuba Date: Tue, 29 Sep 2026 23:19:26 +0200 Subject: [PATCH 2/4] [ShaderCompiler] Fix the SOPK immediate width and S_ADDK_I32's SCC Two things were wrong in the SOPK forms, in both the SPIR-V and the MSL backend. S_CMPK_*_U32 compares against SIMM16 zero-extended; only the signed compares sign-extend it. Both translators sign-extended it for every compare, so "s_cmpk_le_u32 s4, 0xffff" was true for any value at all. NGG geometry programs use exactly that test on the packed vertex and primitive count, so they took their no-primitive path and exported a single null primitive, which left Unreal's volume passes empty. The register S_CMPK names is also only read, yet user-data liveness recorded it as a definition, so a compare on a user-data register left that register unloaded; it now counts as a use, as does the register of the SOPK accumulations. S_ADDK_I32 is defined as D.i = D.i + signext(SIMM16) with SCC set on signed overflow, the same SCC rule as S_ADD_I32, which both backends already implement. Neither wrote SCC for the SOPK form, so a following S_CBRANCH_SCC0/1 or S_CSELECT read whatever the previous instruction had left there. S_MOVK_I32 and S_MULK_I32 correctly leave SCC alone. --- .../Gen5MslTranslator.Alu.cs | 23 ++- .../Gen5SpirvTranslator.Alu.cs | 13 ++ .../Resources/BindingLayout.cs | 13 +- .../ScalarCompareImmediateDeviceTests.cs | 80 +++++++++ .../Gen5ScalarAddImmediateMslTests.cs | 112 +++++++++++++ .../Gen5ScalarAddImmediateTests.cs | 156 ++++++++++++++++++ 6 files changed, 393 insertions(+), 4 deletions(-) create mode 100644 tests/SharpEmu.Libs.Tests/Gpu/Vulkan/ScalarCompareImmediateDeviceTests.cs create mode 100644 tests/SharpEmu.ShaderCompiler.Metal.Tests/Gen5ScalarAddImmediateMslTests.cs create mode 100644 tests/SharpEmu.ShaderCompiler.Tests/Gen5ScalarAddImmediateTests.cs diff --git a/src/SharpEmu.ShaderCompiler.Metal/Gen5MslTranslator.Alu.cs b/src/SharpEmu.ShaderCompiler.Metal/Gen5MslTranslator.Alu.cs index fb3dd60..4eb07ac 100644 --- a/src/SharpEmu.ShaderCompiler.Metal/Gen5MslTranslator.Alu.cs +++ b/src/SharpEmu.ShaderCompiler.Metal/Gen5MslTranslator.Alu.cs @@ -1074,14 +1074,21 @@ public static partial class Gen5MslTranslator var immediate = unchecked((uint)(short)(instruction.Words[0] & 0xFFFF)); if (instruction.Opcode.StartsWith("SCmpk", StringComparison.Ordinal)) { + // The unsigned compares take SIMM16 zero-extended; only the signed ones sign-extend it. + if (instruction.Opcode.EndsWith("U32", StringComparison.Ordinal)) + { + immediate = instruction.Words[0] & 0xFFFF; + } + return TryEmitScalarCompareK(instruction, destination, immediate, out error); } + var current = ScalarExpression(destination); var value = instruction.Opcode switch { "SMovkI32" => FormatUInt(immediate), - "SAddkI32" => $"({ScalarExpression(destination)} + {FormatUInt(immediate)})", - "SMulkI32" => $"({ScalarExpression(destination)} * {FormatUInt(immediate)})", + "SAddkI32" => $"({current} + {FormatUInt(immediate)})", + "SMulkI32" => $"({current} * {FormatUInt(immediate)})", _ => string.Empty, }; if (value.Length == 0) @@ -1090,7 +1097,17 @@ public static partial class Gen5MslTranslator return false; } - StoreScalar(destination, Temp("uint", value)); + var stored = Temp("uint", value); + // RDNA2 ISA: S_ADDK_I32 writes SCC = signed overflow, exactly like + // S_ADD_I32. S_MOVK_I32 and S_MULK_I32 leave SCC alone. + if (instruction.Opcode == "SAddkI32") + { + var addend = FormatUInt(immediate); + Line( + $"scc = ((~({current} ^ {addend}) & ({current} ^ {stored})) >> 31) != 0u;"); + } + + StoreScalar(destination, stored); return true; } diff --git a/src/SharpEmu.ShaderCompiler.Vulkan/Gen5SpirvTranslator.Alu.cs b/src/SharpEmu.ShaderCompiler.Vulkan/Gen5SpirvTranslator.Alu.cs index ae770cd..329b175 100644 --- a/src/SharpEmu.ShaderCompiler.Vulkan/Gen5SpirvTranslator.Alu.cs +++ b/src/SharpEmu.ShaderCompiler.Vulkan/Gen5SpirvTranslator.Alu.cs @@ -2515,6 +2515,12 @@ public static partial class Gen5SpirvTranslator var immediate = unchecked((uint)(short)(instruction.Words[0] & 0xFFFF)); if (instruction.Opcode.StartsWith("SCmpk", StringComparison.Ordinal)) { + // The unsigned compares take SIMM16 zero-extended; only the signed ones sign-extend it. + if (instruction.Opcode.EndsWith("U32", StringComparison.Ordinal)) + { + immediate = instruction.Words[0] & 0xFFFF; + } + return TryEmitScalarCompareK(instruction, destination, immediate, out error); } @@ -2536,6 +2542,13 @@ public static partial class Gen5SpirvTranslator return false; } + // RDNA2 ISA: S_ADDK_I32 writes SCC = signed overflow, exactly like + // S_ADD_I32. S_MOVK_I32 and S_MULK_I32 leave SCC alone. + if (instruction.Opcode == "SAddkI32") + { + Store(_scc, SignedAddOverflow(current, UInt(immediate), value)); + } + StoreS(destination, value); return true; } diff --git a/src/SharpEmu.ShaderCompiler/Resources/BindingLayout.cs b/src/SharpEmu.ShaderCompiler/Resources/BindingLayout.cs index 4f66edf..b803a52 100644 --- a/src/SharpEmu.ShaderCompiler/Resources/BindingLayout.cs +++ b/src/SharpEmu.ShaderCompiler/Resources/BindingLayout.cs @@ -450,7 +450,13 @@ public sealed class BindingLayout : IEquatable } } - if (instruction.Opcode is "SBitset0B32" or "SBitset1B32") + // These read the register their destination field names: the bit sets and the SOPK + // accumulations modify it, and the SOPK compares only read it. + var comparesDestination = instruction.Encoding == Gen5ShaderEncoding.Sopk && + instruction.Opcode.StartsWith("SCmpk", StringComparison.Ordinal); + if (comparesDestination || + instruction.Opcode is "SBitset0B32" or "SBitset1B32" || + instruction.Encoding == Gen5ShaderEncoding.Sopk && instruction.Opcode is "SAddkI32" or "SMulkI32" or "SCmovkI32") { foreach (var destination in instruction.Destinations) { @@ -461,6 +467,11 @@ public sealed class BindingLayout : IEquatable } } + if (comparesDestination) + { + return; + } + switch (instruction.Control) { case Gen5ImageControl image: diff --git a/tests/SharpEmu.Libs.Tests/Gpu/Vulkan/ScalarCompareImmediateDeviceTests.cs b/tests/SharpEmu.Libs.Tests/Gpu/Vulkan/ScalarCompareImmediateDeviceTests.cs new file mode 100644 index 0000000..9b5b772 --- /dev/null +++ b/tests/SharpEmu.Libs.Tests/Gpu/Vulkan/ScalarCompareImmediateDeviceTests.cs @@ -0,0 +1,80 @@ +// Copyright (C) 2026 SharpEmu Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +using System.Buffers.Binary; +using SharpEmu.HLE; +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; + +// S_CMPK compares an SGPR with SIMM16: zero-extended for the unsigned compares and +// sign-extended for the signed ones. NGG geometry programs test "no primitives" with +// s_cmpk_le_u32 s4, 0xffff on a packed vertex and primitive count. +public sealed class ScalarCompareImmediateDeviceTests(HeadlessVulkanFixture fixture) : IClassFixture +{ + private const uint LessEqualSigned = 0x08; + private const uint LessEqualUnsigned = 0x0E; + + [Theory] + [InlineData(LessEqualUnsigned, 0x0002_0006u, 0xFFFFu, 0u)] + [InlineData(LessEqualUnsigned, 0x0000_0010u, 0xFFFFu, 1u)] + [InlineData(LessEqualUnsigned, 0x0000_FFFFu, 0xFFFFu, 1u)] + [InlineData(LessEqualSigned, 0x0002_0006u, 0xFFFFu, 0u)] + [InlineData(LessEqualSigned, 0xFFFF_FFFEu, 0xFFFFu, 1u)] + public void CompareUsesTheImmediateExtensionOfItsSignedness(uint opcode, uint value, uint immediate, uint expected) + { + var vulkan = fixture.Vulkan; + if (!GatePrerequisites.Ready(vulkan, shaderInt64: true)) return; + var program = Program( + DecodeWords(0x8000_0000u | ((0x60u + opcode) << 23) | (8u << 16) | immediate) with { Pc = 0 }, + DecodeWords(0x8509_8081u) with { Pc = 4 }, + MoveVectorFromScalar(8, 2, 9), + BufferAccess(12, "BufferStoreDword", 4, vectorData: 2), + EndProgram(20)); + var (plan, resources, layout) = Prepare(program); + var request = new ShaderCompileRequest(plan, resources, layout) { LocalSizeX = 1, ThreadCountX = 1 }; + Assert.True(Gen5SpirvTranslator.TryCompileProgram(request, out var shader, out var error), error); + using var harness = new ImageTestHarness(vulkan); + using var runner = new LayoutComputeRunner(harness, request, shader.Spirv); + var result = runner.CreateBuffer(64); + var registers = new uint[256]; + registers[6] = 64; + registers[8] = value; + harness.Run(() => runner.Dispatch(registers, + new Dictionary { [DescriptorBindingKind.Buffers] = [result] }, 1)); + Assert.Equal(expected, BinaryPrimitives.ReadUInt32LittleEndian(runner.ReadBack(result, 0, sizeof(uint)))); + harness.AssertNoValidationMessages(); + } + + private static Gen5ShaderInstruction DecodeWords(uint word) + { + var bytes = new byte[2 * sizeof(uint)]; + BinaryPrimitives.WriteUInt32LittleEndian(bytes, word); + BinaryPrimitives.WriteUInt32LittleEndian(bytes.AsSpan(sizeof(uint)), 0xBF810000); + var context = new CpuContext(new InstructionMemory(bytes), Generation.Gen5); + Assert.True(Gen5ShaderTranslator.TryDecodeProgram(context, 0x1000, out var program, out var decodeError), decodeError); + return program.Instructions[0]; + } + + private sealed class InstructionMemory(byte[] bytes) : ICpuMemory + { + public bool TryRead(ulong address, Span destination) + { + if (address < 0x1000 || address - 0x1000 + (ulong)destination.Length > (ulong)bytes.Length) + { + return false; + } + + bytes.AsSpan((int)(address - 0x1000), destination.Length).CopyTo(destination); + return true; + } + + public bool TryWrite(ulong address, ReadOnlySpan source) => false; + } +} diff --git a/tests/SharpEmu.ShaderCompiler.Metal.Tests/Gen5ScalarAddImmediateMslTests.cs b/tests/SharpEmu.ShaderCompiler.Metal.Tests/Gen5ScalarAddImmediateMslTests.cs new file mode 100644 index 0000000..fc81d06 --- /dev/null +++ b/tests/SharpEmu.ShaderCompiler.Metal.Tests/Gen5ScalarAddImmediateMslTests.cs @@ -0,0 +1,112 @@ +// Copyright (C) 2026 SharpEmu Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +using System.Buffers.Binary; +using SharpEmu.HLE; +using SharpEmu.ShaderCompiler; +using SharpEmu.ShaderCompiler.Resources; +using Xunit; + +namespace SharpEmu.ShaderCompiler.Metal.Tests; + +// RDNA2 ISA: S_ADDK_I32 writes SCC = signed overflow, the same rule as +// S_ADD_I32. S_MOVK_I32 and S_MULK_I32 leave SCC untouched. +public sealed class Gen5ScalarAddImmediateMslTests +{ + private const ulong ShaderAddress = 0x2000; + private const uint SEndpgm = 0xBF810000; + + // SOPK: bits 31..30 = 0b10, opcode field (bits 29..23) 0x60 + op, + // sdst at bits 22..16, SIMM16 in the low half. + private const uint SAddkI32S4Max = 0xB7847FFF; // s_addk_i32 s4, 0x7FFF + private const uint SMovkI32S4Max = 0xB0047FFF; // s_movk_i32 s4, 0x7FFF + private const uint SMulkI32S4Max = 0xB8047FFF; // s_mulk_i32 s4, 0x7FFF + + private static string Compile(uint word) + { + var request = Gen5ComputeFixtures.RequestOrThrow( + DecodeProgram(word, SEndpgm), + ShaderStage.Compute, + localSizeX: 1); + Assert.True(Gen5MslTranslator.TryCompileProgram(request, out var shader, out var error), error); + return shader.Source; + } + + [Fact] + public void AddkEmitsTheSignedOverflowSccUpdate() + { + var source = Compile(SAddkI32S4Max); + Assert.Contains("scc = ((~(", source, StringComparison.Ordinal); + Assert.Contains(">> 31) != 0u;", source, StringComparison.Ordinal); + } + + [Theory] + [InlineData(SMovkI32S4Max)] + [InlineData(SMulkI32S4Max)] + public void MovkAndMulkLeaveSccAlone(uint word) + { + Assert.DoesNotContain("scc = ((~(", Compile(word), StringComparison.Ordinal); + } + + private static Gen5ShaderProgram DecodeProgram(params uint[] words) + { + var memory = new AddImmediateTestMemory(ShaderAddress, 0x1000); + var shader = new byte[words.Length * sizeof(uint)]; + for (var index = 0; index < words.Length; index++) + { + BinaryPrimitives.WriteUInt32LittleEndian(shader.AsSpan(index * sizeof(uint)), words[index]); + } + + Assert.True(memory.TryWrite(ShaderAddress, shader)); + var ctx = new CpuContext(memory, Generation.Gen5); + Assert.True( + Gen5ShaderTranslator.TryDecodeProgram(ctx, ShaderAddress, out var program, out var error), + error); + return program; + } + + private sealed class AddImmediateTestMemory(ulong baseAddress, int size) : ICpuMemory + { + private readonly byte[] _storage = new byte[size]; + + public bool TryRead(ulong virtualAddress, Span destination) + { + if (!TryResolve(virtualAddress, destination.Length, out var offset)) + { + return false; + } + + _storage.AsSpan(offset, destination.Length).CopyTo(destination); + return true; + } + + public bool TryWrite(ulong virtualAddress, ReadOnlySpan source) + { + if (!TryResolve(virtualAddress, source.Length, out var offset)) + { + return false; + } + + source.CopyTo(_storage.AsSpan(offset, source.Length)); + return true; + } + + private bool TryResolve(ulong virtualAddress, int length, out int offset) + { + offset = 0; + if (virtualAddress < baseAddress) + { + return false; + } + + var relative = virtualAddress - baseAddress; + if (relative > (ulong)(size - length)) + { + return false; + } + + offset = (int)relative; + return true; + } + } +} diff --git a/tests/SharpEmu.ShaderCompiler.Tests/Gen5ScalarAddImmediateTests.cs b/tests/SharpEmu.ShaderCompiler.Tests/Gen5ScalarAddImmediateTests.cs new file mode 100644 index 0000000..4eb9953 --- /dev/null +++ b/tests/SharpEmu.ShaderCompiler.Tests/Gen5ScalarAddImmediateTests.cs @@ -0,0 +1,156 @@ +// Copyright (C) 2026 SharpEmu Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +using System.Buffers.Binary; +using SharpEmu.HLE; +using SharpEmu.ShaderCompiler.Vulkan; +using Xunit; +using static SharpEmu.ShaderCompiler.Tests.Resources.ResourceTestProgram; + +namespace SharpEmu.ShaderCompiler.Tests; + +// RDNA2 ISA: S_ADDK_I32 is "D.i = D.i + signext(SIMM16); SCC = signed overflow", +// the same SCC rule as S_ADD_I32. S_MOVK_I32 and S_MULK_I32 leave SCC untouched. +public sealed class Gen5ScalarAddImmediateTests +{ + private const ulong ShaderAddress = 0x2000; + private const uint SEndpgm = 0xBF810000; + + // SOPK: bits 31..30 = 0b10, opcode field (bits 29..23) 0x60 + op, + // sdst at bits 22..16, SIMM16 in the low half. + private const uint SAddkI32S4Max = 0xB7847FFF; // s_addk_i32 s4, 0x7FFF + private const uint SMovkI32S4Max = 0xB0047FFF; // s_movk_i32 s4, 0x7FFF + private const uint SMulkI32S4Max = 0xB8047FFF; // s_mulk_i32 s4, 0x7FFF + + [Fact] + public void DecodesTheScalarImmediateOpcodes() + { + var program = DecodeProgram(SAddkI32S4Max, SMovkI32S4Max, SMulkI32S4Max, SEndpgm); + Assert.Equal("SAddkI32", program.Instructions[0].Opcode); + Assert.Equal("SMovkI32", program.Instructions[1].Opcode); + Assert.Equal("SMulkI32", program.Instructions[2].Opcode); + Assert.Equal(4u, program.Instructions[0].Destinations[0].Value); + } + + [Fact] + public void AddkWritesSccWhileMovkAndMulkDoNot() + { + // The SCC write is SignedAddOverflow: one extra IEqual/INotEqual/LogicalAnd + // sign-comparison triple over the same program without it. The shader + // prologue already uses these opcodes, so compare counts rather than + // presence. + var withAddk = CompiledOpcodes(SAddkI32S4Max); + var withMovk = CompiledOpcodes(SMovkI32S4Max); + var withMulk = CompiledOpcodes(SMulkI32S4Max); + foreach (var opcode in new[] { SpirvOp.IEqual, SpirvOp.INotEqual, SpirvOp.LogicalAnd }) + { + Assert.Equal(Count(withMovk, opcode) + 1, Count(withAddk, opcode)); + // S_MOVK_I32 and S_MULK_I32 leave SCC alone. + Assert.Equal(Count(withMovk, opcode), Count(withMulk, opcode)); + } + } + + private static int Count(IReadOnlyList opcodes, SpirvOp opcode) + { + var total = 0; + foreach (var value in opcodes) + { + if (value == (ushort)opcode) + { + total++; + } + } + + return total; + } + + private static IReadOnlyList CompiledOpcodes(uint word) + { + var request = Request(DecodeProgram(word, SEndpgm), userDataCount: 0); + Assert.True( + Gen5SpirvTranslator.TryCompileProgram(request, out var compiled, out var error), + error); + return ReadSpirvOpcodes(compiled.Spirv); + } + + private static IReadOnlyList ReadSpirvOpcodes(byte[] spirv) + { + Assert.Equal(0, spirv.Length % sizeof(uint)); + Assert.True(spirv.Length >= 5 * sizeof(uint)); + Assert.Equal(0x07230203u, BinaryPrimitives.ReadUInt32LittleEndian(spirv)); + + var opcodes = new List(); + for (var offset = 5 * sizeof(uint); offset < spirv.Length;) + { + var instruction = BinaryPrimitives.ReadUInt32LittleEndian(spirv.AsSpan(offset)); + var wordCount = checked((int)(instruction >> 16)); + Assert.InRange(wordCount, 1, (spirv.Length - offset) / sizeof(uint)); + opcodes.Add((ushort)instruction); + offset += wordCount * sizeof(uint); + } + + return opcodes; + } + + private static Gen5ShaderProgram DecodeProgram(params uint[] words) + { + var memory = new AddImmediateTestMemory(ShaderAddress, 0x1000); + var shader = new byte[words.Length * sizeof(uint)]; + for (var index = 0; index < words.Length; index++) + { + BinaryPrimitives.WriteUInt32LittleEndian(shader.AsSpan(index * sizeof(uint)), words[index]); + } + + Assert.True(memory.TryWrite(ShaderAddress, shader)); + var ctx = new CpuContext(memory, Generation.Gen5); + Assert.True( + Gen5ShaderTranslator.TryDecodeProgram(ctx, ShaderAddress, out var program, out var error), + error); + return program; + } + + private sealed class AddImmediateTestMemory(ulong baseAddress, int size) : ICpuMemory + { + private readonly byte[] _storage = new byte[size]; + + public bool TryRead(ulong virtualAddress, Span destination) + { + if (!TryResolve(virtualAddress, destination.Length, out var offset)) + { + return false; + } + + _storage.AsSpan(offset, destination.Length).CopyTo(destination); + return true; + } + + public bool TryWrite(ulong virtualAddress, ReadOnlySpan source) + { + if (!TryResolve(virtualAddress, source.Length, out var offset)) + { + return false; + } + + source.CopyTo(_storage.AsSpan(offset, source.Length)); + return true; + } + + private bool TryResolve(ulong virtualAddress, int length, out int offset) + { + offset = 0; + if (virtualAddress < baseAddress) + { + return false; + } + + var relative = virtualAddress - baseAddress; + if (relative > (ulong)(size - length)) + { + return false; + } + + offset = (int)relative; + return true; + } + } +} From ed08311ce45491fc3c5a6613e8dbabf0ba897e4f Mon Sep 17 00:00:00 2001 From: kuba Date: Tue, 29 Sep 2026 23:19:26 +0200 Subject: [PATCH 3/4] [ShaderCompiler] End the decode at S_CODE_END S_CODE_END pads the space after a shader's last instruction. When the code after the final S_ENDPGM ends in a backward branch, the linear decoder walks straight into that padding and failed with an unknown SOPP opcode 0x1F, which stopped the session. The decoder now names 0x1F and ends the program there, unless a forward branch still targets code beyond it -- in which case the padding really would be inside the program and the decode still fails. --- .../Gen5ShaderTranslator.cs | 16 +++++ .../Gen5CodeEndTests.cs | 64 +++++++++++++++++++ 2 files changed, 80 insertions(+) create mode 100644 tests/SharpEmu.ShaderCompiler.Tests/Gen5CodeEndTests.cs diff --git a/src/SharpEmu.ShaderCompiler/Gen5ShaderTranslator.cs b/src/SharpEmu.ShaderCompiler/Gen5ShaderTranslator.cs index e4c1f3f..dcdd7b0 100644 --- a/src/SharpEmu.ShaderCompiler/Gen5ShaderTranslator.cs +++ b/src/SharpEmu.ShaderCompiler/Gen5ShaderTranslator.cs @@ -310,6 +310,21 @@ public static partial class Gen5ShaderTranslator return false; } + // S_CODE_END pads the space after the last instruction. Code placed after the final + // S_ENDPGM that ends in a backward branch runs straight into it. + if (string.Equals(name, "SCodeEnd", StringComparison.Ordinal)) + { + if (pc < furthestForwardBranchTarget || instructions.Count == 0) + { + error = $"code-end-inside-program pc=0x{pc:X} branchTarget=0x{furthestForwardBranchTarget:X}"; + return false; + } + + program = new Gen5ShaderProgram(address, instructions); + termination = ProgramTermination.EndProgram; + return true; + } + var instructionBytes = checked(sizeDwords * sizeof(uint)); if (maximumBytes.HasValue && instructionBytes > maximumBytes.Value - pc) { @@ -780,6 +795,7 @@ public static partial class Gen5ShaderTranslator 0x18 => "SCbranchCdbguser", 0x19 => "SCbranchCdbgsysOrUser", 0x1A => "SCbranchCdbgsysAndUser", + 0x1F => "SCodeEnd", 0x20 => "SInstPrefetch", 0x21 => "SClause", 0x23 => "SWaitcntDepctr", diff --git a/tests/SharpEmu.ShaderCompiler.Tests/Gen5CodeEndTests.cs b/tests/SharpEmu.ShaderCompiler.Tests/Gen5CodeEndTests.cs new file mode 100644 index 0000000..bc14018 --- /dev/null +++ b/tests/SharpEmu.ShaderCompiler.Tests/Gen5CodeEndTests.cs @@ -0,0 +1,64 @@ +// Copyright (C) 2026 SharpEmu Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +using System.Buffers.Binary; +using SharpEmu.HLE; +using Xunit; + +namespace SharpEmu.ShaderCompiler.Tests; + +// S_CODE_END pads the end of a shader. A block placed after the final S_ENDPGM that loops back +// with S_BRANCH leads the linear decoder into that padding. +public sealed class Gen5CodeEndTests +{ + private const uint BranchScc0Over = 0xBF840002; // s_cbranch_scc0 to 0x0C + private const uint MoveScalar = 0xBE800381; // s_mov_b32 s0, 1 + private const uint EndProgram = 0xBF810000; // s_endpgm + private const uint BranchBack = 0xBF82FFFC; // s_branch to 0x04 + private const uint CodeEnd = 0xBF9F0000; // s_code_end + + [Fact] + public void DecodeEndsAtCodeEndAfterTheLastReachableBlock() + { + var context = Context(BranchScc0Over, MoveScalar, EndProgram, MoveScalar, BranchBack, CodeEnd, CodeEnd); + + Assert.True(Gen5ShaderTranslator.TryDecodeProgram(context, 0x1000, out var program, out var error), error); + + Assert.Equal( + ["SCbranchScc0", "SMovB32", "SEndpgm", "SMovB32", "SBranch"], + program.Instructions.Select(static instruction => instruction.Opcode)); + } + + [Fact] + public void CodeEndBeforeAForwardBranchTargetFails() + { + var context = Context(BranchScc0Over, CodeEnd, EndProgram, MoveScalar, EndProgram); + + Assert.False(Gen5ShaderTranslator.TryDecodeProgram(context, 0x1000, out _, out var error)); + Assert.Contains("code-end-inside-program", error); + } + + private static CpuContext Context(params uint[] words) => new(new InstructionMemory(words), Generation.Gen5); + + private sealed class InstructionMemory(uint[] words) : ICpuMemory + { + public bool TryRead(ulong address, Span destination) + { + if (address < 0x1000 || (address - 0x1000) % sizeof(uint) != 0 || destination.Length != sizeof(uint)) + { + return false; + } + + var index = (address - 0x1000) / sizeof(uint); + if (index >= (ulong)words.Length) + { + return false; + } + + BinaryPrimitives.WriteUInt32LittleEndian(destination, words[(int)index]); + return true; + } + + public bool TryWrite(ulong address, ReadOnlySpan source) => false; + } +} From 7c50c319880e57a7fd7e1f4e852010f2c2bc660c Mon Sep 17 00:00:00 2001 From: kuba Date: Tue, 29 Sep 2026 23:19:26 +0200 Subject: [PATCH 4/4] [ShaderCompiler] Widen host-evaluable scalar operations for descriptors A resource descriptor the host materializes is built by replaying the shader's scalar operations on the host. The validator that decides whether a value can be replayed listed only part of what the scalar evaluator implements, so a descriptor computed through any of the others was rejected even though the host can compute it exactly. The list now covers every pure scalar function the evaluator supports: the remaining integer maximum, absolute value and high multiplies, the signed and unsigned comparisons, 64-bit equality, the bit-counting and bit-scanning operations, the quad mask, the integer/float conversions, and the float arithmetic and comparisons. Anything with side effects or a lane-varying result is still rejected. --- .../Resources/RuntimeValueValidator.cs | 9 +++++ .../RuntimeValueValidatorTests.cs | 35 +++++++++++++++++++ 2 files changed, 44 insertions(+) create mode 100644 tests/SharpEmu.ShaderCompiler.Tests/RuntimeValueValidatorTests.cs diff --git a/src/SharpEmu.ShaderCompiler/Resources/RuntimeValueValidator.cs b/src/SharpEmu.ShaderCompiler/Resources/RuntimeValueValidator.cs index 7df4bb3..1692f8f 100644 --- a/src/SharpEmu.ShaderCompiler/Resources/RuntimeValueValidator.cs +++ b/src/SharpEmu.ShaderCompiler/Resources/RuntimeValueValidator.cs @@ -49,6 +49,15 @@ public sealed class RuntimeValueValidator ScalarOperation.LogicalOr or ScalarOperation.LogicalAnd or ScalarOperation.LogicalXor or ScalarOperation.LogicalNot or ScalarOperation.FLessThanEqual or ScalarOperation.FGreaterThanEqual or ScalarOperation.FIsNan or ScalarOperation.FMul or ScalarOperation.FTrunc => true, + // Every other pure scalar function ScalarOperationSemantics evaluates is as uniform as the + // ones above; a descriptor built through them was rejected although the host can compute it. + ScalarOperation.UMax32 or ScalarOperation.IAbs32 or ScalarOperation.SMulHi32 or ScalarOperation.UMulHi32 or + ScalarOperation.SLessThan32 or ScalarOperation.SLessThanEqual32 or ScalarOperation.SGreaterThan32 or ScalarOperation.SGreaterThanEqual32 or + ScalarOperation.ULessThanEqual32 or ScalarOperation.UGreaterThanEqual32 or ScalarOperation.IEqual64 or ScalarOperation.INotEqual64 or + ScalarOperation.BitCount32 or ScalarOperation.BitReverse32 or ScalarOperation.FindLowestBit32 or ScalarOperation.FindHighestBit32 or + ScalarOperation.QuadMask32 or ScalarOperation.ConvertF32S32 or ScalarOperation.ConvertS32F32 or + ScalarOperation.FAdd or ScalarOperation.FSub or ScalarOperation.FMin or ScalarOperation.FMax or + ScalarOperation.FEqual or ScalarOperation.FNotEqual or ScalarOperation.FLessThan or ScalarOperation.FGreaterThan => true, _ => false, }; diff --git a/tests/SharpEmu.ShaderCompiler.Tests/RuntimeValueValidatorTests.cs b/tests/SharpEmu.ShaderCompiler.Tests/RuntimeValueValidatorTests.cs new file mode 100644 index 0000000..2b8e30c --- /dev/null +++ b/tests/SharpEmu.ShaderCompiler.Tests/RuntimeValueValidatorTests.cs @@ -0,0 +1,35 @@ +// Copyright (C) 2026 SharpEmu Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +using SharpEmu.ShaderCompiler.Resources; +using Xunit; + +namespace SharpEmu.ShaderCompiler.Tests; + +public sealed class RuntimeValueValidatorTests +{ + // The host evaluates runtime descriptor values with ScalarOperationSemantics, so the validator must + // not reject an operation the evaluator can compute (Silent Hill's compute plans failed that way). + [Fact] + public void EveryEvaluableOperationIsAcceptedAsUniform() + { + ulong[] operands = [1, 2, 3, 4]; + foreach (var operation in Enum.GetValues()) + { + bool evaluable; + try + { + evaluable = ScalarOperationSemantics.TryEvaluate(operation, operands, out _); + } + catch (Exception) + { + evaluable = false; + } + + if (evaluable) + { + Assert.True(RuntimeValueValidator.IsUniformOperation(operation), $"{operation} is evaluable but not uniform."); + } + } + } +}