diff --git a/src/SharpEmu.ShaderCompiler.Vulkan/Gen5SpirvTranslator.Alu.cs b/src/SharpEmu.ShaderCompiler.Vulkan/Gen5SpirvTranslator.Alu.cs index 7c6415bc..771eb4e4 100644 --- a/src/SharpEmu.ShaderCompiler.Vulkan/Gen5SpirvTranslator.Alu.cs +++ b/src/SharpEmu.ShaderCompiler.Vulkan/Gen5SpirvTranslator.Alu.cs @@ -959,6 +959,15 @@ public static partial class Gen5SpirvTranslator return false; } + break; + case "VFmaMixF32": + case "VFmaMixloF16": + case "VFmaMixhiF16": + if (!TryEmitFmaMix(instruction, destination, out result, out error)) + { + return false; + } + break; default: error = $"unsupported vector opcode {instruction.Opcode}"; @@ -1033,6 +1042,103 @@ public static partial class Gen5SpirvTranslator return true; } + // V_FMA_MIX_F32 / _MIXLO_F16 / _MIXHI_F16 (VOP3P opcodes 0x20 / 0x21 / + // 0x22). Unlike the packed v_pk_* ops these compute a single f32 + // fma(a, b, c): each of the three sources is *independently* read as + // either a full f32 register/constant or one f16 half widened to f32, + // selected per operand by op_sel_hi (read as f16 when set) and op_sel + // (which half feeds the f32). For the mix ops the VOP3P neg_hi field is + // the absolute-value modifier and neg negates, applied abs-then-neg to + // match the hardware and shadPS4's GetSrcMix. _MIXLO / _MIXHI round the + // f32 result back to f16 and write it into the low / high 16 bits of + // vdst, leaving the other half intact. + private bool TryEmitFmaMix( + Gen5ShaderInstruction instruction, + uint destination, + out uint result, + out string error) + { + result = 0; + error = string.Empty; + if (instruction.Control is not Gen5Vop3pControl control) + { + error = $"missing vop3p control for {instruction.Opcode}"; + return false; + } + + var product = Bitcast( + _uintType, + Ext( + 50, + _floatType, + EmitFmaMixOperand(instruction, control, 0), + EmitFmaMixOperand(instruction, control, 1), + EmitFmaMixOperand(instruction, control, 2))); + if (control.Clamp) + { + product = EmitClampToUnitInterval(product); + } + + if (instruction.Opcode == "VFmaMixF32") + { + result = product; + return true; + } + + // _MIXLO / _MIXHI: narrow to f16 and merge into one half of vdst. + var half = EmitFloatToHalf(product); + var existing = LoadV(destination); + result = instruction.Opcode == "VFmaMixloF16" + ? BitwiseOr(BitwiseAnd(existing, UInt(0xFFFF_0000)), half) + : BitwiseOr( + BitwiseAnd(existing, UInt(0x0000_FFFF)), + ShiftLeftLogical(half, UInt(16))); + return true; + } + + // Reads one V_FMA_MIX source as an f32. op_sel_hi selects whether a + // register operand is taken as an f16 (the half picked by op_sel, widened + // exactly to f32) or as a full f32; inline constants are always f32. The + // per-operand neg_hi bit takes the absolute value and neg negates, in that + // order (abs-then-neg), reusing the VOP3P modifier fields the way the mix + // ops define them rather than the packed low/high-lane meaning. + private uint EmitFmaMixOperand( + Gen5ShaderInstruction instruction, + Gen5Vop3pControl control, + int index) + { + var source = instruction.Sources[index]; + var readAsHalf = + ((control.OpSelHiMask >> index) & 1) != 0 && + source.Kind is Gen5OperandKind.VectorRegister or Gen5OperandKind.ScalarRegister; + + uint value; + if (readAsHalf) + { + var raw = GetRawSource(instruction, index); + var half = ((control.OpSelMask >> index) & 1) != 0 + ? ShiftRightLogical(raw, UInt(16)) + : raw; + value = Bitcast(_floatType, EmitHalfToFloat(half)); + } + else + { + value = GetFloatSource(instruction, index); + } + + if (((control.NegHiMask >> index) & 1) != 0) + { + value = Ext(4, _floatType, value); + } + + if (((control.NegLoMask >> index) & 1) != 0) + { + value = _module.AddInstruction(SpirvOp.FNegate, _floatType, value); + } + + return value; + } + // Computes one result lane (low or high) as a packed 16-bit f16 value. // The op runs in f32 and its result is narrowed back to f16 exactly (see // EmitFloatToHalf). When the clamp modifier is set the pre-narrowing f32 diff --git a/src/SharpEmu.ShaderCompiler/Gen5ShaderTranslator.cs b/src/SharpEmu.ShaderCompiler/Gen5ShaderTranslator.cs index 50426fc4..ec14e8ff 100644 --- a/src/SharpEmu.ShaderCompiler/Gen5ShaderTranslator.cs +++ b/src/SharpEmu.ShaderCompiler/Gen5ShaderTranslator.cs @@ -1192,8 +1192,10 @@ public static class Gen5ShaderTranslator // Opcode numbers taken from LLVM's AMDGPU VOP3PInstructions.td and the // gfx9/gfx10 MC test encodings; they are unchanged across gfx9 and gfx10. - // Unhandled packed opcodes (integer, fma_mix, ...) stay opaque here and - // fail loudly at emission rather than being silently mis-emitted. + // The mix ops (0x20/0x21/0x22) are V_MAD_MIX_* on gfx9 and V_FMA_MIX_* + // (fused) on the gfx10 the PS5 targets; both share these opcodes. Any + // remaining packed opcode (integer, ...) stays opaque here and fails + // loudly at emission rather than being silently mis-emitted. name = opcode switch { 0x0E => "VPkFmaF16", @@ -1201,6 +1203,9 @@ public static class Gen5ShaderTranslator 0x10 => "VPkMulF16", 0x11 => "VPkMinF16", 0x12 => "VPkMaxF16", + 0x20 => "VFmaMixF32", + 0x21 => "VFmaMixloF16", + 0x22 => "VFmaMixhiF16", _ => $"Vop3pRaw{opcode:X2}", }; diff --git a/tests/SharpEmu.Libs.Tests/Agc/Gen5FmaMixSpirvTests.cs b/tests/SharpEmu.Libs.Tests/Agc/Gen5FmaMixSpirvTests.cs new file mode 100644 index 00000000..a839347d --- /dev/null +++ b/tests/SharpEmu.Libs.Tests/Agc/Gen5FmaMixSpirvTests.cs @@ -0,0 +1,135 @@ +// 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.Vulkan; +using Xunit; + +namespace SharpEmu.Libs.Tests.Agc; + +// Regression tests for the VOP3P mix ops V_FMA_MIX_F32 / _MIXLO_F16 / _MIXHI_F16 +// (opcodes 0x20 / 0x21 / 0x22). The decoder leaves any unlowered VOP3P opcode +// opaque (Vop3pRaw20/21/22); before these were lowered they hit the vector-ALU +// switch default and failed emission ("unsupported vector opcode"), which drops +// the whole shader. Unity HDR / tone-mapping / auto-exposure shaders use +// V_FMA_MIX_F32 and so failed to translate entirely. +// +// Each mix op computes a single f32 fma(a, b, c) where every source is read +// *independently* as either a full f32 register or one f16 half widened to f32, +// selected per operand by op_sel_hi (f16 when set) and op_sel (which half). The +// mix ops also repurpose the VOP3P neg_hi field as an absolute-value modifier. +public sealed class Gen5FmaMixSpirvTests +{ + private const ulong ShaderAddress = 0x1_0000_0000; + + // GLSL.std.450 extended-instruction numbers used by the lowering. + private const uint GlslFma = 50; + private const uint GlslFAbs = 4; + + [Fact] + public void FmaMixF32_TranslatesToFmaAndDoesNotDropShader() + { + // V_FMA_MIX_F32 v3, v0, v1, v2 + // op_sel_hi = 0b011 -> src0/src1 read as f16, src2 as full f32 + // op_sel = 0b010 -> src1 takes its high f16 half (src0 low half) + // neg_hi = 0b001 -> abs(src0) + // neg = 0b100 -> -src2 + // Reaching TryCompileComputeShader == true already proves the shader is no + // longer dropped at the VOP3P default error path. + var spirv = Compile([0xCC201103u, 0x9C0A0300u]); + + Assert.True( + ContainsExtInst(spirv, GlslFma), + "V_FMA_MIX_F32 must lower to a GLSL.std.450 Fma"); + Assert.True( + ContainsExtInst(spirv, GlslFAbs), + "the neg_hi modifier on a mix source must lower to an FAbs (abs-then-neg)"); + } + + [Fact] + public void FmaMixLoF16_TranslatesWithoutDroppingShader() + { + // V_FMA_MIXLO_F16 v3, v0, v1, v2 with op_sel_hi = 0b111 (all sources read + // as f16 low halves). The f32 fma result is narrowed to f16 and merged + // into the low 16 bits of vdst; the fma itself is still emitted. + var spirv = Compile([0xCC214003u, 0x1C0A0300u]); + + Assert.True( + ContainsExtInst(spirv, GlslFma), + "V_FMA_MIXLO_F16 must still lower its multiply-add to a GLSL.std.450 Fma"); + } + + // True when the module contains an OpExtInst selecting the given GLSL.std.450 + // instruction number. + private static bool ContainsExtInst(byte[] spirv, uint instruction) + { + foreach (var (op, wordCount, offset) in EnumerateInstructions(spirv)) + { + // OpExtInst = 12: (opcode, resultType, resultId, set, instruction, ...). + if (op != 12 || wordCount < 5) + { + continue; + } + + if (ReadWord(spirv, offset + 16) == instruction) + { + return true; + } + } + + return false; + } + + private static IEnumerable<(ushort Op, int WordCount, int Offset)> EnumerateInstructions( + byte[] spirv) + { + // 5-word SPIR-V header, then (wordCount << 16 | opcode) packed instructions. + for (var offset = 5 * sizeof(uint); offset + sizeof(uint) <= spirv.Length;) + { + var word = ReadWord(spirv, offset); + var wordCount = (int)(word >> 16); + if (wordCount <= 0) + { + yield break; + } + + yield return ((ushort)word, wordCount, offset); + offset += wordCount * sizeof(uint); + } + } + + private static uint ReadWord(byte[] spirv, int offset) => + BinaryPrimitives.ReadUInt32LittleEndian(spirv.AsSpan(offset, sizeof(uint))); + + private static byte[] Compile(uint[] programWords) + { + var memory = new FakeCpuMemory(ShaderAddress, 0x2000); + var ctx = new CpuContext(memory, Generation.Gen5); + Gen5ShaderAtomicDecodeTests.WriteProgram(memory, ShaderAddress, programWords); + var shaderRegisters = new Dictionary + { + [Gen5ShaderAtomicDecodeTests.ComputePgmRsrc2Register] = 16u << 1, + }; + + Assert.True( + Gen5ShaderTranslator.TryCreateState( + ctx, + ShaderAddress, + 0, + shaderRegisters, + Gen5ShaderAtomicDecodeTests.ComputeUserDataRegister, + out var state, + out var error), + error); + Assert.True( + Gen5ShaderScalarEvaluator.TryEvaluate(ctx, state, out var evaluation, out error), + error); + Assert.True( + Gen5SpirvTranslator.TryCompileComputeShader( + state, evaluation, 1, 1, 1, out var shader, out error), + error); + return shader.Spirv; + } +}