diff --git a/src/SharpEmu.Libs/Gpu/Images/GuestImageCache.Transfers.cs b/src/SharpEmu.Libs/Gpu/Images/GuestImageCache.Transfers.cs index 000513d..d4b8cc9 100644 --- a/src/SharpEmu.Libs/Gpu/Images/GuestImageCache.Transfers.cs +++ b/src/SharpEmu.Libs/Gpu/Images/GuestImageCache.Transfers.cs @@ -703,11 +703,39 @@ public sealed unsafe partial class GuestImageCache } } + // The mip layout can end before the tile plan does (Silent Hill: a 128x128 slice planned at + // the exact end of the mip chain aborted the process). Cover the whole plan when the buffer + // holds it; otherwise keep the guest bytes instead of copying a partial image. + if (!plan.Depth && plan.Color.Tiled) + { + var tiledEnd = TiledEndOf(plan.Color.Tiles); + if (tiledEnd > copySize) + { + if (tiledEnd > available) + { + return false; + } + + copySize = tiledEnd; + } + } + DownloadToBuffer(image, buffer, bufferOffset, copySize, plan); image.MarkBufferHoldsGpuContents(); return true; } + private static ulong TiledEndOf(List tiles) + { + var end = 0UL; + foreach (var tile in tiles) + { + end = Math.Max(end, tile.TiledOffset + tile.TiledSize); + } + + return end; + } + // Publishes a GPU-owned image to guest memory after its tick completes; false when it cannot. private bool TryDownloadToGuest(ResourceSlotIdentifier imageIdentifier) { diff --git a/src/SharpEmu.Libs/VideoOut/VulkanVideoPresenter.Rendering.cs b/src/SharpEmu.Libs/VideoOut/VulkanVideoPresenter.Rendering.cs index dc4d92e..d899ad6 100644 --- a/src/SharpEmu.Libs/VideoOut/VulkanVideoPresenter.Rendering.cs +++ b/src/SharpEmu.Libs/VideoOut/VulkanVideoPresenter.Rendering.cs @@ -585,8 +585,13 @@ internal static unsafe partial class VulkanVideoPresenter var view = binding.Request.View; for (var layer = view.BaseLayer; layer < view.BaseLayer + view.LayerCount; layer++) { - var tracked = _imageCache.IsMetadataCleared(metadataAddress, layer, out var metadataValue) && (byte)metadataValue == DccClearToZero; + // Every fixed clear code carries its own colour; only the register code (0x20) needs + // the CB clear word, which is known when the surface binds as a target. Silent Hill + // fast-clears UE's separate-translucency target to (0,0,0,1) and only samples it: + // skipping that clear left stale zeros that blacked out the whole title-menu scene. var clearValue = default(ClearColorValue); + var tracked = _imageCache.IsMetadataCleared(metadataAddress, layer, out var metadataValue) && + TryDecodeDccClear((byte)metadataValue, false, (byte)metadataValue == DccClearToZero || fixedClearSupported, default, out clearValue); ulong guestSlice = 0; if (!tracked && (sliceSize == 0 || !_imageCache.TryReadGuestDccClear(metadataAddress, sliceSize, layer, out guestSlice, out var code) || diff --git a/src/SharpEmu.Libs/VideoOut/VulkanVideoPresenter.Scheduler.cs b/src/SharpEmu.Libs/VideoOut/VulkanVideoPresenter.Scheduler.cs index dbdff71..c56f9df 100644 --- a/src/SharpEmu.Libs/VideoOut/VulkanVideoPresenter.Scheduler.cs +++ b/src/SharpEmu.Libs/VideoOut/VulkanVideoPresenter.Scheduler.cs @@ -101,7 +101,11 @@ internal static unsafe partial class VulkanVideoPresenter private void CreateImageCache() { var (memory, _, backing) = RequireGuestMemory("image store"); - _imageCache = new GuestImageCache(_deviceInfo, _scheduler, memory.Pages, _bufferCache, backing, readbackLinearImages: false); + // The guest reads linear GPU-written images back on the CPU (UE's exposure readback + // feeds View.PreExposure), so they are published to guest memory after their tick. + // SHARPEMU_READBACK_LINEAR_IMAGES=0 keeps them GPU-only. + _imageCache = new GuestImageCache(_deviceInfo, _scheduler, memory.Pages, _bufferCache, backing, + readbackLinearImages: Environment.GetEnvironmentVariable("SHARPEMU_READBACK_LINEAR_IMAGES") != "0"); _bufferCache.ImageCache = _imageCache; _samplerStore = new SamplerStore(_deviceInfo); } diff --git a/src/SharpEmu.ShaderCompiler.Vulkan/Gen5SpirvTranslator.Alu.cs b/src/SharpEmu.ShaderCompiler.Vulkan/Gen5SpirvTranslator.Alu.cs index 9281f00..7b0868a 100644 --- a/src/SharpEmu.ShaderCompiler.Vulkan/Gen5SpirvTranslator.Alu.cs +++ b/src/SharpEmu.ShaderCompiler.Vulkan/Gen5SpirvTranslator.Alu.cs @@ -1785,8 +1785,11 @@ public static partial class Gen5SpirvTranslator } // 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 + // register or literal operand is taken as an f16 (the half picked by op_sel, + // widened exactly to f32) or as a full f32; inline constants keep their value + // either way. A literal is an f16 bit pattern then: reading 0x34CD as an f32 + // made Silent Hill's luminance weights denormal zeros, and the lighting that + // divides by that luminance came out thousands of times too bright. 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. @@ -1798,7 +1801,7 @@ public static partial class Gen5SpirvTranslator var source = instruction.Sources[index]; var readAsHalf = ((control.OpSelHiMask >> index) & 1) != 0 && - source.Kind is Gen5OperandKind.VectorRegister or Gen5OperandKind.ScalarRegister; + source.Kind is Gen5OperandKind.VectorRegister or Gen5OperandKind.ScalarRegister or Gen5OperandKind.LiteralConstant; uint value; if (readAsHalf) @@ -5234,12 +5237,33 @@ public static partial class Gen5SpirvTranslator return Bitcast(_uintType, value); } + // Rounds an f32 toward zero onto an f16 value, so packHalf2x16 then encodes it exactly + // (V_CVT_PKRTZ_F16_F32). Toward zero, a finite value beyond the f16 range becomes + // +-65504, never infinity: Silent Hill's deferred lighting packs values past 65504 and + // its fog pass then scales them down, while an infinity stays infinite, turns into NaN + // and blacks out the whole title-menu scene. Subnormal results keep whole 2^-24 steps. private uint TruncateFloat32ForPack(uint value) { - var raw = BitwiseAnd( - Bitcast(_uintType, value), - UInt(0xFFFF_E000)); - return Bitcast(_floatType, raw); + var normal = Bitcast( + _floatType, + BitwiseAnd(Bitcast(_uintType, Ext(43, _floatType, value, Float(-65504f), Float(65504f))), UInt(0xFFFF_E000))); + var subnormal = _module.AddInstruction( + SpirvOp.FMul, + _floatType, + Ext(3, _floatType, _module.AddInstruction(SpirvOp.FMul, _floatType, value, Float(16777216f))), + Float(1f / 16777216f)); + var isSubnormal = _module.AddInstruction( + SpirvOp.FOrdLessThan, + _boolType, + Ext(4, _floatType, value), + Float(6.10351562e-05f)); + var finite = _module.AddInstruction(SpirvOp.Select, _floatType, isSubnormal, subnormal, normal); + var special = _module.AddInstruction( + SpirvOp.LogicalOr, + _boolType, + _module.AddInstruction(SpirvOp.IsNan, _boolType, value), + _module.AddInstruction(SpirvOp.IsInf, _boolType, value)); + return _module.AddInstruction(SpirvOp.Select, _floatType, special, value, finite); } private uint Ext(uint operation, uint resultType, params uint[] operands) diff --git a/src/SharpEmu.ShaderCompiler.Vulkan/Gen5SpirvTranslator.cs b/src/SharpEmu.ShaderCompiler.Vulkan/Gen5SpirvTranslator.cs index 52f3a1d..d0a48f5 100644 --- a/src/SharpEmu.ShaderCompiler.Vulkan/Gen5SpirvTranslator.cs +++ b/src/SharpEmu.ShaderCompiler.Vulkan/Gen5SpirvTranslator.cs @@ -1466,9 +1466,11 @@ public static partial class Gen5SpirvTranslator var block = blocks[blockIndex]; var halfMaskPlan = HalfMaskPlan(); // One guest wave can span two host subgroups. Keep its shared-memory phases ordered. - // The half-mask plan inserts its own barriers; other wave64 programs also - // need LDS phase ordering when reads and writes span basic blocks. - var synchronizeSharedMemory = _emulateWave64 && halfMaskPlan is null; + // The half-mask plan's barriers only order its own mask exchanges, not the program's + // LDS traffic: with phase ordering off under the plan, UE's FFT bloom (wave64, 512 + // threads, LDS butterflies) read half-written rows and blurred Silent Hill's whole + // post chain into a flat haze. + var synchronizeSharedMemory = _emulateWave64; var sharedMemoryPhase = SharedMemoryPhase.None; // A wait in another block may or may not have run before this one. _vectorMemoryWaited = null; diff --git a/tests/SharpEmu.Libs.Tests/Gpu/GpuCommands/CommandStreamWorkerTests.cs b/tests/SharpEmu.Libs.Tests/Gpu/GpuCommands/CommandStreamWorkerTests.cs index 49b8a3f..ae04a3c 100644 --- a/tests/SharpEmu.Libs.Tests/Gpu/GpuCommands/CommandStreamWorkerTests.cs +++ b/tests/SharpEmu.Libs.Tests/Gpu/GpuCommands/CommandStreamWorkerTests.cs @@ -48,17 +48,25 @@ public sealed class CommandStreamWorkerTests [Fact] public void Worker_RetriesABlockedHeadAfterTheTimedWait() { - var (host, queue, worker) = NewWorker(cancelBlockedAtStop: false); + var (host, queue, worker) = NewWorker(cancelBlockedAtStop: true); host.WriteDword(Label, 0); - Enqueue(host, queue, 1, WaitEqual(Label, 1), CreateInstanceCountPacket(6)); - Thread.Sleep(50); - host.WriteDword(Label, 1); + try + { + Enqueue(host, queue, 1, WaitEqual(Label, 1), CreateInstanceCountPacket(6)); + // A loaded runner can start the worker after a fixed sleep has elapsed. + // Publish the signal only once the worker has actually blocked on it. + Assert.True(SpinWait.SpinUntil(() => queue.BlockedQueueCount == 1, TimeSpan.FromSeconds(5))); + host.WriteDword(Label, 1); - Assert.Equal(IdleOutcome.Completed, queue.WaitForIdle()); - Assert.Equal(6u, queue.GetInterpreter(0).InstanceCount); - Assert.True(queue.BlockedRetries >= 1); - worker.Stop(); + Assert.Equal(IdleOutcome.Completed, queue.WaitForIdle()); + Assert.Equal(6u, queue.GetInterpreter(0).InstanceCount); + Assert.True(queue.BlockedRetries >= 1); + } + finally + { + worker.Stop(); + } } [Fact] diff --git a/tests/SharpEmu.Libs.Tests/Gpu/Vulkan/FloatSemanticsDeviceTests.cs b/tests/SharpEmu.Libs.Tests/Gpu/Vulkan/FloatSemanticsDeviceTests.cs index 9f7e37b..c2c66b9 100644 --- a/tests/SharpEmu.Libs.Tests/Gpu/Vulkan/FloatSemanticsDeviceTests.cs +++ b/tests/SharpEmu.Libs.Tests/Gpu/Vulkan/FloatSemanticsDeviceTests.cs @@ -122,5 +122,106 @@ public sealed class FloatSemanticsDeviceTests(HeadlessVulkanFixture fixture) : I } } + // op_sel_hi marks a V_FMA_MIX operand as f16, literals included: 0x34CD is 0.3, not an + // f32 denormal. Silent Hill weights a luminance with such literals and divides by it. + [Fact] + public void FusedMixReadsHalfSelectedLiteralsAsHalfFloats() + { + var vulkan = fixture.Vulkan; + if (!GatePrerequisites.Ready(vulkan, shaderInt64: true)) return; + var halfLiteral = new Gen5Vop3pControl(0, 0b001, 0, 0, false); + var floatLiteral = new Gen5Vop3pControl(0, 0b000, 0, 0, false); + var program = Program( + Vop1(0, "VCvtF32U32", 1, Gen5Operand.Vector(0)), + Vop3(4, "VFmaMixF32", 12, new Gen5Operand(Gen5OperandKind.LiteralConstant, 0x34CD), Gen5Operand.Vector(1), Operand(0)) with { Control = halfLiteral }, + Vop3(16, "VFmaMixF32", 13, new Gen5Operand(Gen5OperandKind.LiteralConstant, Bits(0.25f)), Gen5Operand.Vector(1), Operand(0)) with { Control = floatLiteral }, + Vop2(28, "VLshlrevB32", 7, Operand(5), Gen5Operand.Vector(0)), + BufferAccess(32, "BufferStoreDword", 4, vectorData: 12, offsetEnabled: true, vectorAddress: 7), + BufferAccess(40, "BufferStoreDword", 4, offset: 4, vectorData: 13, offsetEnabled: true, vectorAddress: 7), + EndProgram(48)); + var (plan, resources, layout) = Prepare(program); + var request = new ShaderCompileRequest(plan, resources, layout) + { + LocalSizeX = ThreadCount, ThreadCountX = ThreadCount, WaveSize = 32, + }; + 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(ThreadCount * RowBytes); + var registers = new uint[256]; + registers[6] = ThreadCount * RowBytes; + harness.Run(() => runner.Dispatch(registers, + new Dictionary { [DescriptorBindingKind.Buffers] = [result] }, 1)); + var actual = runner.ReadBack(result, 0, ThreadCount * RowBytes); + var weight = (float)BitConverter.UInt16BitsToHalf(0x34CD); + for (var lane = 0u; lane < ThreadCount; lane++) + { + var row = actual.AsSpan((int)(lane * RowBytes)); + Assert.Equal(Bits(MathF.FusedMultiplyAdd(weight, lane, 0f)), BinaryPrimitives.ReadUInt32LittleEndian(row)); + Assert.Equal(Bits(lane * 0.25f), BinaryPrimitives.ReadUInt32LittleEndian(row[4..])); + } + } + + // V_CVT_PKRTZ_F16_F32 rounds toward zero: finite values past the f16 range saturate at + // +-65504 instead of becoming infinity, and subnormal results keep whole 2^-24 steps. + [Fact] + public void PackToHalfRoundsTowardZeroWithoutOverflowingToInfinity() + { + var vulkan = fixture.Vulkan; + if (!GatePrerequisites.Ready(vulkan, shaderInt64: true)) return; + var program = Program( + Vop1(0, "VCvtF32U32", 1, Gen5Operand.Vector(0)), + MoveScalar(4, 8, Bits(5003.7f)), + MoveScalar(12, 9, Bits(-80000.3f)), + MoveScalar(20, 10, Bits(3.3e-6f)), + MoveScalar(28, 11, Bits(-5e-5f)), + MoveScalar(36, 12, Bits(float.NaN)), + MoveScalar(44, 13, Bits(float.PositiveInfinity)), + Vop3(52, "VMadF32", 2, Gen5Operand.Vector(1), Gen5Operand.Scalar(8), Gen5Operand.Scalar(9)), + Vop3(60, "VMadF32", 3, Gen5Operand.Vector(1), Gen5Operand.Scalar(10), Gen5Operand.Scalar(11)), + Vop2(68, "VCvtPkrtzF16F32", 12, Gen5Operand.Vector(2), Gen5Operand.Vector(3)), + Vop3(72, "VCvtPkrtzF16F32", 13, Gen5Operand.Scalar(12), Gen5Operand.Scalar(13)), + Vop2(80, "VLshlrevB32", 7, Operand(5), Gen5Operand.Vector(0)), + BufferAccess(84, "BufferStoreDword", 4, vectorData: 12, offsetEnabled: true, vectorAddress: 7), + BufferAccess(92, "BufferStoreDword", 4, offset: 4, vectorData: 13, offsetEnabled: true, vectorAddress: 7), + EndProgram(100)); + var (plan, resources, layout) = Prepare(program); + var request = new ShaderCompileRequest(plan, resources, layout) + { + LocalSizeX = ThreadCount, ThreadCountX = ThreadCount, WaveSize = 32, + }; + 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(ThreadCount * RowBytes); + var registers = new uint[256]; + registers[6] = ThreadCount * RowBytes; + harness.Run(() => runner.Dispatch(registers, + new Dictionary { [DescriptorBindingKind.Buffers] = [result] }, 1)); + var actual = runner.ReadBack(result, 0, ThreadCount * RowBytes); + for (var lane = 0u; lane < ThreadCount; lane++) + { + var row = actual.AsSpan((int)(lane * RowBytes)); + var large = MathF.FusedMultiplyAdd(lane, 5003.7f, -80000.3f); + var small = MathF.FusedMultiplyAdd(lane, 3.3e-6f, -5e-5f); + var packed = BinaryPrimitives.ReadUInt32LittleEndian(row); + Assert.Equal(HalfTowardZero(large), packed & 0xFFFF); + Assert.Equal(HalfTowardZero(small), packed >> 16); + var special = BinaryPrimitives.ReadUInt32LittleEndian(row[4..]); + Assert.True((special & 0x7C00) == 0x7C00 && (special & 0x3FF) != 0, $"lane {lane}: 0x{special:X8}"); + Assert.Equal(0x7C00u, special >> 16); + } + } + + private static uint HalfTowardZero(float value) + { + var sign = value < 0 || (value == 0 && float.IsNegative(value)) ? 0x8000u : 0u; + var magnitude = MathF.Abs(value); + if (magnitude >= 65504f) return sign | 0x7BFF; + if (magnitude < 6.10351562e-05f) return sign | (uint)Math.Floor(magnitude * 16777216.0); + var bits = BitConverter.SingleToUInt32Bits(magnitude); + return sign | ((((bits >> 23) - 127 + 15) << 10) | ((bits >> 13) & 0x3FF)); + } + private static uint Bits(float value) => BitConverter.SingleToUInt32Bits(value); } diff --git a/tests/SharpEmu.Libs.Tests/Gpu/Vulkan/HeadlessVulkan.cs b/tests/SharpEmu.Libs.Tests/Gpu/Vulkan/HeadlessVulkan.cs index 5c3f36f..4b968b4 100644 --- a/tests/SharpEmu.Libs.Tests/Gpu/Vulkan/HeadlessVulkan.cs +++ b/tests/SharpEmu.Libs.Tests/Gpu/Vulkan/HeadlessVulkan.cs @@ -128,8 +128,9 @@ internal sealed unsafe class HeadlessVulkan : IDisposable { return Create(); } - catch (Exception) + catch (Exception exception) { + Console.Error.WriteLine($"[TEST][VULKAN] Device setup failed: {exception}"); return null; } } @@ -215,22 +216,33 @@ internal sealed unsafe class HeadlessVulkan : IDisposable var appInfo = new ApplicationInfo { SType = StructureType.ApplicationInfo, ApiVersion = apiVersion }; var validation = Environment.GetEnvironmentVariable(ValidationVariable) == "1"; var layers = validation ? SilkMarshal.StringArrayToPtr(new[] { "VK_LAYER_KHRONOS_validation" }) : 0; - var extensions = validation ? SilkMarshal.StringArrayToPtr(new[] { ExtDebugUtils.ExtensionName }) : 0; + uint instanceExtensionCount = 0; + vk.EnumerateInstanceExtensionProperties((byte*)null, &instanceExtensionCount, null); + var instanceExtensions = new ExtensionProperties[instanceExtensionCount]; + fixed (ExtensionProperties* pointer = instanceExtensions) + vk.EnumerateInstanceExtensionProperties((byte*)null, &instanceExtensionCount, pointer); + var portable = instanceExtensions.Any(extension => + SilkMarshal.PtrToString((nint)extension.ExtensionName) == "VK_KHR_portability_enumeration"); + var instanceNames = new List(); + if (validation) instanceNames.Add(ExtDebugUtils.ExtensionName); + if (portable) instanceNames.Add("VK_KHR_portability_enumeration"); + var extensions = instanceNames.Count != 0 ? SilkMarshal.StringArrayToPtr(instanceNames.ToArray()) : 0; var instanceInfo = new InstanceCreateInfo { SType = StructureType.InstanceCreateInfo, + Flags = portable ? InstanceCreateFlags.EnumeratePortabilityBitKhr : 0, PApplicationInfo = &appInfo, EnabledLayerCount = validation ? 1u : 0u, PpEnabledLayerNames = (byte**)layers, - EnabledExtensionCount = validation ? 1u : 0u, + EnabledExtensionCount = (uint)instanceNames.Count, PpEnabledExtensionNames = (byte**)extensions, }; var created = vk.CreateInstance(&instanceInfo, null, out var instance); if (validation) { SilkMarshal.Free(layers); - SilkMarshal.Free(extensions); } + if (extensions != 0) SilkMarshal.Free(extensions); if (created != Result.Success) { @@ -368,6 +380,7 @@ internal sealed unsafe class HeadlessVulkan : IDisposable vulkan13Features.PNext = &barycentricFeatures; } var extensionNames = new List(); + if (HasDeviceExtensions(vk, physical, ["VK_KHR_portability_subset"])) extensionNames.Add("VK_KHR_portability_subset"); if (dynamicRendering) extensionNames.AddRange(RenderingExtensionNames); if (barycentric) extensionNames.Add(barycentricExtension); const string fillRectangleExtension = "VK_NV_fill_rectangle";