Merge #940 (pr/shader-isa-fixes)

This commit is contained in:
kuba 2026-09-30 00:12:22 +02:00
commit f254d5370a
12 changed files with 735 additions and 16 deletions

View File

@ -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;
}

View File

@ -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(

View File

@ -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",

View File

@ -450,7 +450,13 @@ public sealed class BindingLayout : IEquatable<BindingLayout>
}
}
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<BindingLayout>
}
}
if (comparesDestination)
{
return;
}
switch (instruction.Control)
{
case Gen5ImageControl image:

View File

@ -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,
};

View File

@ -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<HeadlessVulkanFixture>
{
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, GpuBuffer[]> { [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();
}
}

View File

@ -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<HeadlessVulkanFixture>
{
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, GpuBuffer[]> { [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<byte> 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<byte> source) => false;
}
}

View File

@ -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<HeadlessVulkanFixture>
{
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, GpuBuffer[]> { [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<byte> 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<byte> source) => false;
}
}

View File

@ -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<byte> 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<byte> 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;
}
}
}

View File

@ -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<byte> 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<byte> source) => false;
}
}

View File

@ -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<ushort> opcodes, SpirvOp opcode)
{
var total = 0;
foreach (var value in opcodes)
{
if (value == (ushort)opcode)
{
total++;
}
}
return total;
}
private static IReadOnlyList<ushort> 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<ushort> 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<ushort>();
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<byte> 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<byte> 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;
}
}
}

View File

@ -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<ScalarOperation>())
{
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.");
}
}
}
}