Compare commits

...

8 Commits

Author SHA1 Message Date
kuba 3b12e429a9 [Tests] Wait for the blocked worker before publishing its signal 2026-10-07 10:52:14 +02:00
kuba 52ba493155 [SpirvTranslator] Saturate V_CVT_PKRTZ_F16_F32 at 65504 instead of overflowing to infinity
Rounding toward zero never produces infinity from a finite value: past the
f16 range the result is +-65504. We truncated the mantissa and let
packHalf2x16 round, so large values became infinite. Silent Hill's deferred
lighting packs title-menu values past 65504; its height fog then multiplies
scene color by a tiny transmittance, which keeps 65504 finite but leaves an
infinity infinite, a later pass turns it into NaN and the whole 3D menu
background renders black. Subnormal results now also keep whole 2^-24 steps.
A MoltenVK device test covers overflow, truncation, subnormals, NaN and inf.
2026-10-06 10:42:40 +02:00
kuba 829cf4132e [Tests] Enable MoltenVK portability devices in headless harness 2026-10-06 10:42:40 +02:00
kuba aef84c8934 [Gpu] Publish linear GPU-written images to guest memory by default
Unreal reads its eye-adaptation result back on the CPU through a linear
staging texture (FRHIGPUTextureReadback) and derives View.PreExposure from
it. The image cache was built with linear readback disabled, so the CPU
always read zeros, PreExposure stayed 1.0 and the scene rendered without
pre-exposure. FFT bloom's bright-pixel prefilter, which works in
pre-exposed space, then flattened every pixel to its max luma and the
Silent Hill title menu came out nearly black. SHARPEMU_READBACK_LINEAR_IMAGES=0
restores the old behaviour.
2026-10-06 10:42:40 +02:00
kuba 802c8fc6e4 [SpirvTranslator] Keep wave64 LDS phase barriers when a half-mask plan applies
The half-mask plan's barriers order its own mask exchanges, not the
program's shared-memory traffic, yet planning a program switched the LDS
phase ordering off. UE's FFT bloom (wave64, 512 threads, LDS butterflies)
then read half-written rows: Silent Hill's convolution output collapsed
into a flat haze (max 56 for a scene peaking above 1500) and the histogram
fed by it never adapted exposure.
2026-10-06 10:42:40 +02:00
kuba c52d31d73b [Gpu] Materialize every fixed DCC clear code on a sampled image
A DCC fast clear stays pending until the surface binds as a colour target;
when a shader samples the surface first, only the clear-to-zero code was
materialized. The other fixed codes (alpha 1, rgb 1, rgba 1) carry their
own colour too. Silent Hill fast-clears UE's separate-translucency target
to (0,0,0,1) and only samples it, so the post composite read stale zeros
and the whole title-menu scene came out black.
2026-10-06 10:42:40 +02:00
kuba ecf019f23b [SpirvTranslator] Read half-selected V_FMA_MIX literals as f16
op_sel_hi marks a V_FMA_MIX operand as f16 for literals too, but literals
were always read as f32 bit patterns. Silent Hill's deferred lighting
weights a luminance with the f16 literals 0x34CD/0x38B8/0x2F0A (0.30, 0.59,
0.11); as f32 denormals they made the luminance zero, the shader clamped it
to 1e-4 and divided by it, and the title-menu scene came out thousands of
times too bright, which auto exposure then turned black.
2026-10-06 10:42:40 +02:00
kuba e65b2e8233 [Gpu] Cover the whole tile plan when a buffer reads back an image
TrySynchronizeBufferFromImage sized the copy from the mip layout, but the
tile plan of a fully fitting image can extend past it; Silent Hill hit a
128x128 slice planned at the exact end of the mip chain and the tiler
aborted the process. Grow the copy to the plan's tiled end when the
buffer holds it, otherwise keep the guest bytes.
2026-10-06 10:42:40 +02:00
8 changed files with 209 additions and 24 deletions

View File

@ -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<TileTransfer> 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)
{

View File

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

View File

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

View File

@ -1778,8 +1778,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.
@ -1791,7 +1794,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)
@ -5199,12 +5202,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)

View File

@ -1453,9 +1453,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;
for (var index = block.StartIndex; index < block.EndIndex; index++)
{

View File

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

View File

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

View File

@ -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<string>();
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<string>();
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";