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 3a41b8d..329b175 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": @@ -2520,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); } @@ -2541,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; } @@ -4440,14 +4448,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 +4464,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/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/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/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.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/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.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; + } +} 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/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; + } +} 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; + } + } +} 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."); + } + } + } +}