Files
sharpemu/src/SharpEmu.Libs/VideoOut/VulkanVideoPresenter.cs
T
Berk fbafd3f429 [GUI] Add native Vulkan host surface support and more (#337)
* [GUI] Add native Vulkan host surface support and more

* reuse

* [GUI] set width session menu
2026-07-17 18:57:10 +03:00

14741 lines
600 KiB
C#

// Copyright (C) 2026 SharpEmu Emulator Project
// SPDX-License-Identifier: GPL-2.0-or-later
using Silk.NET.Core;
using Silk.NET.Core.Native;
using Silk.NET.Maths;
using SharpEmu.HLE;
using SharpEmu.Libs.Agc;
using SharpEmu.Libs.Bink;
using Silk.NET.Input;
using SharpEmu.Libs.Gpu;
using SharpEmu.ShaderCompiler;
using SharpEmu.ShaderCompiler.Vulkan;
using Silk.NET.Vulkan;
using Silk.NET.Vulkan.Extensions.KHR;
using Silk.NET.Vulkan.Extensions.EXT;
using Silk.NET.Windowing;
using System.Diagnostics;
using System.Numerics;
using System.Runtime.CompilerServices;
using System.Runtime.InteropServices;
using System.Security.Cryptography;
using System.Text;
using VkBuffer = Silk.NET.Vulkan.Buffer;
using VkSemaphore = Silk.NET.Vulkan.Semaphore;
namespace SharpEmu.Libs.VideoOut;
internal readonly record struct VulkanRenderTargetFormat(
Format Format,
Gen5PixelOutputKind OutputKind)
{
public bool IsInteger => OutputKind is Gen5PixelOutputKind.Uint or Gen5PixelOutputKind.Sint;
}
internal sealed record VulkanTranslatedGuestDraw(
byte[] VertexSpirv,
byte[] PixelSpirv,
IReadOnlyList<GuestDrawTexture> Textures,
IReadOnlyList<GuestMemoryBuffer> GlobalMemoryBuffers,
IReadOnlyList<GuestVertexBuffer> VertexBuffers,
uint AttributeCount,
uint VertexCount,
uint InstanceCount,
uint PrimitiveType,
GuestIndexBuffer? IndexBuffer,
GuestRenderState RenderState);
internal sealed record VulkanOffscreenGuestDraw(
VulkanTranslatedGuestDraw Draw,
IReadOnlyList<GuestRenderTarget> Targets,
GuestDepthTarget? DepthTarget,
bool PublishTarget,
ulong ShaderAddress);
internal sealed record VulkanComputeGuestDispatch(
ulong ShaderAddress,
byte[] ComputeSpirv,
IReadOnlyList<GuestDrawTexture> Textures,
IReadOnlyList<GuestMemoryBuffer> GlobalMemoryBuffers,
uint GroupCountX,
uint GroupCountY,
uint GroupCountZ,
uint BaseGroupX,
uint BaseGroupY,
uint BaseGroupZ,
uint LocalSizeX,
uint LocalSizeY,
uint LocalSizeZ,
bool IsIndirect,
bool WritesGlobalMemory,
uint ThreadCountX = uint.MaxValue,
uint ThreadCountY = uint.MaxValue,
uint ThreadCountZ = uint.MaxValue);
internal sealed record VulkanOrderedGuestAction(
Action Action,
string DebugName);
internal sealed record VulkanOrderedGuestFlip(
long Version,
int VideoOutHandle,
int DisplayBufferIndex,
ulong Address,
uint Width,
uint Height,
uint PitchInPixel);
internal sealed record VulkanOrderedGuestFlipWait(
long Version,
int VideoOutHandle,
int DisplayBufferIndex);
internal readonly record struct VulkanGuestQueueIdentity(
string Name,
ulong SubmissionId)
{
public static VulkanGuestQueueIdentity Default { get; } = new("host.default", 0);
}
internal static unsafe class VulkanVideoPresenter
{
// Standalone CLI launches use a desktop-sized surface. The embedded GUI
// always takes its dimensions from the native child control instead.
private const uint DefaultWindowWidth = 1920;
private const uint DefaultWindowHeight = 1080;
// Vulkan's portable upper bound for minStorageBufferOffsetAlignment is
// 256 bytes. Using that fixed power of two (instead of racing the render
// thread's physical-device query) gives shader translation and descriptor
// creation one stable aliasing contract on every conformant device.
internal const ulong GuestStorageBufferOffsetAlignment = 256;
// Guest draw snapshots churn through a small set of 128 KiB-16 MiB size
// classes thousands of times per second. The process-wide shared pool
// trims and repartitions those large arrays aggressively under GC load,
// causing hundreds of MiB/s of replacement byte[] allocations. Keep a
// bounded, non-shared pool for AGC-to-presenter ownership transfers.
internal static System.Buffers.ArrayPool<byte> GuestDataPool { get; } =
System.Buffers.ArrayPool<byte>.Create(
maxArrayLength: 16 * 1024 * 1024,
maxArraysPerBucket: 96);
// The pending queue and per-render drain budget bound how much guest GPU
// work can be buffered ahead of the presenter. Draws are batched into
// shared command buffers, so draining a large batch per render tick is
// cheap; small caps here throttle games that issue more than a handful
// of draws per frame to a fraction of the display rate. The pending cap
// stays tighter than the drain budget because queued draws pin their
// pooled guest-data arrays until the render thread uploads them.
// The Cocoa event loop must stay responsive while guest work is pending,
// but Windows and Linux render on a dedicated host thread. Keeping the
// macOS item limit everywhere throttles draw-heavy games well below their
// display rate before the byte budget is remotely close to full.
private static readonly int _maxPendingGuestWorkItems =
int.TryParse(
Environment.GetEnvironmentVariable("SHARPEMU_PENDING_GUEST_WORK_ITEMS"),
out var pendingGuestWorkItems) && pendingGuestWorkItems > 0
? pendingGuestWorkItems
: OperatingSystem.IsMacOS() ? 64 : 512;
private const ulong MaximumCachedHostBufferBytes = 128UL * 1024 * 1024;
// A captured 4K flip can consume tens of MiB of device-local memory.
// Retain only a short presentation queue while always preserving the
// newest generation; older immutable versions are retired immediately.
private const int MaxPendingGuestFlipVersions = 4;
// A count-only queue bound is not a memory bound: one compute dispatch can
// carry dozens of full-resolution texture snapshots. At 4K, 64 queued
// dispatches retained more than 12 GiB of managed byte arrays before the
// render thread could upload them. Keep the count cap for small work and
// independently apply backpressure to the actual retained payload.
private static readonly ulong _maxPendingGuestWorkBytes =
(ulong.TryParse(
Environment.GetEnvironmentVariable("SHARPEMU_PENDING_GUEST_WORK_MB"),
out var pendingGuestWorkMb) && pendingGuestWorkMb > 0
? pendingGuestWorkMb
: 256UL) * 1024UL * 1024UL;
private static readonly int _maxGuestWorkPerRender =
int.TryParse(
Environment.GetEnvironmentVariable("SHARPEMU_MAX_GUEST_WORK_PER_RENDER"),
out var guestWorkPerRender) && guestWorkPerRender > 0
? guestWorkPerRender
: OperatingSystem.IsMacOS() ? 256 : 1024;
// On macOS the whole window loop — including Render() and its guest-work
// drain — runs on the process main thread, so draining a large backlog of
// slow guest work (heavy compute) blocks the Cocoa event pump and marks the
// window "Not Responding" while starving the swapchain present. Cap the
// wall-clock time spent draining per Render() call; leftover work stays
// queued for the next frame. SHARPEMU_RENDER_WORK_BUDGET_MS overrides
// (0 disables the cap); default 12ms keeps the macOS window interactive at
// ~60Hz. Windows and Linux use a dedicated render thread, so they drain
// without a time budget by default.
private static readonly long _renderWorkBudgetTicks =
(long.TryParse(
Environment.GetEnvironmentVariable("SHARPEMU_RENDER_WORK_BUDGET_MS"),
out var renderBudgetMs) && renderBudgetMs >= 0
? renderBudgetMs
: OperatingSystem.IsMacOS() ? 12L : 0L) *
System.Diagnostics.Stopwatch.Frequency / 1000L;
// Max time the main-thread Render() will block waiting for a frame slot's
// GPU fence before skipping the frame and returning to the event pump.
// Prevents the window freezing behind a slow-compute GPU backlog.
// SHARPEMU_FRAME_WAIT_BUDGET_MS overrides; default 8ms on macOS. The
// dedicated Windows/Linux render thread may wait for its frame slot so it
// does not drop guest-work drain opportunities under normal GPU load.
private static readonly ulong _frameSlotWaitBudgetNs =
ulong.TryParse(
Environment.GetEnvironmentVariable("SHARPEMU_FRAME_WAIT_BUDGET_MS"),
out var frameWaitMs) && frameWaitMs > 0
? frameWaitMs * 1_000_000UL
: OperatingSystem.IsMacOS() ? 8_000_000UL : ulong.MaxValue;
// Cap the guest-submission fence wait so a GPU submission whose fence never
// signals (a mistranslated compute shader that hangs the Metal queue) cannot
// freeze the render thread forever and starve the swapchain present.
// SHARPEMU_FENCE_WAIT_TIMEOUT_MS overrides; default 3s.
private static readonly ulong _guestFenceWaitTimeoutNs =
ulong.TryParse(Environment.GetEnvironmentVariable("SHARPEMU_FENCE_WAIT_TIMEOUT_MS"), out var fenceMs) && fenceMs > 0
? fenceMs * 1_000_000UL
: 3_000_000_000UL;
// When making room in the in-flight submission queue from the macOS MAIN
// thread (Render() -> guest-work drain), block only this long per attempt
// instead of the full fence timeout. If a slow/capped compute submission
// isn't done yet, proceed anyway: the in-flight cap is soft, the fence and
// command-buffer pools are dynamic so a brief overshoot is safe, and the
// queue drains as GPU completions land on later frames. This keeps the
// window responsive (event pump runs) under a heavy compute backlog instead
// of the main thread sitting in vkWaitForFences for up to 3s per chunk.
// SHARPEMU_SUBMISSION_CAPACITY_WAIT_MS overrides; default 100ms; 0 restores
// the full blocking wait.
private static readonly ulong _submissionCapacityWaitNs =
ulong.TryParse(Environment.GetEnvironmentVariable("SHARPEMU_SUBMISSION_CAPACITY_WAIT_MS"), out var capMs)
? capMs * 1_000_000UL
: 100_000_000UL;
private static readonly HashSet<string> _tracedFenceTimeouts = new();
private static long _guestQueueBackpressureTraceCount;
// Diagnostic: skip every compute dispatch (mistranslated compute shaders
// run long / GPU-hang and starve the present). Isolates whether the
// geometry+composite path renders on its own.
private static readonly bool _skipAllCompute =
Environment.GetEnvironmentVariable("SHARPEMU_SKIP_ALL_COMPUTE") == "1";
// Diagnostic: skip compute dispatches whose GroupCountZ is at least this,
// to isolate a specific tall dispatch (e.g. Demon's Souls' 27x15x72 froxel
// shader that hangs the Metal queue) without needing its ASLR-varying
// address. 0 disables.
private static readonly uint _skipTallComputeZ =
uint.TryParse(Environment.GetEnvironmentVariable("SHARPEMU_SKIP_TALL_COMPUTE_Z"), out var z)
? z
: 0;
private const uint GuestPrimitiveRectList = 0x11;
private static readonly object _gate = new();
private readonly record struct PendingGuestWork(
object Work,
ulong PayloadBytes,
long Sequence,
long RequiredSequence,
long EnqueuedTicks,
VulkanGuestQueueIdentity Queue);
// PS5 exposes independent graphics and asynchronous-compute queues. A
// single host FIFO adds dependencies that do not exist in the guest: one
// slow ACB dispatch can delay a graphics clear until the CPU has reused
// that heap. Keep FIFO order within each logical guest queue and schedule
// ready queues round-robin. Explicit WAIT_REG_MEM packets remain the only
// mechanism that orders one logical queue behind another.
private static readonly Dictionary<string, LinkedList<PendingGuestWork>>
_pendingGuestWorkByQueue = new(StringComparer.Ordinal);
private static readonly List<string> _pendingGuestQueueSchedule = [];
private static int _pendingGuestQueueCursor;
private static int _pendingGuestWorkCount;
private static ulong _pendingGuestWorkBytes;
// A flip names an image that was rendered earlier in the command stream.
// Keep a small FIFO of those flips instead of replacing an incomplete one
// with the next frame: the guest can enqueue the next frame before the
// render thread reaches the previous image, which otherwise starves
// presentation indefinitely.
private static readonly Queue<Presentation> _pendingGuestImagePresentations = new();
private static readonly Dictionary<ulong, long> _guestImageWorkSequences = new();
private static readonly Dictionary<ulong, uint> _availableGuestImages = new();
private static readonly Dictionary<(int Handle, int BufferIndex), long>
_lastOrderedGuestFlipVersions = new();
private static long _orderedGuestFlipVersionSequence;
// Storage-image initialization is copied only by the first queued writer.
// Later dispatches targeting the same image must not each retain another
// multi-megabyte guest-memory snapshot while waiting for that first writer
// to reach the presenter. Reference counts let failed/completed work
// retire its reservation without leaving a permanent false cache hit.
private readonly record struct PendingGuestImageUpload(int Count, long OwnerSequence);
private static readonly Dictionary<(ulong Address, uint Format), PendingGuestImageUpload>
_pendingGuestImageUploads = new();
private static readonly Dictionary<ulong, byte[]> _pendingGuestImageInitialData = new();
private static readonly Dictionary<ulong, (uint Width, uint Height, ulong ByteCount)>
_guestImageExtents = new();
private static readonly bool _traceGuestImageEvents =
string.Equals(
Environment.GetEnvironmentVariable("SHARPEMU_TRACE_DRAWS"),
"1",
StringComparison.Ordinal) ||
string.Equals(
Environment.GetEnvironmentVariable("SHARPEMU_TRACE_GUEST_IMAGE_EVENTS"),
"1",
StringComparison.Ordinal);
private static readonly bool _traceGuestWorkCompletion =
string.Equals(
Environment.GetEnvironmentVariable("SHARPEMU_TRACE_GUEST_WORK_COMPLETION"),
"1",
StringComparison.Ordinal);
private static readonly HashSet<(ulong Address, uint Width, uint Height)>
_tracedGuestImageSubmissions = [];
private static Thread? _thread;
private static VulkanHostSurface? _hostSurface;
private static VulkanHostSurface? _hostSurfacePendingDetach;
internal static event Action<VulkanHostSurface>? FirstHostFramePresented;
private static Presentation? _latestPresentation;
private static byte[]? _copyFragmentSpirv;
private static uint _windowWidth;
private static uint _windowHeight;
private static bool _closed;
private static bool _presenterCloseRequested;
private const string DebugUtilsExtensionName = "VK_EXT_debug_utils";
private const uint NvidiaVendorId = 0x10DE;
private const uint AmdVendorId = 0x1002;
// Other GPU PCI vendor IDs, for reference when adding future rules:
// Intel 0x8086, Apple 0x106B, Qualcomm 0x5143 (Windows-on-ARM), Microsoft software 0x1414.
private const int LastResortPenalty = 1000;
private const string PortabilityEnumerationExtensionName = "VK_KHR_portability_enumeration";
private const string PortabilitySubsetExtensionName = "VK_KHR_portability_subset";
private const int GlfwPlatformHint = 0x00050003;
private const int GlfwPlatformWin32 = 0x00060001;
private const int GlfwPlatformCocoa = 0x00060002;
private const int GlfwPlatformWayland = 0x00060003;
private const int GlfwPlatformX11 = 0x00060004;
private const int GlfwPlatformNull = 0x00060005;
internal static int ScorePhysicalDevice(
PhysicalDeviceProperties properties,
string name,
string? deviceOverride)
{
if (!string.IsNullOrWhiteSpace(deviceOverride))
{
return name.Contains(deviceOverride, StringComparison.OrdinalIgnoreCase) ? 1000 : -1000;
}
var score = properties.DeviceType switch
{
PhysicalDeviceType.DiscreteGpu => 300,
PhysicalDeviceType.VirtualGpu => 100,
PhysicalDeviceType.IntegratedGpu => 50,
PhysicalDeviceType.Cpu => 20,
_ => 10,
};
if (properties.VendorID == NvidiaVendorId)
{
score += 500;
}
score -= ComputeDevicePenalty(properties, OperatingSystem.IsWindows());
return score;
}
internal static int ComputeDevicePenalty(PhysicalDeviceProperties properties, bool isWindows)
{
var penalty = 0;
if (isWindows &&
properties.DeviceType == PhysicalDeviceType.IntegratedGpu &&
properties.VendorID == AmdVendorId)
{
penalty += LastResortPenalty;
}
return penalty;
}
private static bool _splashHidden;
private static long _enqueuedGuestWorkSequence;
// Largest contiguous completed sequence, retained for compact diagnostics.
// Per-queue scheduling can complete a later global id first, so correctness
// checks use IsGuestWorkCompletedLocked rather than numeric <= comparisons.
private static long _completedGuestWorkSequence;
private static readonly HashSet<long> _completedGuestWorkOutOfOrder = [];
private static readonly Dictionary<string, long> _lastEnqueuedGuestWorkByQueue =
new(StringComparer.Ordinal);
private static long _executingGuestWorkSequence;
[ThreadStatic]
private static VulkanGuestQueueIdentity? _submittingGuestQueue;
[ThreadStatic]
private static bool _enqueueAsImmediateQueueFollowup;
[ThreadStatic]
private static LinkedListNode<PendingGuestWork>? _immediateFollowupTail;
private sealed class GuestQueueScope : IDisposable
{
private readonly VulkanGuestQueueIdentity? _previous;
private bool _disposed;
public GuestQueueScope(VulkanGuestQueueIdentity queue)
{
_previous = _submittingGuestQueue;
_submittingGuestQueue = queue;
}
public void Dispose()
{
if (_disposed)
{
return;
}
_disposed = true;
_submittingGuestQueue = _previous;
}
}
public static IDisposable EnterGuestQueue(
string queueName,
ulong submissionId) =>
new GuestQueueScope(new VulkanGuestQueueIdentity(
string.IsNullOrWhiteSpace(queueName) ? "guest.unknown" : queueName,
submissionId));
private static long CurrentSubmittingQueueTailLocked()
{
var queue = _submittingGuestQueue;
return queue is { } identity &&
_lastEnqueuedGuestWorkByQueue.TryGetValue(identity.Name, out var tail)
? tail
: 0;
}
private static bool ShouldTraceGuestImageSubmissionsForDiagnostics()
{
return string.Equals(
Environment.GetEnvironmentVariable("SHARPEMU_TRACE_GUEST_IMAGES"),
"1",
StringComparison.Ordinal);
}
private static bool ShouldSamplePresentedGuestImageForDiagnostics(long frame)
{
var mode = Environment.GetEnvironmentVariable("SHARPEMU_TRACE_GUEST_IMAGES");
if (string.Equals(mode, "present", StringComparison.OrdinalIgnoreCase))
{
// A 4K Vulkan readback is deliberately synchronous and can take
// several seconds on Linux. The lightweight "present" mode only
// needs one proof that the final image is non-black.
return frame == 1;
}
return string.Equals(mode, "1", StringComparison.Ordinal) &&
(frame is 1 or 30 or 120 || frame % 600 == 0);
}
public static void EnsureStarted(uint width, uint height)
{
if (width == 0 || height == 0)
{
return;
}
lock (_gate)
{
if (_closed || _thread is not null)
{
return;
}
}
var hasSplash = PngSplashLoader.TryLoad(
out var splashPixels,
out var splashWidth,
out var splashHeight);
lock (_gate)
{
if (_closed || _thread is not null)
{
return;
}
_windowWidth = width;
_windowHeight = height;
_latestPresentation ??= _splashHidden
? new Presentation(
CreateBlackFrame(width, height),
width,
height,
1,
GuestDrawKind.None,
TranslatedDraw: null,
RequiredGuestWorkSequence: 0,
IsSplash: false)
: hasSplash
? new Presentation(
splashPixels,
splashWidth,
splashHeight,
1,
GuestDrawKind.None,
TranslatedDraw: null,
RequiredGuestWorkSequence: 0,
IsSplash: true)
: new Presentation(
null,
width,
height,
0,
GuestDrawKind.None,
TranslatedDraw: null,
RequiredGuestWorkSequence: 0,
IsSplash: false);
if (_hostSurface is not null && _latestPresentation is { IsSplash: true })
{
// The GUI keeps its native child hidden while the launcher is
// loading. Reveal it for the real VideoOut splash rather than
// substituting a launcher-side image.
Console.Error.WriteLine("[VIDEOOUT][INFO] Hosted splash ready.");
}
StartPresenterLocked();
}
}
/// <summary>
/// Selects a same-process native surface for the next VideoOut session.
/// This is intentionally rejected once the presenter has started: Vulkan
/// surface ownership cannot change under an active swapchain.
/// </summary>
public static bool TryAttachHostSurface(VulkanHostSurface surface)
{
ArgumentNullException.ThrowIfNull(surface);
lock (_gate)
{
if (_thread is not null)
{
return false;
}
ResetHostSessionStateLocked();
_hostSurface = surface;
_closed = false;
_presenterCloseRequested = false;
return true;
}
}
/// <summary>
/// Releases the host surface after the guest session has stopped.
/// </summary>
public static void DetachHostSurface(VulkanHostSurface surface)
{
ArgumentNullException.ThrowIfNull(surface);
lock (_gate)
{
if (!ReferenceEquals(_hostSurface, surface))
{
return;
}
if (_thread is null)
{
_hostSurface = null;
}
else
{
_hostSurfacePendingDetach = surface;
}
}
}
internal static bool UsesHostSurface
{
get
{
lock (_gate)
{
return _hostSurface is not null;
}
}
}
private static void NotifyFirstHostFramePresented(VulkanHostSurface surface)
{
// This marker crosses the GUI child-process pipe. The in-process
// event remains for hosts that embed the renderer directly.
Console.Error.WriteLine("[VIDEOOUT][INFO] Hosted first frame presented.");
var callback = FirstHostFramePresented;
if (callback is null)
{
return;
}
try
{
callback(surface);
}
catch (Exception exception)
{
Console.Error.WriteLine(
$"[LOADER][WARN] Embedded first-frame notification failed: {exception.Message}");
}
}
private static void ResetHostSessionStateLocked()
{
_latestPresentation = null;
_splashHidden = false;
_pendingGuestWorkByQueue.Clear();
_pendingGuestQueueSchedule.Clear();
_pendingGuestQueueCursor = 0;
_pendingGuestWorkCount = 0;
_pendingGuestWorkBytes = 0;
_pendingGuestImagePresentations.Clear();
_guestImageWorkSequences.Clear();
_availableGuestImages.Clear();
_lastOrderedGuestFlipVersions.Clear();
_orderedGuestFlipVersionSequence = 0;
_pendingGuestImageUploads.Clear();
_pendingGuestImageInitialData.Clear();
_guestImageExtents.Clear();
_enqueuedGuestWorkSequence = 0;
_completedGuestWorkSequence = 0;
_completedGuestWorkOutOfOrder.Clear();
_lastEnqueuedGuestWorkByQueue.Clear();
_executingGuestWorkSequence = 0;
_hostSurfacePendingDetach = null;
}
public static void HideSplashScreen()
{
lock (_gate)
{
_splashHidden = true;
if (_closed || _latestPresentation is not { IsSplash: true } latest)
{
return;
}
var sequence = latest.Sequence + 1;
_latestPresentation = new Presentation(
CreateBlackFrame(latest.Width, latest.Height),
latest.Width,
latest.Height,
sequence,
GuestDrawKind.None,
TranslatedDraw: null,
RequiredGuestWorkSequence: 0,
IsSplash: false);
Console.Error.WriteLine("[LOADER][INFO] Vulkan VideoOut hid splash");
}
}
public static void Submit(byte[] bgraFrame, uint width, uint height)
{
if (bgraFrame.Length != checked((int)(width * height * 4)))
{
return;
}
lock (_gate)
{
if (_closed)
{
return;
}
var sequence = (_latestPresentation?.Sequence ?? 0) + 1;
_latestPresentation = new Presentation(
bgraFrame,
width,
height,
sequence,
GuestDrawKind.None,
TranslatedDraw: null,
RequiredGuestWorkSequence: 0,
IsSplash: false);
if (_thread is not null)
{
return;
}
_windowWidth = width;
_windowHeight = height;
StartPresenterLocked();
}
}
public static void SubmitGuestDraw(GuestDrawKind drawKind, uint width, uint height)
{
if (drawKind == GuestDrawKind.None || width == 0 || height == 0)
{
return;
}
lock (_gate)
{
if (_closed ||
_latestPresentation is { Pixels: null } latest &&
latest.DrawKind == drawKind &&
latest.Width == width &&
latest.Height == height)
{
return;
}
var sequence = (_latestPresentation?.Sequence ?? 0) + 1;
_latestPresentation = new Presentation(
null,
width,
height,
sequence,
drawKind,
TranslatedDraw: null,
RequiredGuestWorkSequence: CurrentSubmittingQueueTailLocked(),
IsSplash: false);
if (_thread is not null)
{
return;
}
_windowWidth = width;
_windowHeight = height;
StartPresenterLocked();
}
}
public static void SubmitTranslatedDraw(
byte[] pixelSpirv,
IReadOnlyList<GuestDrawTexture> textures,
IReadOnlyList<GuestMemoryBuffer> globalMemoryBuffers,
uint width,
uint height,
uint attributeCount,
byte[]? vertexSpirv = null,
uint vertexCount = 3,
uint instanceCount = 1,
uint primitiveType = 4,
GuestIndexBuffer? indexBuffer = null,
IReadOnlyList<GuestVertexBuffer>? vertexBuffers = null,
GuestRenderState? renderState = null)
{
if (pixelSpirv.Length == 0 || width == 0 || height == 0)
{
return;
}
lock (_gate)
{
if (_closed)
{
return;
}
var sequence = (_latestPresentation?.Sequence ?? 0) + 1;
_latestPresentation = new Presentation(
null,
width,
height,
sequence,
GuestDrawKind.None,
new VulkanTranslatedGuestDraw(
vertexSpirv ?? [],
pixelSpirv,
textures.ToArray(),
globalMemoryBuffers.ToArray(),
vertexBuffers?.ToArray() ?? [],
attributeCount,
vertexCount,
instanceCount,
primitiveType,
indexBuffer,
renderState ?? GuestRenderState.Default),
RequiredGuestWorkSequence: CurrentSubmittingQueueTailLocked(),
IsSplash: false);
if (_thread is not null)
{
return;
}
_windowWidth = width;
_windowHeight = height;
StartPresenterLocked();
}
}
public static void SubmitOffscreenTranslatedDraw(
byte[] pixelSpirv,
IReadOnlyList<GuestDrawTexture> textures,
IReadOnlyList<GuestMemoryBuffer> globalMemoryBuffers,
uint attributeCount,
GuestRenderTarget target,
byte[]? vertexSpirv = null,
uint vertexCount = 3,
uint instanceCount = 1,
uint primitiveType = 4,
GuestIndexBuffer? indexBuffer = null,
IReadOnlyList<GuestVertexBuffer>? vertexBuffers = null,
GuestRenderState? renderState = null,
GuestDepthTarget? depthTarget = null,
ulong shaderAddress = 0)
{
SubmitOffscreenTranslatedDraw(
pixelSpirv,
textures,
globalMemoryBuffers,
attributeCount,
[target],
vertexSpirv,
vertexCount,
instanceCount,
primitiveType,
indexBuffer,
vertexBuffers,
renderState,
depthTarget,
shaderAddress);
}
// Manual scans (targets are <= 8) so the per-draw validation does not
// allocate LINQ iterators/closures or a Distinct HashSet.
private static bool AnyRenderTargetInvalid(IReadOnlyList<GuestRenderTarget> targets)
{
foreach (var target in targets)
{
if (target.Address == 0 || target.Width == 0 || target.Height == 0)
{
return true;
}
}
return false;
}
private static bool RenderTargetsMismatchedOrAliased(IReadOnlyList<GuestRenderTarget> targets, GuestRenderTarget first)
{
for (var i = 0; i < targets.Count; i++)
{
if (targets[i].Width != first.Width || targets[i].Height != first.Height)
{
return true;
}
for (var j = i + 1; j < targets.Count; j++)
{
if (targets[i].Address == targets[j].Address)
{
return true;
}
}
}
return false;
}
public static void SubmitOffscreenTranslatedDraw(
byte[] pixelSpirv,
IReadOnlyList<GuestDrawTexture> textures,
IReadOnlyList<GuestMemoryBuffer> globalMemoryBuffers,
uint attributeCount,
IReadOnlyList<GuestRenderTarget> targets,
byte[]? vertexSpirv = null,
uint vertexCount = 3,
uint instanceCount = 1,
uint primitiveType = 4,
GuestIndexBuffer? indexBuffer = null,
IReadOnlyList<GuestVertexBuffer>? vertexBuffers = null,
GuestRenderState? renderState = null,
GuestDepthTarget? depthTarget = null,
ulong shaderAddress = 0)
{
if (pixelSpirv.Length == 0 ||
targets.Count == 0 ||
targets.Count > 8 ||
AnyRenderTargetInvalid(targets))
{
return;
}
var firstTarget = targets[0];
if (RenderTargetsMismatchedOrAliased(targets, firstTarget))
{
Console.Error.WriteLine(
"[LOADER][WARN] Vulkan skipped MRT draw with mismatched dimensions or aliased targets.");
return;
}
if (ShouldTraceGuestImageSubmissionsForDiagnostics())
{
Console.Error.WriteLine(
$"[LOADER][TRACE] vk.submit_call kind=SubmitOffscreenTranslatedDraw " +
$"targets={targets.Count} first=0x{firstTarget.Address:X16} " +
$"{firstTarget.Width}x{firstTarget.Height} textures={textures.Count}");
}
var effectiveRenderState = renderState ?? GuestRenderState.Default;
if (effectiveRenderState.Blends.Count == 1 && targets.Count > 1)
{
var broadcastBlends = new GuestBlendState[targets.Count];
Array.Fill(broadcastBlends, effectiveRenderState.Blends[0]);
effectiveRenderState = effectiveRenderState with
{
Blends = broadcastBlends,
};
}
lock (_gate)
{
if (_closed)
{
return;
}
foreach (var target in targets)
{
var guestTextureFormat = GetGuestTextureFormat(
target.Format,
target.NumberType);
if (guestTextureFormat != 0)
{
_availableGuestImages[target.Address] = guestTextureFormat;
}
}
var workSequence = EnqueueGuestWorkLocked(
new VulkanOffscreenGuestDraw(
new VulkanTranslatedGuestDraw(
vertexSpirv ?? [],
pixelSpirv,
textures.ToArray(),
globalMemoryBuffers.ToArray(),
vertexBuffers?.ToArray() ?? [],
attributeCount,
vertexCount,
instanceCount,
primitiveType,
indexBuffer,
effectiveRenderState),
targets.ToArray(),
depthTarget,
PublishTarget: true,
shaderAddress));
foreach (var target in targets)
{
_guestImageWorkSequences[target.Address] = workSequence;
}
}
}
public static void SubmitDepthOnlyTranslatedDraw(
byte[] pixelSpirv,
IReadOnlyList<GuestDrawTexture> textures,
IReadOnlyList<GuestMemoryBuffer> globalMemoryBuffers,
uint attributeCount,
GuestDepthTarget depthTarget,
byte[]? vertexSpirv = null,
uint vertexCount = 3,
uint instanceCount = 1,
uint primitiveType = 4,
GuestIndexBuffer? indexBuffer = null,
IReadOnlyList<GuestVertexBuffer>? vertexBuffers = null,
GuestRenderState? renderState = null,
ulong shaderAddress = 0)
{
if (pixelSpirv.Length == 0 ||
depthTarget.Address == 0 ||
depthTarget.Width == 0 ||
depthTarget.Height == 0)
{
return;
}
lock (_gate)
{
if (_closed)
{
return;
}
EnqueueGuestWorkLocked(
new VulkanOffscreenGuestDraw(
new VulkanTranslatedGuestDraw(
vertexSpirv ?? [],
pixelSpirv,
textures.ToArray(),
globalMemoryBuffers.ToArray(),
vertexBuffers?.ToArray() ?? [],
attributeCount,
vertexCount,
instanceCount,
primitiveType,
indexBuffer,
renderState ?? GuestRenderState.Default),
[new GuestRenderTarget(
Address: 0,
depthTarget.Width,
depthTarget.Height,
Format: 10,
NumberType: 0)],
depthTarget,
PublishTarget: false,
shaderAddress));
}
}
private sealed record VulkanGuestImageWrite(
ulong Address,
byte[]? Pixels,
uint FillValue);
/// <summary>
/// Reports the extent of a live guest image so DMA writes to its backing
/// memory can be mirrored into the Vulkan image (PS5 render targets alias
/// guest memory, so CP DMA fills/copies are visible to later GPU reads).
/// </summary>
internal static bool TryGetGuestImageExtent(
ulong address,
out uint width,
out uint height,
out ulong byteCount)
{
lock (_gate)
{
if (_guestImageExtents.TryGetValue(address, out var extent))
{
(width, height, byteCount) = extent;
return true;
}
}
width = 0;
height = 0;
byteCount = 0;
return false;
}
internal static void SubmitGuestImageFill(ulong address, uint fillValue)
{
lock (_gate)
{
if (_closed || !_guestImageExtents.ContainsKey(address))
{
return;
}
_guestImageWorkSequences[address] = EnqueueGuestWorkLocked(
new VulkanGuestImageWrite(address, null, fillValue));
}
}
internal static void SubmitGuestImageWrite(ulong address, byte[] pixels)
{
lock (_gate)
{
if (_closed || !_guestImageExtents.ContainsKey(address))
{
return;
}
_guestImageWorkSequences[address] = EnqueueGuestWorkLocked(
new VulkanGuestImageWrite(address, pixels, 0));
}
}
private static long _perfDrawCount;
private static long _perfDrawTicks;
private static long _perfPipelineCreations;
private static long _perfSpirvCompilations;
internal static (long Draws, double DrawMs, long Pipelines, long SpirvCompilations)
ReadAndResetPerfCounters()
{
var draws = Interlocked.Exchange(ref _perfDrawCount, 0);
var ticks = Interlocked.Exchange(ref _perfDrawTicks, 0);
var pipelines = Interlocked.Exchange(ref _perfPipelineCreations, 0);
var spirv = Interlocked.Exchange(ref _perfSpirvCompilations, 0);
return (draws, ticks * 1000.0 / System.Diagnostics.Stopwatch.Frequency, pipelines, spirv);
}
internal static void CountSpirvCompilation() =>
Interlocked.Increment(ref _perfSpirvCompilations);
internal static IReadOnlyList<(ulong Address, uint Width, uint Height, ulong ByteCount)> GetGuestImageExtents()
{
lock (_gate)
{
return _guestImageExtents
.Select(entry => (
entry.Key,
entry.Value.Width,
entry.Value.Height,
entry.Value.ByteCount))
.ToArray();
}
}
public static void SubmitStorageTranslatedDraw(
byte[] pixelSpirv,
IReadOnlyList<GuestDrawTexture> textures,
IReadOnlyList<GuestMemoryBuffer> globalMemoryBuffers,
uint attributeCount,
uint width,
uint height,
ulong shaderAddress = 0)
{
if (pixelSpirv.Length == 0 ||
width == 0 ||
height == 0 ||
textures.All(texture => !texture.IsStorage))
{
return;
}
lock (_gate)
{
if (_closed)
{
return;
}
EnqueueGuestWorkLocked(
new VulkanOffscreenGuestDraw(
new VulkanTranslatedGuestDraw(
[],
pixelSpirv,
textures.ToArray(),
globalMemoryBuffers.ToArray(),
[],
attributeCount,
3,
1,
4,
null,
GuestRenderState.Default),
[new GuestRenderTarget(
Address: 0,
width,
height,
Format: 12,
NumberType: 7)],
DepthTarget: null,
PublishTarget: false,
shaderAddress));
}
}
public static long SubmitComputeDispatch(
ulong shaderAddress,
byte[] computeSpirv,
IReadOnlyList<GuestDrawTexture> textures,
IReadOnlyList<GuestMemoryBuffer> globalMemoryBuffers,
uint groupCountX,
uint groupCountY,
uint groupCountZ,
uint baseGroupX,
uint baseGroupY,
uint baseGroupZ,
uint localSizeX,
uint localSizeY,
uint localSizeZ,
bool isIndirect,
bool writesGlobalMemory,
uint threadCountX = uint.MaxValue,
uint threadCountY = uint.MaxValue,
uint threadCountZ = uint.MaxValue)
{
if (computeSpirv.Length == 0 ||
groupCountX == 0 ||
groupCountY == 0 ||
groupCountZ == 0 ||
textures.All(texture => !texture.IsStorage) &&
!writesGlobalMemory)
{
return 0;
}
long workSequence;
lock (_gate)
{
if (_closed)
{
return 0;
}
workSequence = EnqueueGuestWorkLocked(
new VulkanComputeGuestDispatch(
shaderAddress,
computeSpirv,
textures.ToArray(),
globalMemoryBuffers.ToArray(),
groupCountX,
groupCountY,
groupCountZ,
baseGroupX,
baseGroupY,
baseGroupZ,
localSizeX,
localSizeY,
localSizeZ,
isIndirect,
writesGlobalMemory,
threadCountX,
threadCountY,
threadCountZ));
foreach (var key in GetStorageImageUploadKeys(textures))
{
_pendingGuestImageUploads[key] =
_pendingGuestImageUploads.TryGetValue(key, out var pendingUpload)
? pendingUpload with { Count = checked(pendingUpload.Count + 1) }
: new PendingGuestImageUpload(1, workSequence);
}
foreach (var texture in textures)
{
if (texture.IsStorage && texture.Address != 0)
{
_guestImageWorkSequences[texture.Address] = workSequence;
}
}
}
return workSequence;
}
/// <summary>
/// Enqueues a CPU-visible PM4 side effect behind all GPU work submitted
/// before it. The render thread flushes its open batch and waits for the
/// corresponding guest fences before invoking the action.
/// </summary>
public static long SubmitOrderedGuestAction(Action action, string debugName)
{
ArgumentNullException.ThrowIfNull(action);
lock (_gate)
{
return _closed || _thread is null
? 0
: EnqueueGuestWorkLocked(new VulkanOrderedGuestAction(action, debugName));
}
}
/// <summary>
/// Sequence currently being executed by the single guest-work consumer.
/// Intended only for address-filtered lifetime diagnostics emitted from a
/// guest-work callback before <see cref="CompleteGuestWork"/> advances it.
/// </summary>
public static long CurrentGuestWorkSequenceForDiagnostics =>
Volatile.Read(ref _executingGuestWorkSequence);
private static bool IsGuestWorkCompletedLocked(long sequence) =>
sequence <= 0 ||
sequence <= _completedGuestWorkSequence ||
_completedGuestWorkOutOfOrder.Contains(sequence);
public static bool WaitForGuestWork(
long workSequence,
int timeoutMilliseconds = System.Threading.Timeout.Infinite)
{
if (workSequence <= 0)
{
return false;
}
var waitIndefinitely = timeoutMilliseconds == System.Threading.Timeout.Infinite;
var deadline = waitIndefinitely
? long.MaxValue
: Environment.TickCount64 + Math.Max(timeoutMilliseconds, 1);
lock (_gate)
{
if (_traceGuestWorkCompletion)
{
Console.Error.WriteLine(
$"[LOADER][TRACE] vk.guest_work_wait_enter sequence={workSequence} " +
$"contiguous_completed={_completedGuestWorkSequence} " +
$"out_of_order={_completedGuestWorkOutOfOrder.Count}");
}
while (!_closed && !IsGuestWorkCompletedLocked(workSequence))
{
if (!waitIndefinitely)
{
var remaining = deadline - Environment.TickCount64;
if (remaining <= 0)
{
Console.Error.WriteLine(
$"[LOADER][WARN] Vulkan guest work wait timed out " +
$"sequence={workSequence} contiguous_completed={_completedGuestWorkSequence} " +
$"out_of_order={_completedGuestWorkOutOfOrder.Count}");
return false;
}
System.Threading.Monitor.Wait(
_gate,
checked((int)Math.Min(remaining, 1_000)));
continue;
}
// CPU-visible GPU writes are ordering points in the guest
// command stream. First-use shader compilation can take more
// than a minute on MoltenVK; timing out would let the guest
// consume stale zero-filled buffers and permanently corrupt
// the frame. Closing the presenter pulses this monitor, so an
// unbounded correctness wait remains interruptible.
System.Threading.Monitor.Wait(_gate, 1_000);
}
var completed = IsGuestWorkCompletedLocked(workSequence);
if (_traceGuestWorkCompletion)
{
Console.Error.WriteLine(
$"[LOADER][TRACE] vk.guest_work_wait_exit sequence={workSequence} " +
$"completed={completed} contiguous_completed={_completedGuestWorkSequence} " +
$"out_of_order={_completedGuestWorkOutOfOrder.Count}");
}
return completed;
}
}
public static bool TrySubmitGuestImage(
ulong address,
uint width,
uint height,
uint pitchInPixel)
{
var traceSubmission = false;
lock (_gate)
{
if (_closed ||
!_availableGuestImages.ContainsKey(address))
{
return false;
}
traceSubmission =
_tracedGuestImageSubmissions.Add((address, width, height));
var sequence = (_latestPresentation?.Sequence ?? 0) + 1;
var requiredWorkSequence = _guestImageWorkSequences.TryGetValue(
address,
out var imageWorkSequence)
? imageWorkSequence
: _completedGuestWorkSequence;
var presentation = new Presentation(
null,
width,
height,
sequence,
GuestDrawKind.None,
TranslatedDraw: null,
// Wait only for the work that last wrote this image, not for
// every later command the guest has already queued. Requiring
// the global tail makes a fast guest permanently outrun the
// renderer and turns every flip into a dropped black frame.
RequiredGuestWorkSequence: requiredWorkSequence,
IsSplash: false,
GuestImageAddress: address);
_latestPresentation = presentation;
_pendingGuestImagePresentations.Enqueue(presentation);
while (_pendingGuestImagePresentations.Count > MaxPendingGuestFlipVersions)
{
_pendingGuestImagePresentations.Dequeue();
}
}
if (traceSubmission)
{
var effectivePitch = pitchInPixel == 0 ? width : pitchInPixel;
Console.Error.WriteLine(
$"[LOADER][TRACE] vk.submit_guest_image addr=0x{address:X16} " +
$"size={width}x{height} pitch={effectivePitch}");
}
return true;
}
/// <summary>
/// Enqueues an AGC flip at its exact position in the logical guest queue.
/// The presenter captures the named image into an immutable Vulkan image
/// before it executes later work from the same queue. Presentation then
/// consumes that captured generation rather than the mutable render target.
/// </summary>
public static bool TrySubmitOrderedGuestImageFlip(
int videoOutHandle,
int displayBufferIndex,
ulong address,
uint width,
uint height,
uint pitchInPixel)
{
lock (_gate)
{
if (_closed ||
_thread is null ||
!_availableGuestImages.ContainsKey(address))
{
return false;
}
var version = ++_orderedGuestFlipVersionSequence;
_lastOrderedGuestFlipVersions[(videoOutHandle, displayBufferIndex)] = version;
return EnqueueGuestWorkLocked(
new VulkanOrderedGuestFlip(
version,
videoOutHandle,
displayBufferIndex,
address,
width,
height,
pitchInPixel)) > 0;
}
}
/// <summary>
/// Preserves sceAgcDcbWaitUntilSafeForRendering in queue order. Because an
/// ordered flip first copies the mutable render target into an immutable
/// generation on the same Vulkan queue, reaching this marker proves later
/// rendering cannot change the frame selected by that flip. No CPU wait or
/// event-loop stall is required.
/// </summary>
public static long SubmitOrderedGuestFlipWait(
int videoOutHandle,
int displayBufferIndex)
{
lock (_gate)
{
var version = _lastOrderedGuestFlipVersions.TryGetValue(
(videoOutHandle, displayBufferIndex),
out var lastVersion)
? lastVersion
: 0;
return _closed || _thread is null
? 0
: EnqueueGuestWorkLocked(
new VulkanOrderedGuestFlipWait(
version,
videoOutHandle,
displayBufferIndex));
}
}
/// <summary>
/// On PS5 a render target aliases guest memory, so CPU-prefilled pixels are
/// visible before the first draw. Our Vulkan images start undefined, so the
/// first draw into a new address must seed the image from guest memory.
/// </summary>
internal static bool GuestImageWantsInitialData(ulong address)
{
if (address == 0)
{
return false;
}
lock (_gate)
{
return !_availableGuestImages.ContainsKey(address) &&
!_pendingGuestImageInitialData.ContainsKey(address);
}
}
internal static void ProvideGuestImageInitialData(ulong address, byte[] rgbaPixels)
{
lock (_gate)
{
_pendingGuestImageInitialData[address] = rgbaPixels;
}
}
private static byte[]? TakeGuestImageInitialData(ulong address)
{
lock (_gate)
{
if (!_pendingGuestImageInitialData.TryGetValue(address, out var data))
{
return null;
}
_pendingGuestImageInitialData.Remove(address);
return data;
}
}
// Mirror of the render thread's texture-cache identities, readable from
// the guest submit thread. The AGC translator used to allocate and copy
// every referenced texture's texels out of guest memory on every draw,
// only for the presenter to discard the bytes on a cache hit — for a
// scene sampling large textures this was by far the dominant CPU cost
// (gigabytes/second of allocation, page faults and GC pressure).
// ConcurrentDictionary keyed set: reads happen per texture per draw on
// the guest submit thread and must not contend with the render thread's
// mutations.
private static readonly System.Collections.Concurrent.ConcurrentDictionary<
TextureContentIdentity, byte> _cachedTextureIdentities = new();
internal readonly record struct TextureContentIdentity(
ulong Address,
uint Width,
uint Height,
uint Format,
uint NumberType,
uint DstSelect,
uint TileMode,
uint Pitch,
GuestSampler Sampler);
// Guest memory handle for render-thread self-healing: when a draw whose
// texel copy was skipped misses the texture cache (eviction, cache
// clear, or any other race), the presenter re-reads the texels itself
// instead of showing a fallback pattern.
private static volatile SharpEmu.HLE.ICpuMemory? _guestMemory;
internal static void AttachGuestMemory(SharpEmu.HLE.ICpuMemory memory) =>
_guestMemory = memory;
internal static bool IsTextureContentCached(in TextureContentIdentity identity) =>
_cachedTextureIdentities.ContainsKey(identity);
private static void MarkTextureContentCached(in TextureContentIdentity identity) =>
_cachedTextureIdentities.TryAdd(identity, 0);
private static void UnmarkTextureContentCached(in TextureContentIdentity identity) =>
_cachedTextureIdentities.TryRemove(identity, out _);
private static void ClearCachedTextureIdentities() =>
_cachedTextureIdentities.Clear();
internal static bool IsGuestImageAvailable(
ulong address,
uint format,
uint numberType)
{
var guestFormat = GetGuestTextureFormat(format, numberType);
if (address == 0 || guestFormat == 0)
{
return false;
}
lock (_gate)
{
return _availableGuestImages.TryGetValue(address, out var availableFormat) &&
availableFormat == guestFormat;
}
}
// Display buffers registered through sceVideoOutRegisterBuffers remain
// valid flip targets even before AGC has rendered into them.
internal static void RegisterKnownDisplayBuffer(ulong address, uint guestFormat)
{
if (address == 0 || guestFormat == 0)
{
return;
}
lock (_gate)
{
_availableGuestImages[address] = guestFormat;
}
}
internal static bool IsGpuGuestImageAvailable(
ulong address,
uint format,
uint numberType) =>
IsGuestImageAvailable(address, format, numberType);
/// <summary>
/// Returns whether a storage image already exists on the presenter or an
/// earlier queued dispatch owns its one-time guest-memory initialization.
/// This is intentionally separate from <see cref="IsGuestImageAvailable"/>:
/// a pending image may skip a duplicate upload but is not yet safe for a
/// flip/presentation lookup.
/// </summary>
internal static bool IsGuestImageUploadKnown(
ulong address,
uint format,
uint numberType)
{
var guestFormat = GetGuestTextureFormat(format, numberType);
if (address == 0 || guestFormat == 0)
{
return false;
}
lock (_gate)
{
return _availableGuestImages.TryGetValue(address, out var availableFormat) &&
availableFormat == guestFormat ||
_pendingGuestImageUploads.ContainsKey((address, guestFormat));
}
}
public static bool TrySubmitGuestImageBlit(
ulong sourceAddress,
uint sourceWidth,
uint sourceHeight,
uint sourceFormat,
uint sourceNumberType,
ulong destinationAddress,
uint destinationWidth,
uint destinationHeight,
uint destinationFormat,
uint destinationNumberType)
{
if (sourceAddress == 0 ||
destinationAddress == 0 ||
sourceWidth == 0 ||
sourceHeight == 0 ||
destinationWidth == 0 ||
destinationHeight == 0 ||
!TryGetCopyFragmentShader(out var fragmentSpirv))
{
return false;
}
lock (_gate)
{
if (_closed ||
!_availableGuestImages.ContainsKey(sourceAddress) ||
GetGuestTextureFormat(destinationFormat, 0) == 0)
{
return false;
}
}
SubmitOffscreenTranslatedDraw(
fragmentSpirv,
[
new GuestDrawTexture(
sourceAddress,
sourceWidth,
sourceHeight,
sourceFormat,
sourceNumberType,
[],
IsFallback: false,
IsStorage: false),
],
[],
attributeCount: 1,
new GuestRenderTarget(
destinationAddress,
destinationWidth,
destinationHeight,
destinationFormat,
destinationNumberType));
return true;
}
private static bool TryGetCopyFragmentShader(out byte[] spirv)
{
lock (_gate)
{
if (_copyFragmentSpirv is not null)
{
spirv = _copyFragmentSpirv;
return true;
}
}
spirv = SpirvFixedShaders.CreateCopyFragment();
lock (_gate)
{
_copyFragmentSpirv ??= spirv;
spirv = _copyFragmentSpirv;
}
return true;
}
private static uint GetGuestTextureFormat(uint format, uint numberType) =>
IsKnownGuestTextureFormat(format)
? 0x8000_0000u | ((format & 0x1FFu) << 8) | (numberType & 0xFFu)
: 0;
internal static bool TryDecodeRenderTargetFormat(
uint dataFormat,
uint numberType,
out VulkanRenderTargetFormat result)
{
var format = (dataFormat, numberType) switch
{
(4, 4) => Format.R32Uint,
(4, 5) => Format.R32Sint,
(4, 7) => Format.R32Sfloat,
(5, 4) => Format.R16G16Uint,
(5, 5) => Format.R16G16Sint,
(5, 7) => Format.R16G16Sfloat,
(6, 7) or (7, 7) => Format.B10G11R11UfloatPack32,
(9, _) => Format.A2R10G10B10UnormPack32,
(10, 4) => Format.R8G8B8A8Uint,
(10, 5) => Format.R8G8B8A8Sint,
(10, 9) => Format.R8G8B8A8Srgb,
(10, _) => Format.R8G8B8A8Unorm,
(11, 7) => Format.R32G32Sfloat,
(12, 4) => Format.R16G16B16A16Uint,
(12, 5) => Format.R16G16B16A16Sint,
(12, 7) => Format.R16G16B16A16Sfloat,
(13, 7) or (14, 7) => Format.R32G32B32A32Sfloat,
(20, 0) => Format.R32Uint,
(29, 0) or (4, 0) => Format.R32Sfloat,
(1, 0) or (36, 0) => Format.R8Unorm,
(49, 0) => Format.R8Uint,
(3, 0) => Format.R8G8Unorm,
(5, 0) => Format.R16G16Unorm,
(7, 0) => Format.B10G11R11UfloatPack32,
(12, 0) => Format.R16G16B16A16Unorm,
(13, 0) or (14, 0) => Format.R32G32B32A32Sfloat,
(22, 0) or (71, 0) => Format.R16G16B16A16Sfloat,
(56, 0) or (62, 0) or (64, 0) => Format.R8G8B8A8Unorm,
(75, 0) => Format.R32G32Sfloat,
_ => Format.Undefined,
};
if (format == Format.Undefined)
{
result = default;
return false;
}
var outputKind = format switch
{
Format.R8Uint or Format.R32Uint or Format.R16G16Uint or
Format.R8G8B8A8Uint or Format.R16G16B16A16Uint => Gen5PixelOutputKind.Uint,
Format.R32Sint or Format.R16G16Sint or Format.R8G8B8A8Sint or
Format.R16G16B16A16Sint => Gen5PixelOutputKind.Sint,
_ => Gen5PixelOutputKind.Float,
};
result = new VulkanRenderTargetFormat(format, outputKind);
return true;
}
private static bool IsKnownGuestTextureFormat(uint format) =>
format is >= 1 and <= 19 or 34 or >= 169 and <= 182;
private static byte[] CreateBlackFrame(uint width, uint height)
{
if (width == 0 || height == 0 || width > 8192 || height > 8192)
{
width = 1;
height = 1;
}
var pixels = GC.AllocateUninitializedArray<byte>(checked((int)(width * height * 4)));
pixels.AsSpan().Clear();
for (var offset = 3; offset < pixels.Length; offset += 4)
{
pixels[offset] = 0xFF;
}
return pixels;
}
private static void StartPresenterLocked()
{
if (_hostSurface is null && HostMainThread.IsAvailable)
{
// AppKit (and therefore GLFW) traps when touched off the process
// main thread on macOS, so hand the whole window loop to the
// main-thread pump the CLI parked for us. _thread only marks the
// presenter as running; Run() clears it on exit either way.
_thread = Thread.CurrentThread;
HostMainThread.SetShutdownRequestHandler(RequestClose);
HostMainThread.Post(Run);
return;
}
_thread = new Thread(Run)
{
IsBackground = true,
Name = "SharpEmu Vulkan VideoOut",
};
_thread.Start();
}
/// <summary>
/// Asks a running presenter to close its window; used at emulator
/// shutdown so a main-thread-hosted window loop returns to the pump.
/// </summary>
public static void RequestClose()
{
Volatile.Write(ref _presenterCloseRequested, true);
}
/// <summary>
/// GLFW resolves Vulkan with dlopen("libvulkan.1.dylib"), which cannot
/// find the app-local MoltenVK on macOS (Homebrew's Vulkan libraries are
/// arm64-only and this is an x86-64 process). GLFW 3.4 accepts the
/// loader entry point directly instead, so hand it MoltenVK's
/// vkGetInstanceProcAddr before any window exists.
/// </summary>
private static unsafe void InitializeMacVulkanLoader()
{
if (!OperatingSystem.IsMacOS())
{
return;
}
try
{
nint vulkan = 0;
foreach (var candidate in new[]
{
Path.Combine(AppContext.BaseDirectory, "libvulkan.1.dylib"),
Path.Combine(AppContext.BaseDirectory, "libMoltenVK.dylib"),
"libvulkan.1.dylib",
"libMoltenVK.dylib",
})
{
if (System.Runtime.InteropServices.NativeLibrary.TryLoad(candidate, out vulkan))
{
break;
}
}
if (vulkan == 0 ||
!System.Runtime.InteropServices.NativeLibrary.TryGetExport(
vulkan, "vkGetInstanceProcAddr", out var procAddr))
{
Console.Error.WriteLine(
"[LOADER][WARN] No Vulkan loader for GLFW; place a universal libMoltenVK.dylib " +
"next to SharpEmu as libvulkan.1.dylib.");
return;
}
var glfw = System.Runtime.InteropServices.NativeLibrary.Load(
Path.Combine(AppContext.BaseDirectory, "libglfw.3.dylib"));
var initVulkanLoader = (delegate* unmanaged<nint, void>)
System.Runtime.InteropServices.NativeLibrary.GetExport(glfw, "glfwInitVulkanLoader");
initVulkanLoader(procAddr);
Console.Error.WriteLine("[LOADER][INFO] GLFW Vulkan loader wired to MoltenVK.");
}
catch (Exception exception)
{
Console.Error.WriteLine($"[LOADER][WARN] GLFW Vulkan loader setup failed: {exception.Message}");
}
}
private static unsafe void PreferX11OnLinuxWayland()
{
if (!OperatingSystem.IsLinux() ||
string.Equals(
Environment.GetEnvironmentVariable("SHARPEMU_ENABLE_WAYLAND"),
"1",
StringComparison.Ordinal))
{
return;
}
if (string.IsNullOrEmpty(Environment.GetEnvironmentVariable("WAYLAND_DISPLAY")))
{
return;
}
if (string.IsNullOrEmpty(Environment.GetEnvironmentVariable("DISPLAY")))
{
Console.Error.WriteLine(
"[LOADER][WARN] Wayland session without an X server (DISPLAY unset); " +
"cannot steer GLFW to XWayland. Set SHARPEMU_ENABLE_WAYLAND=1 to use native Wayland.");
return;
}
if (!TryLoadGlfw(out var glfw))
{
return;
}
try
{
var initHint = (delegate* unmanaged<int, int, void>)
System.Runtime.InteropServices.NativeLibrary.GetExport(glfw, "glfwInitHint");
initHint(GlfwPlatformHint, GlfwPlatformX11);
Console.Error.WriteLine(
"[LOADER][INFO] Wayland session detected; requested GLFW X11/XWayland backend.");
}
catch (Exception exception)
{
Console.Error.WriteLine(
$"[LOADER][WARN] Could not set GLFW X11 platform hint: {exception.Message}");
}
}
private static unsafe void LogGlfwPlatformInUse()
{
if (OperatingSystem.IsWindows() || !TryLoadGlfw(out var glfw))
{
return;
}
try
{
var getPlatform = (delegate* unmanaged<int>)
System.Runtime.InteropServices.NativeLibrary.GetExport(glfw, "glfwGetPlatform");
var platform = getPlatform();
var label = platform switch
{
GlfwPlatformWin32 => "Win32",
GlfwPlatformCocoa => "Cocoa",
GlfwPlatformWayland => "Wayland",
GlfwPlatformX11 => "X11",
GlfwPlatformNull => "Null",
_ => $"0x{platform:X}",
};
Console.Error.WriteLine($"[LOADER][INFO] GLFW windowing platform in use: {label}");
}
catch (Exception exception)
{
Console.Error.WriteLine($"[LOADER][WARN] Could not query GLFW platform: {exception.Message}");
}
}
private static bool TryLoadGlfw(out nint handle)
{
var name = OperatingSystem.IsMacOS() ? "libglfw.3.dylib" : "libglfw.so.3";
return System.Runtime.InteropServices.NativeLibrary.TryLoad(
Path.Combine(AppContext.BaseDirectory, name), out handle) ||
System.Runtime.InteropServices.NativeLibrary.TryLoad(name, out handle);
}
private static void Run()
{
uint width;
uint height;
VulkanHostSurface? hostSurface;
lock (_gate)
{
width = _windowWidth == 0 ? _latestPresentation?.Width ?? 1280 : _windowWidth;
height = _windowHeight == 0 ? _latestPresentation?.Height ?? 720 : _windowHeight;
hostSurface = _hostSurface;
}
if (hostSurface is null)
{
PreferX11OnLinuxWayland();
InitializeMacVulkanLoader();
}
try
{
using var presenter = new Presenter(width, height, hostSurface);
presenter.Run();
}
catch (Exception exception)
{
Console.Error.WriteLine($"[LOADER][ERROR] Vulkan VideoOut presenter failed: {exception}");
}
finally
{
lock (_gate)
{
_closed = true;
_thread = null;
if (_hostSurfacePendingDetach is not null &&
ReferenceEquals(_hostSurface, _hostSurfacePendingDetach))
{
_hostSurface = null;
_hostSurfacePendingDetach = null;
}
System.Threading.Monitor.PulseAll(_gate);
}
}
}
private static bool TryTakePresentation(long presentedSequence, out Presentation presentation)
{
lock (_gate)
{
// Guest flips are retained in submission order. The renderer is
// deliberately allowed to lag a frame or two behind the guest
// while it drains expensive work, so use the first completed flip
// rather than repeatedly asking only for the newest one.
while (_pendingGuestImagePresentations.Count > 0 &&
_pendingGuestImagePresentations.Peek().Sequence <= presentedSequence)
{
_pendingGuestImagePresentations.Dequeue();
}
if (_pendingGuestImagePresentations.Count > 0)
{
var pending = _pendingGuestImagePresentations.Peek();
if (IsGuestWorkCompletedLocked(pending.RequiredGuestWorkSequence))
{
presentation = _pendingGuestImagePresentations.Dequeue();
TryReplaceWithBinkFrame(ref presentation);
return true;
}
presentation = default;
return false;
}
if (_latestPresentation is not { } latest ||
latest.Sequence == presentedSequence ||
!IsGuestWorkCompletedLocked(latest.RequiredGuestWorkSequence))
{
if (_latestPresentation is { } rej &&
rej.GuestImageAddress != 0 &&
rej.Sequence != presentedSequence &&
_tracedGuestImagePresentRejections.Add(rej.Sequence))
{
var reason = rej.Sequence == presentedSequence
? "already-presented(seq==presented)"
: !IsGuestWorkCompletedLocked(rej.RequiredGuestWorkSequence)
? $"work-not-done(req={rej.RequiredGuestWorkSequence}>" +
$"contiguous_done={_completedGuestWorkSequence})"
: "unknown";
Console.Error.WriteLine(
$"[LOADER][WARN] vk.guest_present_rejected addr=0x{rej.GuestImageAddress:X16} " +
$"seq={rej.Sequence} presentedSeq={presentedSequence} reason={reason}");
}
presentation = default;
return false;
}
presentation = latest;
TryReplaceWithBinkFrame(ref presentation);
return true;
}
}
private static void TryReplaceWithBinkFrame(ref Presentation presentation)
{
if (!Bink2MovieBridge.TryDecodeNextFrame(out var pixels, out var width, out var height))
{
return;
}
presentation = new Presentation(
pixels,
width,
height,
presentation.Sequence,
GuestDrawKind.None,
TranslatedDraw: null,
presentation.RequiredGuestWorkSequence,
IsSplash: false);
}
private static readonly HashSet<long> _tracedGuestImagePresentRejections = new();
private static bool HasPendingGuestPresentation(long presentedSequence)
{
lock (_gate)
{
return _pendingGuestImagePresentations.Count > 0 ||
_latestPresentation is { } latest && latest.Sequence > presentedSequence;
}
}
private static long EnqueueGuestWorkLocked(object work)
{
var payloadBytes = GetGuestWorkPayloadBytes(work);
var backpressureLogged = false;
// Work executed by the render-thread consumer can enqueue an ordered
// same-queue completion marker. Blocking that consumer on the normal
// producer backpressure limit deadlocks a full queue: no other thread
// can drain an item to make room for the marker. The consumer has
// already removed the current item, and each immediate follow-up is
// bounded by that item, so admitting it cannot cause unbounded growth.
while (!_enqueueAsImmediateQueueFollowup &&
!_closed &&
_thread is not null &&
(_pendingGuestWorkCount >= _maxPendingGuestWorkItems ||
// Always admit one item when no payload is outstanding, even
// when that single item exceeds the configured budget. This
// avoids an impossible wait while still bounding the normal
// multi-item backlog.
(_pendingGuestWorkBytes != 0 &&
payloadBytes > _maxPendingGuestWorkBytes -
Math.Min(_pendingGuestWorkBytes, _maxPendingGuestWorkBytes))))
{
if (!backpressureLogged)
{
backpressureLogged = true;
var traceCount = Interlocked.Increment(
ref _guestQueueBackpressureTraceCount);
if (traceCount <= 16 || (traceCount & (traceCount - 1)) == 0)
{
Console.Error.WriteLine(
$"[LOADER][TRACE] vk.guest_queue_backpressure " +
$"count={traceCount} " +
$"queued={_pendingGuestWorkCount} " +
$"logical_queues={_pendingGuestWorkByQueue.Count} " +
$"retained_mb={_pendingGuestWorkBytes / (1024 * 1024)} " +
$"incoming_mb={payloadBytes / (1024 * 1024)} " +
$"budget_mb={_maxPendingGuestWorkBytes / (1024 * 1024)} " +
$"work={work.GetType().Name}" +
GetGuestWorkPayloadBreakdown(work));
}
}
System.Threading.Monitor.Wait(_gate);
}
if (_closed)
{
return 0;
}
var queue = _submittingGuestQueue ?? VulkanGuestQueueIdentity.Default;
var sequence = ++_enqueuedGuestWorkSequence;
_lastEnqueuedGuestWorkByQueue[queue.Name] = sequence;
var requiredSequence = GetGuestWorkDependencyLocked(work);
if (!_pendingGuestWorkByQueue.TryGetValue(queue.Name, out var pendingQueue))
{
pendingQueue = new LinkedList<PendingGuestWork>();
_pendingGuestWorkByQueue.Add(queue.Name, pendingQueue);
_pendingGuestQueueSchedule.Add(queue.Name);
}
var pending = new PendingGuestWork(
work,
payloadBytes,
sequence,
requiredSequence,
System.Diagnostics.Stopwatch.GetTimestamp(),
queue);
if (_traceGuestWorkCompletion)
{
Console.Error.WriteLine(
$"[LOADER][TRACE] vk.guest_work_enqueue sequence={sequence} " +
$"required={requiredSequence} queue={queue.Name} " +
$"immediate={_enqueueAsImmediateQueueFollowup} " +
$"work={work.GetType().Name}");
}
if (_enqueueAsImmediateQueueFollowup &&
_immediateFollowupTail is { List: not null } tail &&
ReferenceEquals(tail.List, pendingQueue))
{
_immediateFollowupTail = pendingQueue.AddAfter(tail, pending);
}
else if (_enqueueAsImmediateQueueFollowup)
{
_immediateFollowupTail = pendingQueue.AddFirst(pending);
}
else
{
pendingQueue.AddLast(pending);
}
RecordGuestImageWritersLocked(work, sequence);
_pendingGuestWorkCount++;
_pendingGuestWorkBytes = SaturatingAdd(_pendingGuestWorkBytes, payloadBytes);
// Wake the embedded render loop parked in WaitForRenderWork; without a
// pulse it only notices new work when its timed wait expires.
System.Threading.Monitor.PulseAll(_gate);
return sequence;
}
private static long GetGuestWorkDependencyLocked(object work)
{
IReadOnlyList<GuestDrawTexture> textures = work switch
{
VulkanOffscreenGuestDraw draw => draw.Draw.Textures,
VulkanComputeGuestDispatch compute => compute.Textures,
_ => Array.Empty<GuestDrawTexture>(),
};
var required = 0L;
foreach (var texture in textures)
{
if (!texture.IsStorage ||
texture.Address == 0 ||
texture.RgbaPixels.Length != 0)
{
continue;
}
var format = GetGuestTextureFormat(texture.Format, texture.NumberType);
if (_pendingGuestImageUploads.TryGetValue(
(texture.Address, format),
out var pendingUpload))
{
required = Math.Max(required, pendingUpload.OwnerSequence);
}
}
return required;
}
private static void RecordGuestImageWritersLocked(object work, long sequence)
{
static IEnumerable<ulong> StorageAddresses(
IReadOnlyList<GuestDrawTexture> textures) =>
textures
.Where(static texture => texture.IsStorage && texture.Address != 0)
.Select(static texture => texture.Address);
IEnumerable<ulong> addresses = work switch
{
VulkanOffscreenGuestDraw draw =>
(draw.PublishTarget
? draw.Targets
.Where(static target => target.Address != 0)
.Select(static target => target.Address)
: Enumerable.Empty<ulong>())
.Concat(StorageAddresses(draw.Draw.Textures)),
VulkanComputeGuestDispatch compute => StorageAddresses(compute.Textures),
VulkanGuestImageWrite imageWrite when imageWrite.Address != 0 =>
new[] { imageWrite.Address },
_ => Array.Empty<ulong>(),
};
foreach (var address in addresses.Distinct())
{
_guestImageWorkSequences[address] = sequence;
}
}
private static bool TryTakeGuestWork(out PendingGuestWork work)
{
lock (_gate)
{
var queuesToProbe = _pendingGuestQueueSchedule.Count;
while (_pendingGuestQueueSchedule.Count > 0 && queuesToProbe > 0)
{
if (_pendingGuestQueueCursor >= _pendingGuestQueueSchedule.Count)
{
_pendingGuestQueueCursor = 0;
}
var queueName = _pendingGuestQueueSchedule[_pendingGuestQueueCursor];
if (!_pendingGuestWorkByQueue.TryGetValue(queueName, out var queue) ||
queue.First is not { } first)
{
_pendingGuestWorkByQueue.Remove(queueName);
_pendingGuestQueueSchedule.RemoveAt(_pendingGuestQueueCursor);
queuesToProbe = Math.Min(
queuesToProbe,
_pendingGuestQueueSchedule.Count);
continue;
}
work = first.Value;
if (!IsGuestWorkCompletedLocked(work.RequiredSequence))
{
_pendingGuestQueueCursor =
(_pendingGuestQueueCursor + 1) % _pendingGuestQueueSchedule.Count;
queuesToProbe--;
continue;
}
queue.RemoveFirst();
_pendingGuestWorkCount--;
if (queue.Count == 0)
{
_pendingGuestWorkByQueue.Remove(queueName);
_pendingGuestQueueSchedule.RemoveAt(_pendingGuestQueueCursor);
}
else
{
_pendingGuestQueueCursor =
(_pendingGuestQueueCursor + 1) % _pendingGuestQueueSchedule.Count;
}
return true;
}
work = default;
return false;
}
}
private static void CompleteGuestWork(in PendingGuestWork pending)
{
SharpEmu.HLE.GuestImageWriteTracker.FlushPendingDiagnostics();
lock (_gate)
{
if (_traceGuestWorkCompletion)
{
Console.Error.WriteLine(
$"[LOADER][TRACE] vk.guest_work_complete_enter " +
$"sequence={pending.Sequence} work={pending.Work.GetType().Name} " +
$"contiguous_completed={_completedGuestWorkSequence} " +
$"out_of_order={_completedGuestWorkOutOfOrder.Count}");
}
_pendingGuestWorkBytes = pending.PayloadBytes >= _pendingGuestWorkBytes
? 0
: _pendingGuestWorkBytes - pending.PayloadBytes;
ReleasePendingGuestImageUploadsLocked(pending.Work);
if (pending.Sequence == _completedGuestWorkSequence + 1)
{
_completedGuestWorkSequence = pending.Sequence;
while (_completedGuestWorkOutOfOrder.Remove(
_completedGuestWorkSequence + 1))
{
_completedGuestWorkSequence++;
}
}
else if (pending.Sequence > _completedGuestWorkSequence)
{
// Debug.Assert calls are compiled out of Release builds, so
// never put the state mutation inside its argument. Doing so
// discarded every out-of-order completion in normal runs and
// left the first immediate follow-up as a permanent sequence
// hole, blocking all later CPU-visible GPU waits.
var added = _completedGuestWorkOutOfOrder.Add(pending.Sequence);
System.Diagnostics.Debug.Assert(
added,
"A guest work sequence must complete exactly once.");
}
System.Threading.Monitor.PulseAll(_gate);
if (_traceGuestWorkCompletion)
{
Console.Error.WriteLine(
$"[LOADER][TRACE] vk.guest_work_complete_exit " +
$"sequence={pending.Sequence} contiguous_completed={_completedGuestWorkSequence} " +
$"out_of_order={_completedGuestWorkOutOfOrder.Count}");
}
}
}
private static ulong GetGuestWorkPayloadBytes(object work) => work switch
{
VulkanComputeGuestDispatch compute => SaturatingAdd(
GetTexturePayloadBytes(compute.Textures),
GetGlobalBufferPayloadBytes(compute.GlobalMemoryBuffers)),
VulkanOffscreenGuestDraw offscreen => GetDrawPayloadBytes(offscreen.Draw),
VulkanGuestImageWrite { Pixels: { } pixels } => (ulong)pixels.LongLength,
_ => 0,
};
private static string GetGuestWorkPayloadBreakdown(object work)
{
static ulong SumTextures(IReadOnlyList<GuestDrawTexture> textures) =>
GetTexturePayloadBytes(textures) / (1024 * 1024);
static ulong SumGlobals(IReadOnlyList<GuestMemoryBuffer> buffers) =>
GetGlobalBufferPayloadBytes(buffers) / (1024 * 1024);
return work switch
{
VulkanOffscreenGuestDraw offscreen =>
$" textures_mb={SumTextures(offscreen.Draw.Textures)}" +
$" globals_mb={SumGlobals(offscreen.Draw.GlobalMemoryBuffers)}" +
$" vertex_mb={offscreen.Draw.VertexBuffers.Aggregate(0UL, static (sum, buffer) => SaturatingAdd(sum, (ulong)buffer.Data.LongLength)) / (1024 * 1024)}" +
$" index_mb={(ulong)(offscreen.Draw.IndexBuffer?.Data.LongLength ?? 0) / (1024 * 1024)}" +
$" vertex_lengths=[{string.Join(',', offscreen.Draw.VertexBuffers.Select(static buffer => $"{buffer.Length}/{buffer.Data.LongLength}:s{buffer.Stride}:o{buffer.OffsetBytes}"))}]" +
$" global_lengths=[{string.Join(',', offscreen.Draw.GlobalMemoryBuffers.Select(static buffer => buffer.Length))}]",
VulkanComputeGuestDispatch compute =>
$" textures_mb={SumTextures(compute.Textures)}" +
$" globals_mb={SumGlobals(compute.GlobalMemoryBuffers)}" +
$" global_lengths=[{string.Join(',', compute.GlobalMemoryBuffers.Select(static buffer => buffer.Length))}]",
_ => string.Empty,
};
}
private static ulong GetDrawPayloadBytes(VulkanTranslatedGuestDraw draw)
{
var bytes = GetTexturePayloadBytes(draw.Textures);
bytes = SaturatingAdd(bytes, GetGlobalBufferPayloadBytes(draw.GlobalMemoryBuffers));
var uniqueVertexData = new HashSet<byte[]>(
System.Collections.Generic.ReferenceEqualityComparer.Instance);
foreach (var vertex in draw.VertexBuffers)
{
if (uniqueVertexData.Add(vertex.Data))
{
bytes = SaturatingAdd(bytes, (ulong)vertex.Data.LongLength);
}
}
if (draw.IndexBuffer is { } index)
{
bytes = SaturatingAdd(bytes, (ulong)index.Data.LongLength);
}
return bytes;
}
private static ulong GetTexturePayloadBytes(
IReadOnlyList<GuestDrawTexture> textures)
{
var bytes = 0UL;
foreach (var texture in textures)
{
bytes = SaturatingAdd(bytes, (ulong)texture.RgbaPixels.LongLength);
}
return bytes;
}
private static ulong GetGlobalBufferPayloadBytes(
IReadOnlyList<GuestMemoryBuffer> buffers)
{
var bytes = 0UL;
foreach (var buffer in buffers)
{
bytes = SaturatingAdd(bytes, (ulong)buffer.Data.LongLength);
}
return bytes;
}
private static ulong SaturatingAdd(ulong left, ulong right) =>
ulong.MaxValue - left < right ? ulong.MaxValue : left + right;
private static void ReleasePendingGuestImageUploadsLocked(object work)
{
if (work is not VulkanComputeGuestDispatch compute)
{
return;
}
foreach (var key in GetStorageImageUploadKeys(compute.Textures))
{
if (!_pendingGuestImageUploads.TryGetValue(key, out var pendingUpload))
{
continue;
}
if (pendingUpload.Count <= 1)
{
_pendingGuestImageUploads.Remove(key);
}
else
{
_pendingGuestImageUploads[key] = pendingUpload with
{
Count = pendingUpload.Count - 1,
};
}
}
}
private static HashSet<(ulong Address, uint Format)> GetStorageImageUploadKeys(
IReadOnlyList<GuestDrawTexture> textures)
{
var keys = new HashSet<(ulong Address, uint Format)>();
foreach (var texture in textures)
{
if (!texture.IsStorage || texture.Address == 0)
{
continue;
}
var format = GetGuestTextureFormat(texture.Format, texture.NumberType);
if (format != 0)
{
keys.Add((texture.Address, format));
}
}
return keys;
}
internal static bool ShouldAttachGuestDepth(
GuestDepthTarget? target,
GuestDepthState state) =>
target is not null &&
(state.TestEnable || state.WriteEnable || state.ClearEnable);
private readonly record struct Presentation(
byte[]? Pixels,
uint Width,
uint Height,
long Sequence,
GuestDrawKind DrawKind,
VulkanTranslatedGuestDraw? TranslatedDraw,
long RequiredGuestWorkSequence,
bool IsSplash,
ulong GuestImageAddress = 0,
long GuestImageVersion = 0);
private sealed class Presenter : IDisposable
{
private const string FullscreenBarycentricVertexSpirv =
"AwIjBwAAAQALAAgAMgAAAAAAAAARAAIAAQAAAAsABgABAAAAR0xTTC5zdGQuNDUwAAAAAA4AAwAAAAAAAQAAAA8ACAAAAAAABAAAAG1haW4AAAAADQAAABoAAAApAAAAAwADAAIAAADCAQAABQAEAAQAAABtYWluAAAAAAUABgALAAAAZ2xfUGVyVmVydGV4AAAAAAYABgALAAAAAAAAAGdsX1Bvc2l0aW9uAAYABwALAAAAAQAAAGdsX1BvaW50U2l6ZQAAAAAGAAcACwAAAAIAAABnbF9DbGlwRGlzdGFuY2UABgAHAAsAAAADAAAAZ2xfQ3VsbERpc3RhbmNlAAUAAwANAAAAAAAAAAUABgAaAAAAZ2xfVmVydGV4SW5kZXgAAAUABQAdAAAAaW5kZXhhYmxlAAAABQAFACkAAABiYXJ5Y2VudHJpYwAFAAUALwAAAGluZGV4YWJsZQAAAEcAAwALAAAAAgAAAEgABQALAAAAAAAAAAsAAAAAAAAASAAFAAsAAAABAAAACwAAAAEAAABIAAUACwAAAAIAAAALAAAAAwAAAEgABQALAAAAAwAAAAsAAAAEAAAARwAEABoAAAALAAAAKgAAAEcABAApAAAAHgAAAAAAAAATAAIAAgAAACEAAwADAAAAAgAAABYAAwAGAAAAIAAAABcABAAHAAAABgAAAAQAAAAVAAQACAAAACAAAAAAAAAAKwAEAAgAAAAJAAAAAQAAABwABAAKAAAABgAAAAkAAAAeAAYACwAAAAcAAAAGAAAACgAAAAoAAAAgAAQADAAAAAMAAAALAAAAOwAEAAwAAAANAAAAAwAAABUABAAOAAAAIAAAAAEAAAArAAQADgAAAA8AAAAAAAAAFwAEABAAAAAGAAAAAgAAACsABAAIAAAAEQAAAAMAAAAcAAQAEgAAABAAAAARAAAAKwAEAAYAAAATAAAAAACAvywABQAQAAAAFAAAABMAAAATAAAAKwAEAAYAAAAVAAAAAABAQCwABQAQAAAAFgAAABUAAAATAAAALAAFABAAAAAXAAAAEwAAABUAAAAsAAYAEgAAABgAAAAUAAAAFgAAABcAAAAgAAQAGQAAAAEAAAAOAAAAOwAEABkAAAAaAAAAAQAAACAABAAcAAAABwAAABIAAAAgAAQAHgAAAAcAAAAQAAAAKwAEAAYAAAAhAAAAAAAAACsABAAGAAAAIgAAAAAAgD8gAAQAJgAAAAMAAAAHAAAAIAAEACgAAAADAAAAEAAAADsABAAoAAAAKQAAAAMAAAAsAAUAEAAAACoAAAAiAAAAIQAAACwABQAQAAAAKwAAACEAAAAiAAAALAAFABAAAAAsAAAAIQAAACEAAAAsAAYAEgAAAC0AAAAqAAAAKwAAACwAAAA2AAUAAgAAAAQAAAAAAAAAAwAAAPgAAgAFAAAAOwAEABwAAAAdAAAABwAAADsABAAcAAAALwAAAAcAAAA9AAQADgAAABsAAAAaAAAAPgADAB0AAAAYAAAAQQAFAB4AAAAfAAAAHQAAABsAAAA9AAQAEAAAACAAAAAfAAAAUQAFAAYAAAAjAAAAIAAAAAAAAABRAAUABgAAACQAAAAgAAAAAQAAAFAABwAHAAAAJQAAACMAAAAkAAAAIQAAACIAAABBAAUAJgAAACcAAAANAAAADwAAAD4AAwAnAAAAJQAAAD0ABAAOAAAALgAAABoAAAA+AAMALwAAAC0AAABBAAUAHgAAADAAAAAvAAAALgAAAD0ABAAQAAAAMQAAADAAAAA+AAMAKQAAADEAAAD9AAEAOAABAA==";
private const string FullscreenBarycentricFragmentSpirv =
"AwIjBwAAAQALAAgAEgAAAAAAAAARAAIAAQAAAAsABgABAAAAR0xTTC5zdGQuNDUwAAAAAA4AAwAAAAAAAQAAAA8ABwAEAAAABAAAAG1haW4AAAAACQAAAAwAAAAQAAMABAAAAAcAAAADAAMAAgAAAMIBAAAFAAQABAAAAG1haW4AAAAABQAFAAkAAABvdXRDb2xvcgAAAAAFAAUADAAAAGJhcnljZW50cmljAEcABAAJAAAAHgAAAAAAAABHAAQADAAAAB4AAAAAAAAAEwACAAIAAAAhAAMAAwAAAAIAAAAWAAMABgAAACAAAAAXAAQABwAAAAYAAAAEAAAAIAAEAAgAAAADAAAABwAAADsABAAIAAAACQAAAAMAAAAXAAQACgAAAAYAAAACAAAAIAAEAAsAAAABAAAACgAAADsABAALAAAADAAAAAEAAAArAAQABgAAAA4AAAAAAAAANgAFAAIAAAAEAAAAAAAAAAMAAAD4AAIABQAAAD0ABAAKAAAADQAAAAwAAABRAAUABgAAAA8AAAANAAAAAAAAAFEABQAGAAAAEAAAAA0AAAABAAAAUAAHAAcAAAARAAAADwAAABAAAAAOAAAADgAAAD4AAwAJAAAAEQAAAP0AAQA4AAEA";
private readonly IWindow? _window;
private readonly VulkanHostSurface? _hostSurface;
private int _lastHostResizeGeneration;
private bool _embeddedLoopClosed;
private const int MaxInFlightGuestSubmissions = 8;
private Vk _vk = null!;
private KhrSurface _surfaceApi = null!;
private KhrSwapchain _swapchainApi = null!;
private delegate* unmanaged<Device, DebugUtilsObjectNameInfoEXT*, Result> _setDebugUtilsObjectName;
private delegate* unmanaged<CommandBuffer, DebugUtilsLabelEXT*, void> _cmdBeginDebugUtilsLabel;
private delegate* unmanaged<CommandBuffer, void> _cmdEndDebugUtilsLabel;
private Instance _instance;
private SurfaceKHR _surface;
private DebugUtilsMessengerEXT _debugMessenger;
private ExtDebugUtils? _debugUtils;
private PhysicalDevice _physicalDevice;
private uint _maxComputeWorkGroupCountX;
private uint _maxComputeWorkGroupCountY;
private uint _maxComputeWorkGroupCountZ;
private uint _maxComputeWorkGroupSizeX;
private uint _maxComputeWorkGroupSizeY;
private uint _maxComputeWorkGroupSizeZ;
private uint _maxComputeWorkGroupInvocations;
private ulong _minStorageBufferOffsetAlignment = 1;
private bool _supportsIndependentBlend;
private uint _maxColorAttachments;
private Device _device;
private PipelineCache _pipelineCache;
private string? _pipelineCachePath;
private bool _pipelineCacheDirty;
private long _lastPipelineCacheSaveTick;
private Queue _queue;
private uint _queueFamilyIndex;
private SwapchainKHR _swapchain;
private Image[] _swapchainImages = [];
private ImageView[] _swapchainImageViews = [];
private Framebuffer[] _framebuffers = [];
private bool[] _imageInitialized = [];
private Format _swapchainFormat;
private Extent2D _extent;
private RenderPass _renderPass;
private PipelineLayout _pipelineLayout;
private Pipeline _barycentricPipeline;
private CommandPool _commandPool;
private CommandBuffer _commandBuffer;
private CommandBuffer _presentationCommandBuffer;
// Presentation runs with multiple frames in flight: each frame slot
// owns a command buffer, an acquire semaphore and a fence, so the CPU
// can record frame N+1 while the GPU still executes frame N. The
// per-present vkQueueWaitIdle this replaces made CPU and GPU costs
// strictly additive. Render-finished semaphores are per swapchain
// image because the presentation engine may still wait on them after
// the frame fence has signaled.
private const int MaxFramesInFlight = 2;
private CommandBuffer[] _frameCommandBuffers = [];
private VkSemaphore[] _frameImageAvailable = [];
private VkSemaphore[] _renderFinishedPerImage = [];
private Fence[] _frameFences = [];
private bool[] _frameFencePending = [];
private ulong[] _frameTimelines = [];
private TranslatedDrawResources?[] _frameTranslatedResources = [];
private GuestImageResource?[] _frameGuestImageVersions = [];
private int _currentFrameSlot;
// Monotonic submission/completion counters across every queue submit
// (guest batches, compute chunks and presents). Fences on a single
// queue signal in submission order, so "timeline <= completed" means
// the GPU is done with everything submitted up to that point; this
// lets evicted resources be destroyed without a queue drain.
private ulong _submitTimeline;
private ulong _completedTimeline;
private readonly Queue<(TextureResource Texture, ulong RetireTimeline)>
_deferredTextureDestroys = new();
private readonly Queue<(TranslatedDrawResources Resources, ulong RetireTimeline)>
_deferredResourceDestroys = new();
private readonly Queue<(GuestImageResource Image, ulong RetireTimeline)>
_deferredGuestImageVersionDestroys = new();
private readonly Stack<Fence> _recycledGuestFences = new();
private readonly Stack<CommandBuffer> _recycledGuestCommandBuffers = new();
private readonly List<(VkBuffer Buffer, DeviceMemory Memory)> _batchRetireBuffers = new();
private const int MaxRecycledGuestFences = 32;
private const int MaxRecycledGuestCommandBuffers = 32;
private VkBuffer _stagingBuffer;
private DeviceMemory _stagingMemory;
private ulong _stagingSize;
// Perf overlay: CPU-rasterized panel copied through per-slot staging
// buffers into one image, then blitted onto the swapchain.
private Image _overlayImage;
private DeviceMemory _overlayImageMemory;
private bool _overlayImageInitialized;
private VkBuffer[] _overlayStagingBuffers = [];
private DeviceMemory[] _overlayStagingMemory = [];
private nint[] _overlayStagingMapped = [];
private long _presentedSequence;
private long _presentNotTakenLoggedSequence = long.MinValue;
private bool _vulkanReady;
private bool _firstFramePresented;
private bool _firstHostFramePresented;
private bool _firstGuestDrawPresented;
private bool _splashPresented;
private Presentation? _lastHostSplashPresentation;
private Presentation? _pendingHostSplashReplay;
private bool _swapchainRecreateDeferred;
private bool _tracedPresentedSwapchain;
private bool _swapchainReadbackPending;
private long _presentedSwapchainCount;
private static int _guestImageDumpSequence;
private readonly System.Collections.Concurrent.ConcurrentQueue<GuestImageResource> _pendingAliasImageDumps = new();
private bool _deviceLost;
private bool _deviceLostLogged;
private int _directPresentationCount;
private readonly Dictionary<ulong, long> _presentedGuestImageTraceCounts = new();
private readonly Dictionary<ulong, GuestImageResource> _guestImages = new();
private readonly record struct GuestImageVariantKey(
ulong Address,
uint Width,
uint Height,
uint MipLevels,
uint GuestFormat,
Format Format);
// A single guest allocation may be rebound through several RT descriptors.
// Keep the inactive Vulkan images instead of destroying their contents each
// time the guest switches size or format at the same address.
private readonly Dictionary<GuestImageVariantKey, GuestImageResource>
_guestImageVariants = new();
private readonly Dictionary<long, GuestImageResource> _guestImageVersions = new();
private readonly HashSet<long> _capturedGuestFlipVersions = [];
private readonly record struct GuestDepthKey(
ulong Address,
ulong ReadAddress,
uint Width,
uint Height,
uint GuestFormat,
uint SwizzleMode);
private readonly Dictionary<GuestDepthKey, GuestDepthResource> _guestDepthImages = new();
private readonly Dictionary<GuestDepthKey, ulong> _depthOnlyColorAddresses = new();
private ulong _nextDepthOnlyColorAddress = 0xFFFF_FF00_0000_0000UL;
private readonly HashSet<(ulong Address, uint Width, uint Height, Format Format)> _tracedTextureCacheHits = new();
private readonly HashSet<(ulong Address, uint Width, uint Height, uint DstSelect)> _tracedDepthTextureAliases = new();
private readonly HashSet<(ulong Address, uint Width, uint Height)> _tracedDepthExtentFallbacks = new();
private readonly HashSet<(ulong Address, uint Width, uint Height, Format Format)> _tracedTextureUploads = new();
private readonly HashSet<(ulong Address, uint Width, uint Height, uint Format)> _dumpedTextures = new();
private readonly HashSet<(ulong Address, uint Width, uint Height, uint Format)> _tracedTextureUploadContents = new();
private readonly HashSet<(ulong Address, int Size)> _tracedGlobalBuffers = new();
private readonly HashSet<(ulong Address, ulong Size)> _tracedGlobalWritebacks = new();
private readonly HashSet<(ulong Shader, uint X, uint Y, uint Z, string Reason)>
_rejectedComputeDispatches = new();
private int _tracedSmallGlobalWritebackEvents;
private int _tracedLargeGlobalWritebackEvents;
private readonly HashSet<ulong> _tracedGuestImageContents = new();
private readonly Dictionary<ulong, int> _tracedGuestWriteCounts = new();
private readonly Dictionary<int, int> _pixelSpirvWriteCounts = new();
private int _tracedVertexBufferCount;
private bool _tracedTitleDraw;
// Compute translation can produce an equivalent new byte array on a
// later submit. Reference identity turns that into an expensive new
// MoltenVK pipeline compilation every frame, so key the cache by the
// program content and descriptor-layout shape instead.
private readonly Dictionary<ComputePipelineKey, Pipeline> _computePipelines = new();
private readonly Dictionary<GraphicsPipelineKey, Pipeline> _graphicsPipelines = new();
private readonly Dictionary<GuestSampler, Sampler> _samplers = new();
private readonly Dictionary<byte[], string> _shaderDigests =
new(ReferenceEqualityComparer.Instance);
private readonly Dictionary<DescriptorLayoutKey, DescriptorLayoutBundle>
_descriptorLayouts = new();
private readonly VulkanHostBufferPool _hostBufferPool;
private readonly List<GuestBufferAllocation> _guestBufferAllocations = [];
private readonly Queue<PendingGuestSubmission> _pendingGuestSubmissions = new();
private readonly Dictionary<string, ulong> _lastSubmittedTimelineByGuestQueue =
new(StringComparer.Ordinal);
private readonly Stack<DescriptorPool> _recycledDescriptorPools = new();
private VulkanGuestQueueIdentity _activeGuestQueue =
VulkanGuestQueueIdentity.Default;
private long _activeGuestWorkSequence;
private readonly record struct GraphicsPipelineKey(
string VertexShader,
string FragmentShader,
string RenderTargetLayout,
bool HasDepthAttachment,
PrimitiveTopology Topology,
string BlendLayout,
string ResourceLayout,
string VertexLayout,
GuestRasterState Raster,
GuestDepthState Depth);
private readonly record struct DescriptorLayoutKey(
ShaderStageFlags Stages,
string Resources);
private readonly record struct ComputePipelineKey(
string ShaderDigest,
string Resources);
private sealed record DescriptorLayoutBundle(
DescriptorSetLayout DescriptorSetLayout,
PipelineLayout PipelineLayout);
private readonly record struct DirtyGuestBufferRange(ulong Offset, ulong Length);
private sealed class GuestBufferAllocation
{
public string QueueName = VulkanGuestQueueIdentity.Default.Name;
public ulong BaseAddress;
public ulong Size;
public VkBuffer Buffer;
public DeviceMemory Memory;
public nint Mapped;
public byte[] Shadow = [];
public ulong LastUseTimeline;
public List<DirtyGuestBufferRange> DirtyRanges { get; } = [];
}
private const string SharedReadOnlyGuestBufferQueue = "shared.readonly";
private sealed class TranslatedDrawResources
{
public string DebugName = "SharpEmu translated";
public PipelineLayout PipelineLayout;
public Pipeline Pipeline;
public bool PipelineCached;
public bool DescriptorLayoutCached;
public DescriptorSetLayout DescriptorSetLayout;
public DescriptorPool DescriptorPool;
public DescriptorSet DescriptorSet;
public TextureResource[] Textures = [];
public GlobalBufferResource[] GlobalMemoryBuffers = [];
public VertexBufferResource[] VertexBuffers = [];
public VkBuffer IndexBuffer;
public DeviceMemory IndexMemory;
public bool Index32Bit;
public uint VertexCount = 3;
public uint InstanceCount = 1;
public PrimitiveTopology Topology = PrimitiveTopology.TriangleList;
public GuestBlendState[] Blends = [GuestBlendState.Default];
// Vulkan format of this draw's color target. Needed to suppress
// blending on formats Metal cannot blend (integer / 32-bit float),
// which otherwise makes vkCreateGraphicsPipelines fail and can
// trip a Metal validation assertion.
public Format[] TargetFormats = [Format.Undefined];
public GuestRect? Scissor;
public GuestViewport? Viewport;
public GuestRasterState Raster = GuestRasterState.Default;
public GuestDepthState Depth = GuestDepthState.Default;
public bool HasDepthAttachment;
// Layout keys are needed twice per draw (pipeline lookup and
// descriptor-layout lookup); cache the built strings.
public string? ResourceLayoutKey;
public string? VertexLayoutKey;
public RenderPass TransientRenderPass;
public Framebuffer TransientFramebuffer;
}
private sealed class TextureResource
{
public ulong Address;
public VkBuffer StagingBuffer;
public DeviceMemory StagingMemory;
public Image Image;
public DeviceMemory ImageMemory;
public ImageView View;
public uint Width;
public uint Height;
public uint RowLength;
public uint DstSelect;
public bool NeedsUpload;
public bool OwnsStorage;
public bool IsStorage;
public bool Cached;
public ulong CpuContentFingerprint;
public bool UpdatesCpuContent;
public GuestSampler SamplerState;
public Sampler Sampler;
public GuestImageResource? GuestImage;
public GuestDepthResource? GuestDepth;
// A sampled render-target alias cannot remain bound to the same
// image while that image is a color attachment. The per-draw
// snapshot uses this source to copy the target's pre-draw contents
// before the render pass begins. The snapshot itself is owned by
// this TextureResource and retires with the draw fence.
public GuestImageResource? FeedbackSource;
public GuestDepthResource? DepthFeedbackSource;
}
private sealed class GlobalBufferResource
{
public ulong BaseAddress;
public bool Writable;
public bool WriteBackToGuest;
public VkBuffer Buffer;
public DeviceMemory Memory;
public nint Mapped;
// DescriptorOffset/Size include the shader-visible byte bias.
public ulong Offset;
public ulong Size;
// GuestOffset/Size identify only the original guest resource and
// are used for dirty writeback; descriptor padding must not be
// published over unrelated guest bytes.
public ulong GuestOffset;
public ulong GuestSize;
public GuestBufferAllocation? Allocation;
}
private sealed class VertexBufferResource
{
public VkBuffer Buffer;
public DeviceMemory Memory;
public bool OwnsBuffer;
public ulong Size;
public uint Location;
public uint ComponentCount;
public uint DataFormat;
public uint NumberFormat;
public uint Stride;
public uint OffsetBytes;
}
private const Format DepthFormat = Format.D32Sfloat;
private sealed class GuestDepthResource
{
public GuestDepthKey Key;
public ulong Address;
public ulong ReadAddress;
public ulong WriteAddress;
public uint Width;
public uint Height;
public uint GuestFormat;
public uint SwizzleMode;
public Image Image;
public DeviceMemory Memory;
public ImageView View;
public Dictionary<uint, ImageView> SampleViews { get; } = new();
public bool Initialized;
public ImageLayout Layout = ImageLayout.Undefined;
public float GuestClearDepth = 1f;
public float ClearDepth = 1f;
public string InitializationSource = "none";
}
private sealed class DepthFramebufferResource
{
public required GuestDepthResource Depth;
public RenderPass LoadRenderPass;
public RenderPass ColorClearRenderPass;
public RenderPass DepthClearRenderPass;
public RenderPass BothClearRenderPass;
public Framebuffer Framebuffer;
}
private sealed class GuestImageResource
{
public ulong Address;
public long FlipVersion;
public uint Width;
public uint Height;
public uint MipLevels;
public uint GuestFormat;
public Format Format;
public Image Image;
public DeviceMemory Memory;
public ImageView View;
public ImageView[] MipViews = [];
public Dictionary<(Format Format, uint MipLevel, uint LevelCount, uint DstSelect), ImageView> FormatViews { get; } = new();
public RenderPass RenderPass;
public RenderPass InitialRenderPass;
public Framebuffer Framebuffer;
public Dictionary<GuestDepthKey, DepthFramebufferResource> DepthFramebuffers { get; } = new();
public bool Initialized;
public bool InitialUploadPending;
public bool IsCpuBacked;
public ulong CpuContentFingerprint;
}
private sealed record PendingGuestSubmission(
Fence Fence,
CommandBuffer CommandBuffer,
IReadOnlyList<TranslatedDrawResources> Resources,
IReadOnlyList<GuestImageResource> TraceImages,
IReadOnlyList<(VkBuffer Buffer, DeviceMemory Memory)> RetireBuffers,
ulong Timeline,
string DebugName,
VulkanGuestQueueIdentity Queue,
long WorkSequence);
public Presenter(uint width, uint height, VulkanHostSurface? hostSurface)
{
_hostSurface = hostSurface;
_hostBufferPool = new VulkanHostBufferPool(
MaximumCachedHostBufferBytes,
DestroyHostBufferAllocation);
if (_hostSurface is not null)
{
_hostSurface.UpdatePixelSize(
_hostSurface.PixelWidth > 0 ? _hostSurface.PixelWidth : (int)width,
_hostSurface.PixelHeight > 0 ? _hostSurface.PixelHeight : (int)height);
_lastHostResizeGeneration = _hostSurface.ResizeGeneration;
return;
}
var options = WindowOptions.DefaultVulkan;
options.Size = new Vector2D<int>((int)DefaultWindowWidth, (int)DefaultWindowHeight);
options.Title = VideoOutExports.GetWindowTitle();
options.WindowBorder = WindowBorder.Fixed;
options.VSync = true;
options.FramesPerSecond = 60;
options.UpdatesPerSecond = 60;
_window = Window.Create(options);
_window.Load += Initialize;
_window.Render += Render;
_window.Closing += () =>
{
Console.Error.WriteLine(
$"[LOADER][WARN] Vulkan VideoOut window closing; " +
$"requested={Volatile.Read(ref _presenterCloseRequested)} " +
$"deviceLost={_deviceLost}");
VideoOutExports.NotifyPresentationWindowClosed();
DisposeVulkan();
};
}
public void Run()
{
if (_window is not null)
{
_window.Run();
return;
}
Initialize();
while (!_embeddedLoopClosed)
{
Render(0);
// Guest draws and flips execute on this thread. An unconditional
// Thread.Sleep here is quantized to the Windows timer period
// (~15.6ms) and throttles every hosted game far below the GLFW
// path. Wait only while there is genuinely no render work.
WaitForRenderWork();
}
}
public void Dispose()
{
DisposeVulkan();
try
{
_window?.Dispose();
}
catch (InvalidOperationException exception)
when (exception.Message.Contains("render loop", StringComparison.OrdinalIgnoreCase))
{
Console.Error.WriteLine(
$"[LOADER][WARN] Vulkan VideoOut window dispose skipped during render loop: {exception.Message}");
}
}
private void Initialize()
{
if (_window is not null)
{
LogGlfwPlatformInUse();
if (!OperatingSystem.IsWindows())
{
try
{
Pad.HostWindowInput.Attach(_window.CreateInput());
Console.Error.WriteLine("[LOADER][INFO] Window keyboard input attached for pad emulation.");
}
catch (Exception exception)
{
Console.Error.WriteLine($"[LOADER][WARN] Window keyboard input unavailable: {exception.Message}");
}
}
if (PngSplashLoader.TryLoadIcon(out var iconPixels, out var iconWidth, out var iconHeight))
{
var icon = new RawImage((int)iconWidth, (int)iconHeight, iconPixels);
_window.SetWindowIcon(ref icon);
}
}
WaitForRenderDocAttachIfRequested();
_vk = Vk.GetApi();
CreateInstance();
CreateSurface();
SelectPhysicalDevice();
CreateDevice();
CreatePipelineCache();
CreateSwapchain();
CreateCommandResources();
CreateGuestDrawResources();
_vulkanReady = true;
Console.Error.WriteLine(
$"[LOADER][INFO] Vulkan VideoOut ready: {_extent.Width}x{_extent.Height}, format={_swapchainFormat}");
}
private static void WaitForRenderDocAttachIfRequested()
{
var value = Environment.GetEnvironmentVariable("SHARPEMU_RENDERDOC_WAIT");
if (string.IsNullOrWhiteSpace(value))
{
return;
}
if (string.Equals(value, "enter", StringComparison.OrdinalIgnoreCase))
{
Console.Error.WriteLine(
$"[LOADER][INFO] Waiting for RenderDoc attach before Vulkan init. pid={Environment.ProcessId}. Press Enter to continue.");
_ = Console.ReadLine();
return;
}
var seconds = 15;
if (int.TryParse(value, out var parsedSeconds))
{
seconds = Math.Clamp(parsedSeconds, 1, 300);
}
Console.Error.WriteLine(
$"[LOADER][INFO] Waiting {seconds}s for RenderDoc attach before Vulkan init. pid={Environment.ProcessId}");
Thread.Sleep(TimeSpan.FromSeconds(seconds));
}
private bool IsInstanceExtensionAvailable(string extensionName)
{
uint extensionCount = 0;
if (_vk.EnumerateInstanceExtensionProperties((byte*)null, &extensionCount, null) != Result.Success ||
extensionCount == 0)
{
return false;
}
var properties = new ExtensionProperties[extensionCount];
fixed (ExtensionProperties* propertyPointer = properties)
{
if (_vk.EnumerateInstanceExtensionProperties(
(byte*)null,
&extensionCount,
propertyPointer) != Result.Success)
{
return false;
}
var expected = Encoding.UTF8.GetBytes(extensionName);
for (var index = 0; index < extensionCount; index++)
{
if (Utf8NullTerminatedEquals(propertyPointer[index].ExtensionName, expected))
{
return true;
}
}
}
return false;
}
private static bool Utf8NullTerminatedEquals(byte* actual, ReadOnlySpan<byte> expected)
{
for (var index = 0; index < expected.Length; index++)
{
if (actual[index] != expected[index])
{
return false;
}
}
return actual[expected.Length] == 0;
}
private void LoadDebugUtilsCommands()
{
if (!_vulkanDebugUtilsEnabled)
{
return;
}
var setObjectName = _vk.GetDeviceProcAddr(_device, "vkSetDebugUtilsObjectNameEXT");
var beginLabel = _vk.GetDeviceProcAddr(_device, "vkCmdBeginDebugUtilsLabelEXT");
var endLabel = _vk.GetDeviceProcAddr(_device, "vkCmdEndDebugUtilsLabelEXT");
_setDebugUtilsObjectName =
(delegate* unmanaged<Device, DebugUtilsObjectNameInfoEXT*, Result>)
setObjectName.Handle;
_cmdBeginDebugUtilsLabel =
(delegate* unmanaged<CommandBuffer, DebugUtilsLabelEXT*, void>)
beginLabel.Handle;
_cmdEndDebugUtilsLabel =
(delegate* unmanaged<CommandBuffer, void>)
endLabel.Handle;
if (_setDebugUtilsObjectName is not null)
{
Console.Error.WriteLine("[LOADER][INFO] Vulkan debug labels enabled.");
}
}
private void SetDebugName(ObjectType objectType, ulong objectHandle, string name)
{
if (_setDebugUtilsObjectName is null ||
_device.Handle == 0 ||
objectHandle == 0)
{
return;
}
var bytes = NullTerminatedUtf8(name);
fixed (byte* namePointer = bytes)
{
var info = new DebugUtilsObjectNameInfoEXT
{
SType = StructureType.DebugUtilsObjectNameInfoExt,
ObjectType = objectType,
ObjectHandle = objectHandle,
PObjectName = namePointer,
};
_ = _setDebugUtilsObjectName(_device, &info);
}
}
private void BeginDebugLabel(CommandBuffer commandBuffer, string name)
{
if (_cmdBeginDebugUtilsLabel is null ||
commandBuffer.Handle == 0)
{
return;
}
var bytes = NullTerminatedUtf8(name);
fixed (byte* namePointer = bytes)
{
var label = new DebugUtilsLabelEXT
{
SType = StructureType.DebugUtilsLabelExt,
PLabelName = namePointer,
};
label.Color[0] = 0.20f;
label.Color[1] = 0.60f;
label.Color[2] = 1.00f;
label.Color[3] = 1.00f;
_cmdBeginDebugUtilsLabel(commandBuffer, &label);
}
}
private void EndDebugLabel(CommandBuffer commandBuffer)
{
if (_cmdEndDebugUtilsLabel is not null &&
commandBuffer.Handle != 0)
{
_cmdEndDebugUtilsLabel(commandBuffer);
}
}
private static byte[] NullTerminatedUtf8(string value)
{
var bytes = Encoding.UTF8.GetBytes(value);
Array.Resize(ref bytes, bytes.Length + 1);
return bytes;
}
private static string BuildComputeDebugName(VulkanComputeGuestDispatch dispatch)
{
var storage = dispatch.Textures.FirstOrDefault(texture => texture.IsStorage && texture.Address != 0);
return storage is null
? $"SharpEmu compute cs=0x{dispatch.ShaderAddress:X16} " +
$"{dispatch.GroupCountX}x{dispatch.GroupCountY}x{dispatch.GroupCountZ}"
: $"SharpEmu compute cs=0x{dispatch.ShaderAddress:X16} " +
$"storage=0x{storage.Address:X16} " +
$"{storage.Width}x{storage.Height} fmt{storage.Format} " +
$"{dispatch.GroupCountX}x{dispatch.GroupCountY}x{dispatch.GroupCountZ}";
}
private static string GuestImageDebugName(GuestRenderTarget target, Format format) =>
$"SharpEmu guest 0x{target.Address:X16} {target.Width}x{target.Height} " +
$"fmt{target.Format}/{format}";
private static string TextureDebugName(GuestDrawTexture texture, Format format) =>
$"SharpEmu texture 0x{texture.Address:X16} {texture.Width}x{texture.Height} " +
$"fmt{texture.Format}/{format}";
private static bool IsTitleDraw(IReadOnlyList<GuestVertexBuffer> vertexBuffers)
{
foreach (var buffer in vertexBuffers)
{
if (buffer.Location == 0 &&
buffer.ComponentCount == 4 &&
buffer.DataFormat == 10 &&
buffer.NumberFormat == 0 &&
buffer.Stride == 16 &&
buffer.OffsetBytes == 12 &&
buffer.Length == 67568)
{
return true;
}
}
return false;
}
private static bool AnyTargetAddressMatches(
IReadOnlyList<GuestImageResource>? targets,
string environmentVariable)
{
if (targets is null)
{
return false;
}
foreach (var target in targets)
{
if (AddressListContains(environmentVariable, target.Address))
{
return true;
}
}
return false;
}
private void CreateInstance()
{
var applicationName = (byte*)SilkMarshal.StringToPtr("SharpEmu");
byte* validationLayerName = null;
try
{
var applicationInfo = new ApplicationInfo
{
SType = StructureType.ApplicationInfo,
PApplicationName = applicationName,
ApplicationVersion = Vk.MakeVersion(0, 0, 1),
PEngineName = applicationName,
EngineVersion = Vk.MakeVersion(0, 0, 1),
ApiVersion = Vk.Version12,
};
var hostExtensionNames = _hostSurface is null
? null
: GetHostSurfaceExtensions(_hostSurface.Kind);
byte** extensions;
uint extensionCount;
if (hostExtensionNames is not null)
{
extensions = null;
extensionCount = (uint)hostExtensionNames.Length;
}
else if (_window?.VkSurface is { } glfwSurface)
{
extensions = glfwSurface.GetRequiredExtensions(out extensionCount);
}
else
{
throw new InvalidOperationException("GLFW did not provide Vulkan surface extensions.");
}
byte* debugUtilsExtension = null;
byte* portabilityExtension = null;
var instanceCreateFlags = InstanceCreateFlags.None;
var enabledExtensionCount = (int)extensionCount;
var enabledExtensions = stackalloc byte*[(int)extensionCount + 2];
var allocatedHostExtensions = hostExtensionNames is null
? null
: new nint[hostExtensionNames.Length];
for (var index = 0; index < (int)extensionCount; index++)
{
if (hostExtensionNames is null)
{
enabledExtensions[index] = extensions[index];
}
else
{
var extension = (byte*)SilkMarshal.StringToPtr(hostExtensionNames[index]);
allocatedHostExtensions![index] = (nint)extension;
enabledExtensions[index] = extension;
}
}
if (_vulkanDebugUtilsEnabled &&
IsInstanceExtensionAvailable(DebugUtilsExtensionName))
{
debugUtilsExtension = (byte*)SilkMarshal.StringToPtr(DebugUtilsExtensionName);
enabledExtensions[enabledExtensionCount++] = debugUtilsExtension;
}
if (IsInstanceExtensionAvailable(PortabilityEnumerationExtensionName))
{
// MoltenVK is a portability (non-conformant) implementation;
// without this flag + extension the loader hides it.
portabilityExtension = (byte*)SilkMarshal.StringToPtr(PortabilityEnumerationExtensionName);
enabledExtensions[enabledExtensionCount++] = portabilityExtension;
instanceCreateFlags |= InstanceCreateFlags.EnumeratePortabilityBitKhr;
}
if (_vulkanValidationEnabled &&
IsInstanceLayerAvailable("VK_LAYER_KHRONOS_validation"))
{
validationLayerName = (byte*)SilkMarshal.StringToPtr("VK_LAYER_KHRONOS_validation");
}
else if (_vulkanValidationEnabled)
{
Console.Error.WriteLine("[LOADER][WARN] SHARPEMU_VK_VALIDATION=1 but VK_LAYER_KHRONOS_validation not found (Vulkan SDK installed?).");
}
var layers = stackalloc byte*[1];
if (validationLayerName is not null)
{
layers[0] = validationLayerName;
}
var createInfo = new InstanceCreateInfo
{
SType = StructureType.InstanceCreateInfo,
Flags = instanceCreateFlags,
PApplicationInfo = &applicationInfo,
EnabledExtensionCount = (uint)enabledExtensionCount,
PpEnabledExtensionNames = enabledExtensions,
EnabledLayerCount = validationLayerName is not null ? 1u : 0u,
PpEnabledLayerNames = validationLayerName is not null ? layers : null,
};
try
{
Check(_vk.CreateInstance(&createInfo, null, out _instance), "vkCreateInstance");
if (!_vk.TryGetInstanceExtension(_instance, out _surfaceApi))
{
throw new InvalidOperationException("VK_KHR_surface is unavailable.");
}
if (validationLayerName is not null && _vk.TryGetInstanceExtension(_instance, out ExtDebugUtils debugUtils))
{
_debugUtils = debugUtils;
RegisterDebugMessenger(debugUtils);
Console.Error.WriteLine("[LOADER][INFO] Vulkan Validation Layers active (SHARPEMU_VK_VALIDATION=1).");
}
}
finally
{
if (debugUtilsExtension is not null)
{
SilkMarshal.Free((nint)debugUtilsExtension);
}
if (portabilityExtension is not null)
{
SilkMarshal.Free((nint)portabilityExtension);
}
if (allocatedHostExtensions is not null)
{
foreach (var extension in allocatedHostExtensions)
{
SilkMarshal.Free(extension);
}
}
}
}
finally
{
SilkMarshal.Free((nint)applicationName);
if (validationLayerName is not null)
{
SilkMarshal.Free((nint)validationLayerName);
}
}
}
private bool IsDeviceExtensionAvailable(string extensionName)
{
uint extensionCount = 0;
if (_vk.EnumerateDeviceExtensionProperties(_physicalDevice, (byte*)null, &extensionCount, null) != Result.Success ||
extensionCount == 0)
{
return false;
}
var properties = new ExtensionProperties[extensionCount];
fixed (ExtensionProperties* propertyPointer = properties)
{
if (_vk.EnumerateDeviceExtensionProperties(
_physicalDevice,
(byte*)null,
&extensionCount,
propertyPointer) != Result.Success)
{
return false;
}
var expected = Encoding.UTF8.GetBytes(extensionName);
for (var index = 0; index < extensionCount; index++)
{
if (Utf8NullTerminatedEquals(propertyPointer[index].ExtensionName, expected))
{
return true;
}
}
}
return false;
}
private bool IsInstanceLayerAvailable(string layerName)
{
uint layerCount = 0;
if (_vk.EnumerateInstanceLayerProperties(&layerCount, null) != Result.Success || layerCount == 0)
{
return false;
}
var properties = new LayerProperties[layerCount];
fixed (LayerProperties* propertyPointer = properties)
{
if (_vk.EnumerateInstanceLayerProperties(&layerCount, propertyPointer) != Result.Success)
{
return false;
}
var expected = Encoding.UTF8.GetBytes(layerName);
for (var index = 0; index < layerCount; index++)
{
if (Utf8NullTerminatedEquals(propertyPointer[index].LayerName, expected))
{
return true;
}
}
}
return false;
}
private void RegisterDebugMessenger(ExtDebugUtils debugUtils)
{
var messengerInfo = new DebugUtilsMessengerCreateInfoEXT
{
SType = StructureType.DebugUtilsMessengerCreateInfoExt,
MessageSeverity = DebugUtilsMessageSeverityFlagsEXT.ErrorBitExt
| DebugUtilsMessageSeverityFlagsEXT.WarningBitExt,
MessageType = DebugUtilsMessageTypeFlagsEXT.ValidationBitExt
| DebugUtilsMessageTypeFlagsEXT.PerformanceBitExt
| DebugUtilsMessageTypeFlagsEXT.GeneralBitExt,
PfnUserCallback = new PfnDebugUtilsMessengerCallbackEXT(DebugCallback),
};
Check(debugUtils.CreateDebugUtilsMessenger(_instance, &messengerInfo, null, out _debugMessenger),
"vkCreateDebugUtilsMessengerEXT");
}
private static unsafe uint DebugCallback(
DebugUtilsMessageSeverityFlagsEXT severity,
DebugUtilsMessageTypeFlagsEXT type,
DebugUtilsMessengerCallbackDataEXT* callbackData,
void* userData)
{
var message = SilkMarshal.PtrToString((nint)callbackData->PMessage);
var prefix = severity switch
{
DebugUtilsMessageSeverityFlagsEXT.ErrorBitExt => "[VULKAN][ERROR]",
DebugUtilsMessageSeverityFlagsEXT.WarningBitExt => "[VULKAN][WARN]",
_ => "[VULKAN][INFO]",
};
Console.Error.WriteLine($"{prefix} {message}");
return Vk.False;
}
private void CreateSurface()
{
if (_hostSurface is not null)
{
CreateHostSurface(_hostSurface);
return;
}
var instanceHandle = new VkHandle(_instance.Handle);
var surfaceHandle = _window!.VkSurface!.Create<AllocationCallbacks>(instanceHandle, null);
_surface = new SurfaceKHR(surfaceHandle.Handle);
}
private static string[] GetHostSurfaceExtensions(VulkanHostSurfaceKind kind) => kind switch
{
VulkanHostSurfaceKind.Win32 => ["VK_KHR_surface", "VK_KHR_win32_surface"],
VulkanHostSurfaceKind.Xlib => ["VK_KHR_surface", "VK_KHR_xlib_surface"],
VulkanHostSurfaceKind.Metal => ["VK_KHR_surface", "VK_EXT_metal_surface"],
_ => throw new PlatformNotSupportedException($"Unsupported Vulkan host surface: {kind}."),
};
private void CreateHostSurface(VulkanHostSurface hostSurface)
{
switch (hostSurface.Kind)
{
case VulkanHostSurfaceKind.Win32:
CreateWin32HostSurface(hostSurface);
return;
case VulkanHostSurfaceKind.Xlib:
CreateXlibHostSurface(hostSurface);
return;
case VulkanHostSurfaceKind.Metal:
CreateMetalHostSurface(hostSurface);
return;
default:
throw new PlatformNotSupportedException($"Unsupported Vulkan host surface: {hostSurface.Kind}.");
}
}
private void CreateWin32HostSurface(VulkanHostSurface hostSurface)
{
if (!_vk.TryGetInstanceExtension(_instance, out KhrWin32Surface win32Surface))
{
throw new InvalidOperationException("VK_KHR_win32_surface is unavailable.");
}
var createInfo = new Win32SurfaceCreateInfoKHR
{
SType = StructureType.Win32SurfaceCreateInfoKhr,
Hinstance = hostSurface.DisplayHandle != 0
? hostSurface.DisplayHandle
: GetModuleHandleW(null),
Hwnd = hostSurface.WindowHandle,
};
Check(win32Surface.CreateWin32Surface(_instance, &createInfo, null, out _surface), "vkCreateWin32SurfaceKHR");
}
private void CreateXlibHostSurface(VulkanHostSurface hostSurface)
{
if (!_vk.TryGetInstanceExtension(_instance, out KhrXlibSurface xlibSurface))
{
throw new InvalidOperationException("VK_KHR_xlib_surface is unavailable.");
}
var createInfo = new XlibSurfaceCreateInfoKHR
{
SType = StructureType.XlibSurfaceCreateInfoKhr,
Dpy = (nint*)hostSurface.DisplayHandle,
Window = hostSurface.WindowHandle,
};
Check(xlibSurface.CreateXlibSurface(_instance, &createInfo, null, out _surface), "vkCreateXlibSurfaceKHR");
}
private void CreateMetalHostSurface(VulkanHostSurface hostSurface)
{
var proc = _vk.GetInstanceProcAddr(_instance, "vkCreateMetalSurfaceEXT");
if (proc == 0)
{
throw new InvalidOperationException("VK_EXT_metal_surface is unavailable.");
}
var createInfo = new MetalSurfaceCreateInfoEXT
{
SType = StructureType.MetalSurfaceCreateInfoExt,
PLayer = (nint*)hostSurface.MetalLayerHandle,
};
var createSurface = (delegate* unmanaged<Instance, MetalSurfaceCreateInfoEXT*, AllocationCallbacks*, SurfaceKHR*, Result>)proc.Handle;
SurfaceKHR surface;
Check(createSurface(_instance, &createInfo, null, &surface), "vkCreateMetalSurfaceEXT");
_surface = surface;
}
[DllImport("kernel32.dll", EntryPoint = "GetModuleHandleW", CharSet = CharSet.Unicode)]
private static extern nint GetModuleHandleW(string? moduleName);
private void SelectPhysicalDevice()
{
uint deviceCount = 0;
Check(_vk.EnumeratePhysicalDevices(_instance, &deviceCount, null), "vkEnumeratePhysicalDevices");
if (deviceCount == 0)
{
throw new InvalidOperationException("No Vulkan physical device was found.");
}
var devices = new PhysicalDevice[deviceCount];
fixed (PhysicalDevice* devicePointer = devices)
{
Check(_vk.EnumeratePhysicalDevices(_instance, &deviceCount, devicePointer), "vkEnumeratePhysicalDevices");
}
var deviceOverride = Environment.GetEnvironmentVariable("SHARPEMU_VK_DEVICE");
var bestScore = int.MinValue;
var found = false;
foreach (var device in devices)
{
uint queueCount = 0;
_vk.GetPhysicalDeviceQueueFamilyProperties(device, &queueCount, null);
var queues = new QueueFamilyProperties[queueCount];
fixed (QueueFamilyProperties* queuePointer = queues)
{
_vk.GetPhysicalDeviceQueueFamilyProperties(device, &queueCount, queuePointer);
}
for (uint index = 0; index < queueCount; index++)
{
var supportsGraphics = (queues[index].QueueFlags & QueueFlags.GraphicsBit) != 0;
_surfaceApi.GetPhysicalDeviceSurfaceSupport(device, index, _surface, out var supportsPresent);
if (!supportsGraphics || !supportsPresent)
{
continue;
}
_vk.GetPhysicalDeviceProperties(device, out var properties);
var name = SilkMarshal.PtrToString((nint)properties.DeviceName) ?? string.Empty;
var score = ScorePhysicalDevice(properties, name, deviceOverride);
Console.Error.WriteLine(
$"[LOADER][INFO] Vulkan candidate: {name} ({properties.DeviceType}) score={score}");
if (score > bestScore)
{
bestScore = score;
_physicalDevice = device;
_queueFamilyIndex = index;
found = true;
}
break;
}
}
if (!found)
{
throw new InvalidOperationException("No Vulkan graphics/present queue was found.");
}
LoadComputeDeviceLimits();
_vk.GetPhysicalDeviceProperties(_physicalDevice, out var selected);
_maxColorAttachments = selected.Limits.MaxColorAttachments;
var selectedName = SilkMarshal.PtrToString((nint)selected.DeviceName) ?? "unknown";
Console.Error.WriteLine(
$"[LOADER][INFO] Vulkan device: {selectedName} ({selected.DeviceType})");
VideoOutExports.SetSelectedGpuName(selectedName);
if (_window is not null)
{
_window.Title = VideoOutExports.GetWindowTitle();
}
}
private void LoadComputeDeviceLimits()
{
_vk.GetPhysicalDeviceProperties(_physicalDevice, out var properties);
var subgroupSizeControl = new PhysicalDeviceSubgroupSizeControlProperties
{
SType = StructureType.PhysicalDeviceSubgroupSizeControlProperties,
};
var subgroup = new PhysicalDeviceSubgroupProperties
{
SType = StructureType.PhysicalDeviceSubgroupProperties,
PNext = &subgroupSizeControl,
};
var properties2 = new PhysicalDeviceProperties2
{
SType = StructureType.PhysicalDeviceProperties2,
PNext = &subgroup,
};
_vk.GetPhysicalDeviceProperties2(_physicalDevice, &properties2);
_maxComputeWorkGroupCountX = properties.Limits.MaxComputeWorkGroupCount[0];
_maxComputeWorkGroupCountY = properties.Limits.MaxComputeWorkGroupCount[1];
_maxComputeWorkGroupCountZ = properties.Limits.MaxComputeWorkGroupCount[2];
_maxComputeWorkGroupSizeX = properties.Limits.MaxComputeWorkGroupSize[0];
_maxComputeWorkGroupSizeY = properties.Limits.MaxComputeWorkGroupSize[1];
_maxComputeWorkGroupSizeZ = properties.Limits.MaxComputeWorkGroupSize[2];
_maxComputeWorkGroupInvocations = properties.Limits.MaxComputeWorkGroupInvocations;
_minStorageBufferOffsetAlignment = Math.Max(
properties.Limits.MinStorageBufferOffsetAlignment,
1UL);
if (GuestStorageBufferOffsetAlignment %
_minStorageBufferOffsetAlignment != 0)
{
throw new InvalidOperationException(
$"Vulkan storage-buffer alignment " +
$"{_minStorageBufferOffsetAlignment} is not compatible with " +
$"the portable alias alignment " +
$"{GuestStorageBufferOffsetAlignment}");
}
Console.Error.WriteLine(
$"[LOADER][INFO] Vulkan compute limits groups=" +
$"{_maxComputeWorkGroupCountX}x{_maxComputeWorkGroupCountY}x{_maxComputeWorkGroupCountZ} " +
$"local={_maxComputeWorkGroupSizeX}x{_maxComputeWorkGroupSizeY}x" +
$"{_maxComputeWorkGroupSizeZ} invocations={_maxComputeWorkGroupInvocations} " +
$"storage_alignment={_minStorageBufferOffsetAlignment}");
Console.Error.WriteLine(
$"[LOADER][INFO] Vulkan subgroup default={subgroup.SubgroupSize} " +
$"stages={subgroup.SupportedStages} ops={subgroup.SupportedOperations} " +
$"size_control={subgroupSizeControl.MinSubgroupSize}-" +
$"{subgroupSizeControl.MaxSubgroupSize} " +
$"required_stages={subgroupSizeControl.RequiredSubgroupSizeStages} " +
$"max_compute_subgroups=" +
$"{subgroupSizeControl.MaxComputeWorkgroupSubgroups}");
}
private void CreateDevice()
{
var priority = 1.0f;
var queueInfo = new DeviceQueueCreateInfo
{
SType = StructureType.DeviceQueueCreateInfo,
QueueFamilyIndex = _queueFamilyIndex,
QueueCount = 1,
PQueuePriorities = &priority,
};
_vk.GetPhysicalDeviceFeatures(_physicalDevice, out var supportedFeatures);
_supportsIndependentBlend = supportedFeatures.IndependentBlend;
var enabledFeatures = new PhysicalDeviceFeatures
{
IndependentBlend = supportedFeatures.IndependentBlend,
VertexPipelineStoresAndAtomics = supportedFeatures.VertexPipelineStoresAndAtomics,
FragmentStoresAndAtomics = supportedFeatures.FragmentStoresAndAtomics,
ShaderInt64 = supportedFeatures.ShaderInt64,
ShaderImageGatherExtended = supportedFeatures.ShaderImageGatherExtended,
ShaderStorageImageExtendedFormats = supportedFeatures.ShaderStorageImageExtendedFormats,
ShaderStorageImageReadWithoutFormat = supportedFeatures.ShaderStorageImageReadWithoutFormat,
ShaderStorageImageWriteWithoutFormat = supportedFeatures.ShaderStorageImageWriteWithoutFormat,
RobustBufferAccess = supportedFeatures.RobustBufferAccess,
};
if (!supportedFeatures.RobustBufferAccess)
{
Console.Error.WriteLine(
"[LOADER][WARN] GPU does not support robustBufferAccess " +
"translated shaders performing out-of-bounds buffer access may cause device loss.");
}
if (!supportedFeatures.ShaderInt64)
{
Console.Error.WriteLine(
"[LOADER][WARN] GPU does not support shaderInt64 " +
"translated shaders using 64-bit integers will fail.");
}
if (!supportedFeatures.VertexPipelineStoresAndAtomics || !supportedFeatures.FragmentStoresAndAtomics)
{
Console.Error.WriteLine(
"[LOADER][WARN] GPU does not support vertexPipelineStoresAndAtomics/fragmentStoresAndAtomics " +
"translated shaders using storage buffers in vertex/fragment stages may fail.");
}
if (!supportedFeatures.ShaderImageGatherExtended)
{
Console.Error.WriteLine(
"[LOADER][WARN] GPU does not support shaderImageGatherExtended " +
"translated shaders using image gather with offsets/LOD/bias will fail.");
}
if (!supportedFeatures.ShaderStorageImageReadWithoutFormat ||
!supportedFeatures.ShaderStorageImageWriteWithoutFormat)
{
Console.Error.WriteLine(
"[LOADER][WARN] GPU does not support shaderStorageImage(Read|Write)WithoutFormat " +
"translated shaders using unformatted storage image load/store will fail.");
}
var maintenance8Features = new PhysicalDeviceMaintenance8FeaturesKHR
{
SType = StructureType.PhysicalDeviceMaintenance8FeaturesKhr,
};
var robustness2Features = new PhysicalDeviceRobustness2FeaturesEXT
{
SType = StructureType.PhysicalDeviceRobustness2FeaturesExt,
PNext = &maintenance8Features,
};
var featuresQuery = new PhysicalDeviceFeatures2
{
SType = StructureType.PhysicalDeviceFeatures2,
PNext = &robustness2Features,
};
_vk.GetPhysicalDeviceFeatures2(_physicalDevice, &featuresQuery);
var supportsMaintenance8 = maintenance8Features.Maintenance8;
var supportsRobustBufferAccess2 = robustness2Features.RobustBufferAccess2;
var supportsRobustImageAccess2 = robustness2Features.RobustImageAccess2;
var supportsNullDescriptor = robustness2Features.NullDescriptor;
var supportsRobustness2 = supportsRobustImageAccess2 || supportsNullDescriptor;
if (!supportsMaintenance8)
{
Console.Error.WriteLine(
"[LOADER][WARN] GPU does not support VK_KHR_maintenance8 " +
"translated shaders using a dynamic texel offset on non-gather image samples will fail.");
}
if (!supportsRobustImageAccess2)
{
Console.Error.WriteLine(
"[LOADER][WARN] GPU does not support VK_EXT_robustness2 robustImageAccess2 " +
"translated shaders performing out-of-bounds image access may cause device loss.");
}
var swapchainExtension = (byte*)SilkMarshal.StringToPtr("VK_KHR_swapchain");
var maintenance8Extension = (byte*)SilkMarshal.StringToPtr("VK_KHR_maintenance8");
var robustness2Extension = (byte*)SilkMarshal.StringToPtr("VK_EXT_robustness2");
var portabilitySubsetExtension = (byte*)SilkMarshal.StringToPtr(PortabilitySubsetExtensionName);
try
{
var extensions = stackalloc byte*[4];
var extensionCount = 0u;
extensions[extensionCount++] = swapchainExtension;
if (supportsMaintenance8)
{
extensions[extensionCount++] = maintenance8Extension;
}
if (supportsRobustness2)
{
extensions[extensionCount++] = robustness2Extension;
}
if (IsDeviceExtensionAvailable(PortabilitySubsetExtensionName))
{
// The spec requires enabling this when the (MoltenVK)
// device advertises it.
extensions[extensionCount++] = portabilitySubsetExtension;
}
maintenance8Features.Maintenance8 = supportsMaintenance8;
maintenance8Features.PNext = null;
robustness2Features.RobustBufferAccess2 =
supportsRobustBufferAccess2 && supportedFeatures.RobustBufferAccess;
robustness2Features.RobustImageAccess2 = supportsRobustImageAccess2;
robustness2Features.NullDescriptor = supportsNullDescriptor;
robustness2Features.PNext = supportsMaintenance8 ? &maintenance8Features : null;
var features2 = new PhysicalDeviceFeatures2
{
SType = StructureType.PhysicalDeviceFeatures2,
PNext = supportsRobustness2
? &robustness2Features
: (supportsMaintenance8 ? &maintenance8Features : null),
Features = enabledFeatures,
};
var createInfo = new DeviceCreateInfo
{
SType = StructureType.DeviceCreateInfo,
PNext = &features2,
QueueCreateInfoCount = 1,
PQueueCreateInfos = &queueInfo,
EnabledExtensionCount = extensionCount,
PpEnabledExtensionNames = extensions,
};
Check(_vk.CreateDevice(_physicalDevice, &createInfo, null, out _device), "vkCreateDevice");
}
finally
{
SilkMarshal.Free((nint)swapchainExtension);
SilkMarshal.Free((nint)maintenance8Extension);
SilkMarshal.Free((nint)robustness2Extension);
SilkMarshal.Free((nint)portabilitySubsetExtension);
}
_vk.GetDeviceQueue(_device, _queueFamilyIndex, 0, out _queue);
LoadDebugUtilsCommands();
if (!_vk.TryGetDeviceExtension(_instance, _device, out _swapchainApi))
{
throw new InvalidOperationException("VK_KHR_swapchain is unavailable.");
}
}
private void CreatePipelineCache()
{
var cacheMode = Environment.GetEnvironmentVariable("SHARPEMU_VK_PIPELINE_CACHE");
// Vulkan cache blobs carry the implementation's compatibility
// header and are rejected/rebuilt below when the device or driver
// changes. MoltenVK compilation of a large translated shader can
// take ten seconds, so discarding a valid cache at every launch is
// much more harmful than using Vulkan's normal persistence path.
// Keep an explicit opt-out for diagnostics and read-only systems.
var persistentCacheEnabled =
!string.Equals(cacheMode, "0", StringComparison.Ordinal);
_pipelineCachePath = persistentCacheEnabled ? GetPipelineCachePath() : null;
byte[] initialData = [];
try
{
if (_pipelineCachePath is not null && File.Exists(_pipelineCachePath))
{
initialData = File.ReadAllBytes(_pipelineCachePath);
}
}
catch (Exception exception)
{
Console.Error.WriteLine(
$"[LOADER][WARN] Vulkan pipeline cache read failed: {exception.Message}");
}
var result = TryCreatePipelineCache(initialData, out _pipelineCache);
if (result != Result.Success && initialData.Length != 0)
{
Console.Error.WriteLine(
$"[LOADER][WARN] Vulkan pipeline cache rejected ({result}); rebuilding it.");
result = TryCreatePipelineCache([], out _pipelineCache);
}
if (result != Result.Success)
{
_pipelineCache = default;
_pipelineCachePath = null;
Console.Error.WriteLine(
$"[LOADER][WARN] Vulkan pipeline cache unavailable: {result}");
return;
}
SetDebugName(
ObjectType.PipelineCache,
_pipelineCache.Handle,
_pipelineCachePath is null
? "SharpEmu in-memory pipeline cache"
: "SharpEmu persistent pipeline cache");
_lastPipelineCacheSaveTick = Environment.TickCount64;
if (_pipelineCachePath is null)
{
Console.Error.WriteLine(
"[LOADER][INFO] Vulkan pipeline cache ready: memory-only " +
"(persistence disabled with SHARPEMU_VK_PIPELINE_CACHE=0).");
}
else
{
Console.Error.WriteLine(
$"[LOADER][INFO] Vulkan pipeline cache ready: path={_pipelineCachePath} initial={initialData.Length} bytes");
}
}
private Result TryCreatePipelineCache(byte[] initialData, out PipelineCache pipelineCache)
{
fixed (byte* initialDataPointer = initialData)
{
var createInfo = new PipelineCacheCreateInfo
{
SType = StructureType.PipelineCacheCreateInfo,
InitialDataSize = (nuint)initialData.Length,
PInitialData = initialData.Length == 0 ? null : initialDataPointer,
};
return _vk.CreatePipelineCache(
_device,
&createInfo,
null,
out pipelineCache);
}
}
private static string GetPipelineCachePath()
{
var configured = Environment.GetEnvironmentVariable("SHARPEMU_VK_PIPELINE_CACHE_PATH");
if (!string.IsNullOrWhiteSpace(configured))
{
return Path.GetFullPath(
Environment.ExpandEnvironmentVariables(configured));
}
var root = OperatingSystem.IsMacOS()
? Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.UserProfile),
"Library",
"Caches")
: Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData);
return Path.Combine(root, "SharpEmu", "vulkan-pipeline-cache.bin");
}
private void MarkPipelineCacheDirty()
{
if (_pipelineCache.Handle == 0)
{
return;
}
_pipelineCacheDirty = true;
// Exporting MoltenVK's cache can itself serialize the compiler.
// Gameplay may discover dozens of expensive pipelines in one
// frame, so saving after every slow creation compounds a warm-up
// hitch into a multi-minute stall. Coalesce all creations into one
// periodic snapshot; shutdown still forces a final save.
if (Environment.TickCount64 - _lastPipelineCacheSaveTick >= 30_000)
{
SavePipelineCache(force: false);
}
}
private void SavePipelineCache(bool force)
{
if (_pipelineCache.Handle == 0 || string.IsNullOrWhiteSpace(_pipelineCachePath))
{
return;
}
if (!force && !_pipelineCacheDirty)
{
return;
}
try
{
nuint size = 0;
var result = _vk.GetPipelineCacheData(
_device,
_pipelineCache,
&size,
null);
if (result != Result.Success || size == 0 || size > 256u * 1024u * 1024u)
{
Console.Error.WriteLine(
$"[LOADER][WARN] Vulkan pipeline cache query failed: result={result} size={size}");
return;
}
var data = new byte[checked((int)size)];
fixed (byte* dataPointer = data)
{
result = _vk.GetPipelineCacheData(
_device,
_pipelineCache,
&size,
dataPointer);
}
if (result != Result.Success)
{
Console.Error.WriteLine(
$"[LOADER][WARN] Vulkan pipeline cache export failed: {result}");
return;
}
if (size != (nuint)data.Length)
{
Array.Resize(ref data, checked((int)size));
}
var directory = Path.GetDirectoryName(_pipelineCachePath);
if (!string.IsNullOrWhiteSpace(directory))
{
Directory.CreateDirectory(directory);
}
var temporaryPath = _pipelineCachePath + $".{Environment.ProcessId}.tmp";
File.WriteAllBytes(temporaryPath, data);
File.Move(temporaryPath, _pipelineCachePath, overwrite: true);
_pipelineCacheDirty = false;
_lastPipelineCacheSaveTick = Environment.TickCount64;
Console.Error.WriteLine(
$"[LOADER][INFO] Vulkan pipeline cache saved: path={_pipelineCachePath} bytes={data.Length}");
}
catch (Exception exception)
{
Console.Error.WriteLine(
$"[LOADER][WARN] Vulkan pipeline cache save failed: {exception.Message}");
}
}
private void CreateSwapchain()
{
Check(
_surfaceApi.GetPhysicalDeviceSurfaceCapabilities(_physicalDevice, _surface, out var capabilities),
"vkGetPhysicalDeviceSurfaceCapabilitiesKHR");
uint formatCount = 0;
Check(
_surfaceApi.GetPhysicalDeviceSurfaceFormats(_physicalDevice, _surface, &formatCount, null),
"vkGetPhysicalDeviceSurfaceFormatsKHR");
var formats = new SurfaceFormatKHR[formatCount];
fixed (SurfaceFormatKHR* formatPointer = formats)
{
Check(
_surfaceApi.GetPhysicalDeviceSurfaceFormats(_physicalDevice, _surface, &formatCount, formatPointer),
"vkGetPhysicalDeviceSurfaceFormatsKHR");
}
var surfaceFormat = ChooseSurfaceFormat(formats);
_swapchainFormat = surfaceFormat.Format;
_extent = ChooseExtent(capabilities);
var presentMode = ChoosePresentMode();
var imageCount = capabilities.MinImageCount + 1;
if (capabilities.MaxImageCount != 0)
{
imageCount = Math.Min(imageCount, capabilities.MaxImageCount);
}
var compositeAlpha = ChooseCompositeAlpha(capabilities.SupportedCompositeAlpha);
var createInfo = new SwapchainCreateInfoKHR
{
SType = StructureType.SwapchainCreateInfoKhr,
Surface = _surface,
MinImageCount = imageCount,
ImageFormat = surfaceFormat.Format,
ImageColorSpace = surfaceFormat.ColorSpace,
ImageExtent = _extent,
ImageArrayLayers = 1,
ImageUsage =
ImageUsageFlags.TransferDstBit |
ImageUsageFlags.TransferSrcBit |
ImageUsageFlags.ColorAttachmentBit,
ImageSharingMode = SharingMode.Exclusive,
PreTransform = capabilities.CurrentTransform,
CompositeAlpha = compositeAlpha,
PresentMode = presentMode,
Clipped = true,
};
Check(_swapchainApi.CreateSwapchain(_device, &createInfo, null, out _swapchain), "vkCreateSwapchainKHR");
uint swapchainImageCount = 0;
Check(
_swapchainApi.GetSwapchainImages(_device, _swapchain, &swapchainImageCount, null),
"vkGetSwapchainImagesKHR");
_swapchainImages = new Image[swapchainImageCount];
fixed (Image* imagePointer = _swapchainImages)
{
Check(
_swapchainApi.GetSwapchainImages(_device, _swapchain, &swapchainImageCount, imagePointer),
"vkGetSwapchainImagesKHR");
}
_imageInitialized = new bool[swapchainImageCount];
}
private PresentModeKHR ChoosePresentMode()
{
// MAILBOX never blocks vkQueuePresentKHR on vblank, so a slow
// frame does not quantize the frame rate down to 30/20 fps the
// way FIFO does; the guest side is already paced by PaceFlip.
// FIFO is the only mode guaranteed by the spec and remains the
// fallback (MoltenVK typically exposes FIFO + IMMEDIATE only).
uint modeCount = 0;
if (_surfaceApi.GetPhysicalDeviceSurfacePresentModes(
_physicalDevice,
_surface,
&modeCount,
null) != Result.Success ||
modeCount == 0)
{
return PresentModeKHR.FifoKhr;
}
var modes = stackalloc PresentModeKHR[(int)modeCount];
if (_surfaceApi.GetPhysicalDeviceSurfacePresentModes(
_physicalDevice,
_surface,
&modeCount,
modes) != Result.Success)
{
return PresentModeKHR.FifoKhr;
}
for (var index = 0u; index < modeCount; index++)
{
if (modes[index] == PresentModeKHR.MailboxKhr)
{
return PresentModeKHR.MailboxKhr;
}
}
return PresentModeKHR.FifoKhr;
}
private void CreateCommandResources()
{
var poolInfo = new CommandPoolCreateInfo
{
SType = StructureType.CommandPoolCreateInfo,
Flags = CommandPoolCreateFlags.ResetCommandBufferBit,
QueueFamilyIndex = _queueFamilyIndex,
};
Check(_vk.CreateCommandPool(_device, &poolInfo, null, out _commandPool), "vkCreateCommandPool");
var allocateInfo = new CommandBufferAllocateInfo
{
SType = StructureType.CommandBufferAllocateInfo,
CommandPool = _commandPool,
Level = CommandBufferLevel.Primary,
CommandBufferCount = MaxFramesInFlight,
};
_frameCommandBuffers = new CommandBuffer[MaxFramesInFlight];
fixed (CommandBuffer* frameCommandBuffers = _frameCommandBuffers)
{
Check(
_vk.AllocateCommandBuffers(_device, &allocateInfo, frameCommandBuffers),
"vkAllocateCommandBuffers");
}
var semaphoreInfo = new SemaphoreCreateInfo
{
SType = StructureType.SemaphoreCreateInfo,
};
var fenceInfo = new FenceCreateInfo
{
SType = StructureType.FenceCreateInfo,
};
_frameImageAvailable = new VkSemaphore[MaxFramesInFlight];
_frameFences = new Fence[MaxFramesInFlight];
_frameFencePending = new bool[MaxFramesInFlight];
_frameTimelines = new ulong[MaxFramesInFlight];
_frameTranslatedResources = new TranslatedDrawResources?[MaxFramesInFlight];
_frameGuestImageVersions = new GuestImageResource?[MaxFramesInFlight];
for (var slot = 0; slot < MaxFramesInFlight; slot++)
{
Check(
_vk.CreateSemaphore(_device, &semaphoreInfo, null, out _frameImageAvailable[slot]),
"vkCreateSemaphore");
Check(
_vk.CreateFence(_device, &fenceInfo, null, out _frameFences[slot]),
"vkCreateFence(frame)");
}
_renderFinishedPerImage = new VkSemaphore[_swapchainImages.Length];
for (var image = 0; image < _renderFinishedPerImage.Length; image++)
{
Check(
_vk.CreateSemaphore(_device, &semaphoreInfo, null, out _renderFinishedPerImage[image]),
"vkCreateSemaphore");
}
_currentFrameSlot = 0;
_commandBuffer = _frameCommandBuffers[0];
_presentationCommandBuffer = _commandBuffer;
CreateStagingBuffer((ulong)_extent.Width * _extent.Height * 4);
CreateOverlayResources();
}
private void CreateOverlayResources()
{
const ulong overlayBytes = PerfOverlay.PanelWidth * PerfOverlay.PanelHeight * 4;
var imageInfo = new ImageCreateInfo
{
SType = StructureType.ImageCreateInfo,
ImageType = ImageType.Type2D,
Format = Format.B8G8R8A8Unorm,
Extent = new Extent3D(PerfOverlay.PanelWidth, PerfOverlay.PanelHeight, 1),
MipLevels = 1,
ArrayLayers = 1,
Samples = SampleCountFlags.Count1Bit,
Tiling = ImageTiling.Optimal,
Usage = ImageUsageFlags.TransferDstBit | ImageUsageFlags.TransferSrcBit,
SharingMode = SharingMode.Exclusive,
InitialLayout = ImageLayout.Undefined,
};
Check(_vk.CreateImage(_device, &imageInfo, null, out _overlayImage), "vkCreateImage(overlay)");
_vk.GetImageMemoryRequirements(_device, _overlayImage, out var requirements);
var memoryInfo = new MemoryAllocateInfo
{
SType = StructureType.MemoryAllocateInfo,
AllocationSize = requirements.Size,
MemoryTypeIndex = FindMemoryType(
requirements.MemoryTypeBits,
MemoryPropertyFlags.DeviceLocalBit),
};
Check(
_vk.AllocateMemory(_device, &memoryInfo, null, out _overlayImageMemory),
"vkAllocateMemory(overlay)");
Check(
_vk.BindImageMemory(_device, _overlayImage, _overlayImageMemory, 0),
"vkBindImageMemory(overlay)");
_overlayImageInitialized = false;
_overlayStagingBuffers = new VkBuffer[MaxFramesInFlight];
_overlayStagingMemory = new DeviceMemory[MaxFramesInFlight];
_overlayStagingMapped = new nint[MaxFramesInFlight];
for (var slot = 0; slot < MaxFramesInFlight; slot++)
{
_overlayStagingBuffers[slot] = CreateBuffer(
overlayBytes,
BufferUsageFlags.TransferSrcBit,
MemoryPropertyFlags.HostVisibleBit | MemoryPropertyFlags.HostCoherentBit,
out _overlayStagingMemory[slot]);
void* mapped;
Check(
_vk.MapMemory(_device, _overlayStagingMemory[slot], 0, overlayBytes, 0, &mapped),
"vkMapMemory(overlay staging)");
_overlayStagingMapped[slot] = (nint)mapped;
}
}
private void RecordOverlayBlit(uint imageIndex, int frameSlot)
{
if (_overlayImage.Handle == 0 || _overlayStagingMapped.Length <= frameSlot)
{
return;
}
int pendingWork;
lock (_gate)
{
pendingWork = _pendingGuestWorkCount;
}
var pixels = new Span<byte>(
(void*)_overlayStagingMapped[frameSlot],
PerfOverlay.PanelWidth * PerfOverlay.PanelHeight * 4);
PerfOverlay.Fill(pixels, pendingWork, _pendingGuestSubmissions.Count);
var toTransferDst = new ImageMemoryBarrier
{
SType = StructureType.ImageMemoryBarrier,
SrcAccessMask = _overlayImageInitialized ? AccessFlags.TransferReadBit : 0,
DstAccessMask = AccessFlags.TransferWriteBit,
OldLayout = _overlayImageInitialized
? ImageLayout.TransferSrcOptimal
: ImageLayout.Undefined,
NewLayout = ImageLayout.TransferDstOptimal,
SrcQueueFamilyIndex = Vk.QueueFamilyIgnored,
DstQueueFamilyIndex = Vk.QueueFamilyIgnored,
Image = _overlayImage,
SubresourceRange = ColorSubresourceRange(),
};
_vk.CmdPipelineBarrier(
_commandBuffer,
PipelineStageFlags.TransferBit,
PipelineStageFlags.TransferBit,
0, 0, null, 0, null, 1, &toTransferDst);
var copyRegion = new BufferImageCopy
{
ImageSubresource = new ImageSubresourceLayers(ImageAspectFlags.ColorBit, 0, 0, 1),
ImageExtent = new Extent3D(PerfOverlay.PanelWidth, PerfOverlay.PanelHeight, 1),
};
_vk.CmdCopyBufferToImage(
_commandBuffer,
_overlayStagingBuffers[frameSlot],
_overlayImage,
ImageLayout.TransferDstOptimal,
1,
&copyRegion);
var toTransferSrc = new ImageMemoryBarrier
{
SType = StructureType.ImageMemoryBarrier,
SrcAccessMask = AccessFlags.TransferWriteBit,
DstAccessMask = AccessFlags.TransferReadBit,
OldLayout = ImageLayout.TransferDstOptimal,
NewLayout = ImageLayout.TransferSrcOptimal,
SrcQueueFamilyIndex = Vk.QueueFamilyIgnored,
DstQueueFamilyIndex = Vk.QueueFamilyIgnored,
Image = _overlayImage,
SubresourceRange = ColorSubresourceRange(),
};
var swapchainToDst = new ImageMemoryBarrier
{
SType = StructureType.ImageMemoryBarrier,
SrcAccessMask = 0,
DstAccessMask = AccessFlags.TransferWriteBit,
OldLayout = ImageLayout.PresentSrcKhr,
NewLayout = ImageLayout.TransferDstOptimal,
SrcQueueFamilyIndex = Vk.QueueFamilyIgnored,
DstQueueFamilyIndex = Vk.QueueFamilyIgnored,
Image = _swapchainImages[imageIndex],
SubresourceRange = ColorSubresourceRange(),
};
var preBlitBarriers = stackalloc ImageMemoryBarrier[2] { toTransferSrc, swapchainToDst };
_vk.CmdPipelineBarrier(
_commandBuffer,
PipelineStageFlags.TransferBit | PipelineStageFlags.ColorAttachmentOutputBit,
PipelineStageFlags.TransferBit,
0, 0, null, 0, null, 2, preBlitBarriers);
const int margin = 12;
var panelWidth = (int)Math.Min(PerfOverlay.PanelWidth, _extent.Width - margin);
var panelHeight = (int)Math.Min(PerfOverlay.PanelHeight, _extent.Height - margin);
// Source and destination are both B8G8R8A8 and the panel is not
// scaled. MoltenVK has corrupted pixels outside the blit region
// for this transfer-on-swapchain path (horizontal red/yellow
// scanlines across the entire window). An exact image copy has
// the required semantics and avoids the driver's blit conversion
// path altogether.
var copy = new ImageCopy
{
SrcSubresource = new ImageSubresourceLayers(ImageAspectFlags.ColorBit, 0, 0, 1),
DstSubresource = new ImageSubresourceLayers(ImageAspectFlags.ColorBit, 0, 0, 1),
SrcOffset = new Offset3D(0, 0, 0),
DstOffset = new Offset3D(margin, margin, 0),
Extent = new Extent3D((uint)panelWidth, (uint)panelHeight, 1),
};
_vk.CmdCopyImage(
_commandBuffer,
_overlayImage,
ImageLayout.TransferSrcOptimal,
_swapchainImages[imageIndex],
ImageLayout.TransferDstOptimal,
1,
&copy);
var swapchainToPresent = new ImageMemoryBarrier
{
SType = StructureType.ImageMemoryBarrier,
SrcAccessMask = AccessFlags.TransferWriteBit,
DstAccessMask = 0,
OldLayout = ImageLayout.TransferDstOptimal,
NewLayout = ImageLayout.PresentSrcKhr,
SrcQueueFamilyIndex = Vk.QueueFamilyIgnored,
DstQueueFamilyIndex = Vk.QueueFamilyIgnored,
Image = _swapchainImages[imageIndex],
SubresourceRange = ColorSubresourceRange(),
};
_vk.CmdPipelineBarrier(
_commandBuffer,
PipelineStageFlags.TransferBit,
PipelineStageFlags.BottomOfPipeBit,
0, 0, null, 0, null, 1, &swapchainToPresent);
_overlayImageInitialized = true;
}
private CommandBuffer AllocateGuestCommandBuffer()
{
// The pool has ResetCommandBufferBit, so vkBeginCommandBuffer
// implicitly resets recycled buffers.
if (_recycledGuestCommandBuffers.TryPop(out var recycled))
{
return recycled;
}
var allocateInfo = new CommandBufferAllocateInfo
{
SType = StructureType.CommandBufferAllocateInfo,
CommandPool = _commandPool,
Level = CommandBufferLevel.Primary,
CommandBufferCount = 1,
};
CommandBuffer commandBuffer;
Check(
_vk.AllocateCommandBuffers(
_device,
&allocateInfo,
out commandBuffer),
"vkAllocateCommandBuffers(guest)");
return commandBuffer;
}
private Fence AcquireGuestFence()
{
// Recycled fences were reset when they were collected.
if (_recycledGuestFences.TryPop(out var recycled))
{
return recycled;
}
var fenceInfo = new FenceCreateInfo
{
SType = StructureType.FenceCreateInfo,
};
Fence fence;
Check(
_vk.CreateFence(_device, &fenceInfo, null, out fence),
"vkCreateFence(guest)");
return fence;
}
private void ReleaseGuestCommandBuffer(CommandBuffer commandBuffer)
{
if (_recycledGuestCommandBuffers.Count < MaxRecycledGuestCommandBuffers)
{
_recycledGuestCommandBuffers.Push(commandBuffer);
return;
}
_vk.FreeCommandBuffers(_device, _commandPool, 1, &commandBuffer);
}
private void ReleaseGuestFence(Fence fence, bool needsReset)
{
if (_recycledGuestFences.Count < MaxRecycledGuestFences)
{
if (needsReset)
{
Check(_vk.ResetFences(_device, 1, &fence), "vkResetFences(guest)");
}
_recycledGuestFences.Push(fence);
return;
}
_vk.DestroyFence(_device, fence, null);
}
// Translated draws are recorded into a shared command buffer and
// submitted once per drained work batch: on MoltenVK every
// vkQueueSubmit is a Metal command-buffer commit (~0.8ms), which used
// to be paid per draw and dominated the frame time.
private CommandBuffer _batchCommandBuffer;
private bool _batchOpen;
private int _batchDrawCount;
private readonly List<TranslatedDrawResources> _batchResources = new();
private readonly List<GuestImageResource> _batchTraceImages = new();
// Consecutive draws into the same target stay inside one render pass:
// on MoltenVK every render pass is a Metal render encoder, and one
// encoder per draw was the dominant per-draw fixed cost after submit
// batching. The pass closes when the target changes, when a draw
// needs transfer/storage work outside a pass, or when the batch
// flushes.
private GuestImageResource? _openPassTarget;
private void CloseOpenTranslatedRenderPass()
{
if (_openPassTarget is not { } target)
{
return;
}
_openPassTarget = null;
_vk.CmdEndRenderPass(_batchCommandBuffer);
var toShaderRead = new ImageMemoryBarrier
{
SType = StructureType.ImageMemoryBarrier,
SrcAccessMask = AccessFlags.ColorAttachmentWriteBit,
DstAccessMask = AccessFlags.ShaderReadBit,
OldLayout = ImageLayout.ColorAttachmentOptimal,
NewLayout = ImageLayout.ShaderReadOnlyOptimal,
SrcQueueFamilyIndex = Vk.QueueFamilyIgnored,
DstQueueFamilyIndex = Vk.QueueFamilyIgnored,
Image = target.Image,
SubresourceRange = ColorSubresourceRange(),
};
_vk.CmdPipelineBarrier(
_batchCommandBuffer,
PipelineStageFlags.ColorAttachmentOutputBit,
PipelineStageFlags.FragmentShaderBit,
0,
0,
null,
0,
null,
1,
&toShaderRead);
}
private CommandBuffer BeginBatchedGuestCommands()
{
if (_batchOpen)
{
return _batchCommandBuffer;
}
_batchCommandBuffer = AllocateGuestCommandBuffer();
var beginInfo = new CommandBufferBeginInfo
{
SType = StructureType.CommandBufferBeginInfo,
Flags = CommandBufferUsageFlags.OneTimeSubmitBit,
};
Check(
_vk.BeginCommandBuffer(_batchCommandBuffer, &beginInfo),
"vkBeginCommandBuffer(batch)");
_batchOpen = true;
_batchDrawCount = 0;
return _batchCommandBuffer;
}
private void FlushBatchedGuestCommands()
{
if (!_batchOpen)
{
return;
}
CloseOpenTranslatedRenderPass();
_batchOpen = false;
try
{
Check(_vk.EndCommandBuffer(_batchCommandBuffer), "vkEndCommandBuffer(batch)");
SubmitGuestCommandBuffer(
_batchCommandBuffer,
_batchResources.ToArray(),
_batchTraceImages.ToArray(),
_batchRetireBuffers.Count > 0 ? _batchRetireBuffers.ToArray() : []);
}
catch
{
// The batch never reached the queue: release everything it
// owned here so the stale lists cannot ride into the next
// batch's submission.
foreach (var resources in _batchResources)
{
DestroyTranslatedDrawResources(resources);
}
foreach (var (buffer, memory) in _batchRetireBuffers)
{
_vk.DestroyBuffer(_device, buffer, null);
_vk.FreeMemory(_device, memory, null);
}
ReleaseGuestCommandBuffer(_batchCommandBuffer);
throw;
}
finally
{
_batchResources.Clear();
_batchTraceImages.Clear();
_batchRetireBuffers.Clear();
_batchCommandBuffer = default;
}
}
private void SubmitGuestCommandBuffer(
CommandBuffer commandBuffer,
IReadOnlyList<TranslatedDrawResources> resources,
IReadOnlyList<GuestImageResource> traceImages,
IReadOnlyList<(VkBuffer Buffer, DeviceMemory Memory)>? retireBuffers = null,
IReadOnlyList<TranslatedDrawResources>? referencedResources = null)
{
var fence = AcquireGuestFence();
try
{
var submitInfo = new SubmitInfo
{
SType = StructureType.SubmitInfo,
CommandBufferCount = 1,
PCommandBuffers = &commandBuffer,
};
Check(
_vk.QueueSubmit(_queue, 1, &submitInfo, fence),
"vkQueueSubmit(guest)");
}
catch
{
ReleaseGuestFence(fence, needsReset: false);
throw;
}
_submitTimeline++;
foreach (var referenced in referencedResources ?? resources)
{
foreach (var globalBuffer in referenced.GlobalMemoryBuffers)
{
if (globalBuffer.Allocation is not { } allocation)
{
continue;
}
allocation.LastUseTimeline = Math.Max(
allocation.LastUseTimeline,
_submitTimeline);
if (globalBuffer.Writable && globalBuffer.WriteBackToGuest)
{
MarkGuestBufferDirty(
allocation,
globalBuffer.GuestOffset,
globalBuffer.GuestSize);
}
}
}
_pendingGuestSubmissions.Enqueue(
new PendingGuestSubmission(
fence,
commandBuffer,
resources,
traceImages,
retireBuffers ?? [],
_submitTimeline,
resources.Count > 0 ? resources[0].DebugName : "batch",
_activeGuestQueue,
_activeGuestWorkSequence));
_lastSubmittedTimelineByGuestQueue[_activeGuestQueue.Name] =
_submitTimeline;
}
private void TransitionNewGuestImageToSampled(Image image, uint mipLevels)
{
var commandBuffer = AllocateGuestCommandBuffer();
var beginInfo = new CommandBufferBeginInfo
{
SType = StructureType.CommandBufferBeginInfo,
Flags = CommandBufferUsageFlags.OneTimeSubmitBit,
};
Check(
_vk.BeginCommandBuffer(commandBuffer, &beginInfo),
"vkBeginCommandBuffer(guest image init)");
var barrier = new ImageMemoryBarrier
{
SType = StructureType.ImageMemoryBarrier,
SrcAccessMask = 0,
DstAccessMask = AccessFlags.ShaderReadBit,
OldLayout = ImageLayout.Undefined,
NewLayout = ImageLayout.ShaderReadOnlyOptimal,
SrcQueueFamilyIndex = Vk.QueueFamilyIgnored,
DstQueueFamilyIndex = Vk.QueueFamilyIgnored,
Image = image,
SubresourceRange = ColorSubresourceRange(0, mipLevels),
};
_vk.CmdPipelineBarrier(
commandBuffer,
PipelineStageFlags.TopOfPipeBit,
PipelineStageFlags.AllCommandsBit,
0,
0,
null,
0,
null,
1,
&barrier);
Check(
_vk.EndCommandBuffer(commandBuffer),
"vkEndCommandBuffer(guest image init)");
// Same-queue submission order makes the transition visible to any
// later use of the image; no CPU-side wait is needed.
SubmitGuestCommandBuffer(commandBuffer, [], []);
}
private void EnsureGuestSubmissionCapacity()
{
CollectCompletedGuestSubmissions(waitForOldest: false);
if (_pendingGuestSubmissions.Count >= MaxInFlightGuestSubmissions)
{
// Bounded wait so the macOS main thread returns to its event
// pump promptly under a slow-compute backlog; if the oldest
// isn't done yet we proceed (soft cap, dynamic pools).
CollectCompletedGuestSubmissions(
waitForOldest: true,
maxWaitNs: _submissionCapacityWaitNs == 0
? _guestFenceWaitTimeoutNs
: _submissionCapacityWaitNs);
}
}
private void WaitForAllGuestSubmissions()
{
while (_pendingGuestSubmissions.Count != 0)
{
CollectCompletedGuestSubmissions(waitForOldest: true);
}
}
private void CollectCompletedGuestSubmissions(bool waitForOldest, ulong maxWaitNs = 0)
{
if (waitForOldest && _pendingGuestSubmissions.TryPeek(out var oldest))
{
var fence = oldest.Fence;
// maxWaitNs==0 => the full "is this submission hung" timeout,
// which also emits the one-shot hang warning below. A shorter
// capacity-probe wait (maxWaitNs>0) must NOT report a hang: the
// submission is still tracked and will be collected once the GPU
// finishes it on a later frame.
var isProbeWait = maxWaitNs != 0 && maxWaitNs < _guestFenceWaitTimeoutNs;
var waitNs = maxWaitNs != 0 ? maxWaitNs : _guestFenceWaitTimeoutNs;
var result = _vk.WaitForFences(
_device,
1,
&fence,
true,
waitNs);
if (result == Result.Timeout)
{
// A GPU submission whose fence never signals (typically a
// mistranslated compute shader that hangs the Metal queue)
// would otherwise block the render thread forever, starving
// the swapchain present (black screen). Log the culprit and
// continue so at least the last good frame can be shown.
if (!isProbeWait && _tracedFenceTimeouts.Add(oldest.DebugName))
{
Console.Error.WriteLine(
$"[LOADER][WARN] vk.fence_wait_timeout submission='{oldest.DebugName}' " +
$"— GPU work not completing after {_guestFenceWaitTimeoutNs / 1_000_000}ms; " +
"render thread continuing (present not blocked).");
}
return;
}
if (result == Result.ErrorDeviceLost)
{
_deviceLost = true;
}
else
{
Check(result, $"vkWaitForFences(guest: {oldest.DebugName})");
}
}
while (_pendingGuestSubmissions.TryPeek(out var submission))
{
var status = _vk.GetFenceStatus(_device, submission.Fence);
if (status == Result.NotReady && !_deviceLost)
{
break;
}
if (status == Result.ErrorDeviceLost)
{
// Pending fences never signal on a lost device; retire the
// submission anyway so teardown and back-pressure survive.
_deviceLost = true;
}
else if (status != Result.NotReady)
{
Check(status, $"vkGetFenceStatus(guest: {submission.DebugName})");
}
_pendingGuestSubmissions.Dequeue();
if (!_deviceLost)
{
foreach (var image in submission.TraceImages)
{
TraceGuestImageContents(image);
}
}
foreach (var resources in submission.Resources)
{
DestroyTranslatedDrawResources(resources);
}
foreach (var (buffer, memory) in submission.RetireBuffers)
{
_vk.DestroyBuffer(_device, buffer, null);
_vk.FreeMemory(_device, memory, null);
}
ReleaseGuestCommandBuffer(submission.CommandBuffer);
ReleaseGuestFence(submission.Fence, needsReset: true);
if (submission.Timeline > _completedTimeline)
{
_completedTimeline = submission.Timeline;
}
}
ProcessDeferredTextureDestroys();
}
private void WaitForAllGuestSubmissionsForCpuVisibility()
{
FlushBatchedGuestCommands();
while (_pendingGuestSubmissions.TryPeek(out var oldest))
{
var fence = oldest.Fence;
Check(
_vk.WaitForFences(_device, 1, &fence, true, ulong.MaxValue),
$"vkWaitForFences(cpu visibility: {oldest.DebugName})");
CollectCompletedGuestSubmissions(waitForOldest: false);
}
}
private void WaitForActiveGuestQueueSubmissionsForCpuVisibility()
{
FlushBatchedGuestCommands();
if (!_lastSubmittedTimelineByGuestQueue.TryGetValue(
_activeGuestQueue.Name,
out var targetTimeline) ||
targetTimeline <= _completedTimeline)
{
return;
}
PendingGuestSubmission? target = null;
foreach (var submission in _pendingGuestSubmissions)
{
if (submission.Timeline == targetTimeline)
{
target = submission;
break;
}
}
if (target is null)
{
throw new InvalidOperationException(
$"Guest queue '{_activeGuestQueue.Name}' lost pending timeline " +
$"{targetTimeline} (completed={_completedTimeline}).");
}
var waitStart = System.Diagnostics.Stopwatch.GetTimestamp();
var fence = target.Fence;
Check(
_vk.WaitForFences(_device, 1, &fence, true, ulong.MaxValue),
$"vkWaitForFences(queue visibility: {_activeGuestQueue.Name})");
CollectCompletedGuestSubmissions(waitForOldest: false);
var waitedMs = (System.Diagnostics.Stopwatch.GetTimestamp() - waitStart) *
1000.0 / System.Diagnostics.Stopwatch.Frequency;
if (_traceVulkanShaderEnabled)
{
TraceVulkanShader(
$"vk.queue_visibility queue={_activeGuestQueue.Name} " +
$"submission={_activeGuestQueue.SubmissionId} " +
$"target_timeline={targetTimeline} completed_timeline={_completedTimeline} " +
$"waited_ms={waitedMs:F3}");
}
}
private void ExecuteOrderedGuestAction(VulkanOrderedGuestAction work)
{
WaitForActiveGuestQueueSubmissionsForCpuVisibility();
WriteBackAllDirtyGuestBuffers(_activeGuestQueue.Name);
work.Action();
if (_traceVulkanShaderEnabled)
{
TraceVulkanShader(
$"vk.ordered_action queue={_activeGuestQueue.Name} " +
$"submission={_activeGuestQueue.SubmissionId} " +
$"work_sequence={_activeGuestWorkSequence} name='{work.DebugName}'");
}
}
private void ExecuteOrderedGuestFlip(VulkanOrderedGuestFlip work)
{
FlushBatchedGuestCommands();
_guestImages.TryGetValue(work.Address, out var source);
if (_deviceLost ||
source is null ||
!source.Initialized)
{
Console.Error.WriteLine(
$"[LOADER][WARN] vk.flip_capture_failed version={work.Version} " +
$"queue={_activeGuestQueue.Name} addr=0x{work.Address:X16} " +
$"found={(source is not null)} initialized={(source?.Initialized ?? false)}");
return;
}
EnsureGuestSubmissionCapacity();
var snapshot = CreateGuestFlipSnapshot(source, work.Version);
var commandBuffer = AllocateGuestCommandBuffer();
var submitted = false;
try
{
var beginInfo = new CommandBufferBeginInfo
{
SType = StructureType.CommandBufferBeginInfo,
Flags = CommandBufferUsageFlags.OneTimeSubmitBit,
};
Check(
_vk.BeginCommandBuffer(commandBuffer, &beginInfo),
"vkBeginCommandBuffer(flip capture)");
var barriers = stackalloc ImageMemoryBarrier[2];
barriers[0] = new ImageMemoryBarrier
{
SType = StructureType.ImageMemoryBarrier,
SrcAccessMask = AccessFlags.ShaderReadBit |
AccessFlags.ShaderWriteBit |
AccessFlags.ColorAttachmentWriteBit |
AccessFlags.TransferWriteBit,
DstAccessMask = AccessFlags.TransferReadBit,
OldLayout = ImageLayout.ShaderReadOnlyOptimal,
NewLayout = ImageLayout.TransferSrcOptimal,
SrcQueueFamilyIndex = Vk.QueueFamilyIgnored,
DstQueueFamilyIndex = Vk.QueueFamilyIgnored,
Image = source.Image,
SubresourceRange = ColorSubresourceRange(),
};
barriers[1] = new ImageMemoryBarrier
{
SType = StructureType.ImageMemoryBarrier,
SrcAccessMask = 0,
DstAccessMask = AccessFlags.TransferWriteBit,
OldLayout = ImageLayout.Undefined,
NewLayout = ImageLayout.TransferDstOptimal,
SrcQueueFamilyIndex = Vk.QueueFamilyIgnored,
DstQueueFamilyIndex = Vk.QueueFamilyIgnored,
Image = snapshot.Image,
SubresourceRange = ColorSubresourceRange(),
};
_vk.CmdPipelineBarrier(
commandBuffer,
PipelineStageFlags.AllCommandsBit,
PipelineStageFlags.TransferBit,
0,
0,
null,
0,
null,
2,
barriers);
var copy = new ImageCopy
{
SrcSubresource = new ImageSubresourceLayers(
ImageAspectFlags.ColorBit, 0, 0, 1),
DstSubresource = new ImageSubresourceLayers(
ImageAspectFlags.ColorBit, 0, 0, 1),
Extent = new Extent3D(source.Width, source.Height, 1),
};
_vk.CmdCopyImage(
commandBuffer,
source.Image,
ImageLayout.TransferSrcOptimal,
snapshot.Image,
ImageLayout.TransferDstOptimal,
1,
&copy);
barriers[0] = new ImageMemoryBarrier
{
SType = StructureType.ImageMemoryBarrier,
SrcAccessMask = AccessFlags.TransferReadBit,
DstAccessMask = AccessFlags.ShaderReadBit |
AccessFlags.ShaderWriteBit |
AccessFlags.ColorAttachmentWriteBit,
OldLayout = ImageLayout.TransferSrcOptimal,
NewLayout = ImageLayout.ShaderReadOnlyOptimal,
SrcQueueFamilyIndex = Vk.QueueFamilyIgnored,
DstQueueFamilyIndex = Vk.QueueFamilyIgnored,
Image = source.Image,
SubresourceRange = ColorSubresourceRange(),
};
barriers[1] = new ImageMemoryBarrier
{
SType = StructureType.ImageMemoryBarrier,
SrcAccessMask = AccessFlags.TransferWriteBit,
DstAccessMask = AccessFlags.ShaderReadBit,
OldLayout = ImageLayout.TransferDstOptimal,
NewLayout = ImageLayout.ShaderReadOnlyOptimal,
SrcQueueFamilyIndex = Vk.QueueFamilyIgnored,
DstQueueFamilyIndex = Vk.QueueFamilyIgnored,
Image = snapshot.Image,
SubresourceRange = ColorSubresourceRange(),
};
_vk.CmdPipelineBarrier(
commandBuffer,
PipelineStageFlags.TransferBit,
PipelineStageFlags.AllCommandsBit,
0,
0,
null,
0,
null,
2,
barriers);
Check(
_vk.EndCommandBuffer(commandBuffer),
"vkEndCommandBuffer(flip capture)");
SubmitGuestCommandBuffer(commandBuffer, [], []);
submitted = true;
snapshot.Initialized = true;
_guestImageVersions.Add(work.Version, snapshot);
_capturedGuestFlipVersions.Add(work.Version);
lock (_gate)
{
var sequence = (_latestPresentation?.Sequence ?? 0) + 1;
var presentation = new Presentation(
null,
work.Width,
work.Height,
sequence,
GuestDrawKind.None,
TranslatedDraw: null,
RequiredGuestWorkSequence: _activeGuestWorkSequence,
IsSplash: false,
GuestImageAddress: work.Address,
GuestImageVersion: work.Version);
_latestPresentation = presentation;
_pendingGuestImagePresentations.Enqueue(presentation);
while (_pendingGuestImagePresentations.Count > MaxPendingGuestFlipVersions)
{
_pendingGuestImagePresentations.Dequeue();
}
}
CollectAbandonedGuestImageVersions();
var effectivePitch = work.PitchInPixel == 0
? work.Width
: work.PitchInPixel;
TraceVulkanShader(
$"vk.flip_capture version={work.Version} " +
$"queue={_activeGuestQueue.Name} submission={_activeGuestQueue.SubmissionId} " +
$"work_sequence={_activeGuestWorkSequence} addr=0x{work.Address:X16} " +
$"size={work.Width}x{work.Height} pitch={effectivePitch}");
}
finally
{
if (!submitted)
{
ReleaseGuestCommandBuffer(commandBuffer);
DestroyGuestImage(snapshot);
}
}
}
private void ExecuteOrderedGuestFlipWait(VulkanOrderedGuestFlipWait work)
{
var captured = work.Version != 0 &&
_capturedGuestFlipVersions.Contains(work.Version);
TraceVulkanShader(
$"vk.flip_wait_safe version={work.Version} " +
$"queue={_activeGuestQueue.Name} submission={_activeGuestQueue.SubmissionId} " +
$"handle={work.VideoOutHandle} index={work.DisplayBufferIndex} " +
$"capture_complete={(captured ? 1 : 0)}");
// Demon's Souls executes wait-safe markers before their flip capture;
// an assert here would fail-fast the process, so warn once instead.
// Dedup on a flag, not the (per-frame-unique) version, to bound growth.
if (work.Version != 0 && !captured && !_loggedFlipWaitOrderViolation)
{
_loggedFlipWaitOrderViolation = true;
Console.Error.WriteLine(
$"[LOADER][WARN] vk.flip_wait_order version={work.Version} " +
"executed before its flip capture; continuing.");
}
}
private bool _loggedFlipWaitOrderViolation;
private GuestImageResource CreateGuestFlipSnapshot(
GuestImageResource source,
long version)
{
var imageInfo = new ImageCreateInfo
{
SType = StructureType.ImageCreateInfo,
ImageType = ImageType.Type2D,
Format = source.Format,
Extent = new Extent3D(source.Width, source.Height, 1),
MipLevels = 1,
ArrayLayers = 1,
Samples = SampleCountFlags.Count1Bit,
Tiling = ImageTiling.Optimal,
Usage = ImageUsageFlags.TransferSrcBit |
ImageUsageFlags.TransferDstBit |
ImageUsageFlags.SampledBit,
SharingMode = SharingMode.Exclusive,
InitialLayout = ImageLayout.Undefined,
};
Check(
_vk.CreateImage(_device, &imageInfo, null, out var image),
"vkCreateImage(flip snapshot)");
_vk.GetImageMemoryRequirements(_device, image, out var requirements);
var allocationInfo = new MemoryAllocateInfo
{
SType = StructureType.MemoryAllocateInfo,
AllocationSize = requirements.Size,
MemoryTypeIndex = FindMemoryType(
requirements.MemoryTypeBits,
MemoryPropertyFlags.DeviceLocalBit),
};
DeviceMemory memory = default;
try
{
Check(
_vk.AllocateMemory(_device, &allocationInfo, null, out memory),
"vkAllocateMemory(flip snapshot)");
Check(
_vk.BindImageMemory(_device, image, memory, 0),
"vkBindImageMemory(flip snapshot)");
}
catch
{
if (memory.Handle != 0)
{
_vk.FreeMemory(_device, memory, null);
}
_vk.DestroyImage(_device, image, null);
throw;
}
SetDebugName(
ObjectType.Image,
image.Handle,
$"guest flip v{version} source 0x{source.Address:X16}");
return new GuestImageResource
{
Address = source.Address,
FlipVersion = version,
Width = source.Width,
Height = source.Height,
MipLevels = 1,
GuestFormat = source.GuestFormat,
Format = source.Format,
Image = image,
Memory = memory,
};
}
private static byte[]? TryReadGuestTexturePixels(GuestDrawTexture texture)
{
var memory = _guestMemory;
if (memory is null || texture.Address == 0)
{
return null;
}
var width = Math.Max(texture.Width, 1);
var height = Math.Max(texture.Height, 1);
var rowLength = texture.TileMode == 0
? Math.Max(texture.Pitch, width)
: width;
var byteCount = GetTextureByteCount(texture.Format, rowLength, height);
if (byteCount == 0 || byteCount > int.MaxValue)
{
return null;
}
var pixels = new byte[(int)byteCount];
return memory.TryRead(texture.Address, pixels) ? pixels : null;
}
/// <summary>
/// Returns a skipped draw's pooled data arrays: draws dropped before
/// resource creation would otherwise strand their rented buffers.
/// </summary>
private static void ReturnPooledGuestData(VulkanTranslatedGuestDraw draw)
{
foreach (var buffer in draw.GlobalMemoryBuffers)
{
if (buffer.Pooled)
{
GuestDataPool.Return(buffer.Data);
}
}
foreach (var buffer in draw.VertexBuffers)
{
if (buffer.Pooled)
{
GuestDataPool.Return(buffer.Data);
}
}
if (draw.IndexBuffer is { Pooled: true } indexBuffer)
{
GuestDataPool.Return(indexBuffer.Data);
}
}
private void ProcessDeferredTextureDestroys()
{
while (_deferredTextureDestroys.TryPeek(out var entry) &&
entry.RetireTimeline <= _completedTimeline)
{
_deferredTextureDestroys.Dequeue();
DestroyCachedTextureResource(entry.Texture);
}
while (_deferredResourceDestroys.TryPeek(out var resourceEntry) &&
resourceEntry.RetireTimeline <= _completedTimeline)
{
_deferredResourceDestroys.Dequeue();
DestroyTranslatedDrawResources(resourceEntry.Resources);
}
while (_deferredGuestImageVersionDestroys.TryPeek(out var imageEntry) &&
imageEntry.RetireTimeline <= _completedTimeline)
{
_deferredGuestImageVersionDestroys.Dequeue();
DestroyGuestImage(imageEntry.Image);
TraceVulkanShader(
$"vk.flip_retired version={imageEntry.Image.FlipVersion} " +
$"timeline={imageEntry.RetireTimeline} reason=presentation-dropped");
}
}
private void WaitFrameSlot(int slot) => TryWaitFrameSlot(slot, ulong.MaxValue);
// Returns false when the slot's fence is still unsignaled after
// timeoutNs (the GPU is behind, e.g. a slow-compute backlog). Callers
// on the macOS main thread must NOT wait forever here or the Cocoa
// event pump stalls and the window goes "Not Responding" (F1 overlay /
// close stop working). A bounded wait lets Render() skip the frame and
// return to the pump; the fence still signals later and the frame is
// retried.
private bool TryWaitFrameSlot(int slot, ulong timeoutNs)
{
if (_frameFencePending.Length <= slot || !_frameFencePending[slot])
{
if (_frameGuestImageVersions.Length > slot &&
_frameGuestImageVersions[slot] is { } unsubmittedVersion)
{
_frameGuestImageVersions[slot] = null;
_capturedGuestFlipVersions.Remove(unsubmittedVersion.FlipVersion);
DestroyGuestImage(unsubmittedVersion);
TraceVulkanShader(
$"vk.flip_retired version={unsubmittedVersion.FlipVersion} " +
$"frame_slot={slot} reason=frame-not-submitted");
}
return true;
}
var fence = _frameFences[slot];
var waitResult = _vk.WaitForFences(_device, 1, &fence, true, timeoutNs);
if (waitResult == Result.Timeout)
{
return false;
}
Check(waitResult, "vkWaitForFences(frame)");
Check(_vk.ResetFences(_device, 1, &fence), "vkResetFences(frame)");
_frameFencePending[slot] = false;
if (_frameTimelines[slot] > _completedTimeline)
{
_completedTimeline = _frameTimelines[slot];
}
if (_frameTranslatedResources[slot] is { } translated)
{
_frameTranslatedResources[slot] = null;
DestroyTranslatedDrawResources(translated);
}
if (_frameGuestImageVersions[slot] is { } guestImageVersion)
{
_frameGuestImageVersions[slot] = null;
_capturedGuestFlipVersions.Remove(guestImageVersion.FlipVersion);
DestroyGuestImage(guestImageVersion);
TraceVulkanShader(
$"vk.flip_retired version={guestImageVersion.FlipVersion} " +
$"frame_slot={slot} timeline={_frameTimelines[slot]}");
}
ProcessDeferredTextureDestroys();
return true;
}
private void WaitAllFrameSlots()
{
for (var slot = 0; slot < _frameFencePending.Length; slot++)
{
WaitFrameSlot(slot);
}
}
private void CollectAbandonedGuestImageVersions()
{
if (_guestImageVersions.Count == 0)
{
return;
}
HashSet<long> referencedVersions;
lock (_gate)
{
referencedVersions = _pendingGuestImagePresentations
.Select(static presentation => presentation.GuestImageVersion)
.Where(static version => version != 0)
.ToHashSet();
if (_latestPresentation is { GuestImageVersion: not 0 } latest)
{
referencedVersions.Add(latest.GuestImageVersion);
}
}
foreach (var entry in _guestImageVersions.ToArray())
{
if (referencedVersions.Contains(entry.Key))
{
continue;
}
_guestImageVersions.Remove(entry.Key);
_capturedGuestFlipVersions.Remove(entry.Key);
_deferredGuestImageVersionDestroys.Enqueue(
(entry.Value, _submitTimeline));
TraceVulkanShader(
$"vk.flip_retire_deferred version={entry.Key} " +
$"timeline={_submitTimeline} reason=presentation-dropped");
}
}
// Used on teardown paths that already drained the queue (device
// wait-idle): releases per-slot state without touching fences that
// were never submitted.
private void DrainFrameSlots()
{
WaitAllFrameSlots();
_completedTimeline = _submitTimeline;
ProcessDeferredTextureDestroys();
}
private IReadOnlyList<GuestImageResource> GetTraceImages(
TranslatedDrawResources resources,
IReadOnlyList<GuestImageResource>? renderTargets = null,
ulong shaderAddress = 0)
{
if (!_traceGuestImagesEnabled &&
!_traceGuestImageAddressFilterEnabled &&
GuestImageTraceInterval() is null)
{
return Array.Empty<GuestImageResource>();
}
var candidates = new HashSet<GuestImageResource>();
foreach (var renderTarget in renderTargets ?? [])
{
candidates.Add(renderTarget);
}
foreach (var texture in resources.Textures)
{
if ((texture.IsStorage || _traceGuestImageAddressFilterEnabled) &&
texture.GuestImage is { } image)
{
candidates.Add(image);
}
}
return candidates
.Where(image => ShouldTraceGuestImageContents(image, shaderAddress))
.ToArray();
}
private void CreateGuestDrawResources()
{
var colorAttachment = new AttachmentDescription
{
Format = _swapchainFormat,
Samples = SampleCountFlags.Count1Bit,
LoadOp = AttachmentLoadOp.Clear,
StoreOp = AttachmentStoreOp.Store,
StencilLoadOp = AttachmentLoadOp.DontCare,
StencilStoreOp = AttachmentStoreOp.DontCare,
InitialLayout = ImageLayout.Undefined,
FinalLayout = ImageLayout.PresentSrcKhr,
};
var colorReference = new AttachmentReference
{
Attachment = 0,
Layout = ImageLayout.ColorAttachmentOptimal,
};
var subpass = new SubpassDescription
{
PipelineBindPoint = PipelineBindPoint.Graphics,
ColorAttachmentCount = 1,
PColorAttachments = &colorReference,
};
var dependency = new SubpassDependency
{
SrcSubpass = Vk.SubpassExternal,
DstSubpass = 0,
SrcStageMask = PipelineStageFlags.ColorAttachmentOutputBit,
DstStageMask = PipelineStageFlags.ColorAttachmentOutputBit,
DstAccessMask = AccessFlags.ColorAttachmentWriteBit,
};
var renderPassInfo = new RenderPassCreateInfo
{
SType = StructureType.RenderPassCreateInfo,
AttachmentCount = 1,
PAttachments = &colorAttachment,
SubpassCount = 1,
PSubpasses = &subpass,
DependencyCount = 1,
PDependencies = &dependency,
};
Check(_vk.CreateRenderPass(_device, &renderPassInfo, null, out _renderPass), "vkCreateRenderPass");
_swapchainImageViews = new ImageView[_swapchainImages.Length];
_framebuffers = new Framebuffer[_swapchainImages.Length];
for (var index = 0; index < _swapchainImages.Length; index++)
{
var viewInfo = new ImageViewCreateInfo
{
SType = StructureType.ImageViewCreateInfo,
Image = _swapchainImages[index],
ViewType = ImageViewType.Type2D,
Format = _swapchainFormat,
Components = new ComponentMapping(
ComponentSwizzle.Identity,
ComponentSwizzle.Identity,
ComponentSwizzle.Identity,
ComponentSwizzle.Identity),
SubresourceRange = ColorSubresourceRange(),
};
Check(
_vk.CreateImageView(_device, &viewInfo, null, out _swapchainImageViews[index]),
"vkCreateImageView");
var imageView = _swapchainImageViews[index];
var framebufferInfo = new FramebufferCreateInfo
{
SType = StructureType.FramebufferCreateInfo,
RenderPass = _renderPass,
AttachmentCount = 1,
PAttachments = &imageView,
Width = _extent.Width,
Height = _extent.Height,
Layers = 1,
};
Check(
_vk.CreateFramebuffer(_device, &framebufferInfo, null, out _framebuffers[index]),
"vkCreateFramebuffer");
}
var layoutInfo = new PipelineLayoutCreateInfo
{
SType = StructureType.PipelineLayoutCreateInfo,
};
Check(
_vk.CreatePipelineLayout(_device, &layoutInfo, null, out _pipelineLayout),
"vkCreatePipelineLayout");
CreateBarycentricPipeline();
}
private void CreateBarycentricPipeline()
{
var vertexBytes = Convert.FromBase64String(FullscreenBarycentricVertexSpirv);
var fragmentBytes = Convert.FromBase64String(FullscreenBarycentricFragmentSpirv);
var vertexModule = CreateShaderModule(vertexBytes);
var fragmentModule = CreateShaderModule(fragmentBytes);
var entryPoint = (byte*)SilkMarshal.StringToPtr("main");
try
{
var shaderStages = stackalloc PipelineShaderStageCreateInfo[2];
shaderStages[0] = new PipelineShaderStageCreateInfo
{
SType = StructureType.PipelineShaderStageCreateInfo,
Stage = ShaderStageFlags.VertexBit,
Module = vertexModule,
PName = entryPoint,
};
shaderStages[1] = new PipelineShaderStageCreateInfo
{
SType = StructureType.PipelineShaderStageCreateInfo,
Stage = ShaderStageFlags.FragmentBit,
Module = fragmentModule,
PName = entryPoint,
};
var vertexInput = new PipelineVertexInputStateCreateInfo
{
SType = StructureType.PipelineVertexInputStateCreateInfo,
};
var inputAssembly = new PipelineInputAssemblyStateCreateInfo
{
SType = StructureType.PipelineInputAssemblyStateCreateInfo,
Topology = PrimitiveTopology.TriangleList,
};
var viewport = new Viewport(0, 0, _extent.Width, _extent.Height, 0, 1);
var scissor = new Rect2D(new Offset2D(0, 0), _extent);
var viewportState = new PipelineViewportStateCreateInfo
{
SType = StructureType.PipelineViewportStateCreateInfo,
ViewportCount = 1,
PViewports = &viewport,
ScissorCount = 1,
PScissors = &scissor,
};
var rasterization = new PipelineRasterizationStateCreateInfo
{
SType = StructureType.PipelineRasterizationStateCreateInfo,
PolygonMode = PolygonMode.Fill,
CullMode = CullModeFlags.None,
FrontFace = FrontFace.CounterClockwise,
LineWidth = 1,
};
var multisample = new PipelineMultisampleStateCreateInfo
{
SType = StructureType.PipelineMultisampleStateCreateInfo,
RasterizationSamples = SampleCountFlags.Count1Bit,
};
var colorBlendAttachment = new PipelineColorBlendAttachmentState
{
ColorWriteMask =
ColorComponentFlags.RBit |
ColorComponentFlags.GBit |
ColorComponentFlags.BBit |
ColorComponentFlags.ABit,
};
var colorBlend = new PipelineColorBlendStateCreateInfo
{
SType = StructureType.PipelineColorBlendStateCreateInfo,
AttachmentCount = 1,
PAttachments = &colorBlendAttachment,
};
var pipelineInfo = new GraphicsPipelineCreateInfo
{
SType = StructureType.GraphicsPipelineCreateInfo,
StageCount = 2,
PStages = shaderStages,
PVertexInputState = &vertexInput,
PInputAssemblyState = &inputAssembly,
PViewportState = &viewportState,
PRasterizationState = &rasterization,
PMultisampleState = &multisample,
PColorBlendState = &colorBlend,
Layout = _pipelineLayout,
RenderPass = _renderPass,
Subpass = 0,
};
Check(
_vk.CreateGraphicsPipelines(
_device,
_pipelineCache,
1,
&pipelineInfo,
null,
out _barycentricPipeline),
"vkCreateGraphicsPipelines");
MarkPipelineCacheDirty();
}
finally
{
SilkMarshal.Free((nint)entryPoint);
_vk.DestroyShaderModule(_device, fragmentModule, null);
_vk.DestroyShaderModule(_device, vertexModule, null);
}
}
private ShaderModule CreateShaderModule(byte[] code)
{
fixed (byte* codePointer = code)
{
var createInfo = new ShaderModuleCreateInfo
{
SType = StructureType.ShaderModuleCreateInfo,
CodeSize = (nuint)code.Length,
PCode = (uint*)codePointer,
};
Check(
_vk.CreateShaderModule(_device, &createInfo, null, out var module),
"vkCreateShaderModule");
return module;
}
}
private TranslatedDrawResources CreateTranslatedDrawResources(
VulkanTranslatedGuestDraw draw,
RenderPass renderPass,
IReadOnlyList<Format> renderTargetFormats,
Extent2D extent,
IReadOnlyList<GuestImageResource>? feedbackTargets = null,
bool hasDepthAttachment = false,
GuestDepthResource? feedbackDepth = null)
{
var isTitleDraw = IsTitleDraw(draw.VertexBuffers);
var forceFullscreenVertex = _forceFullscreenPipeline ||
_forceFullscreenVertex ||
isTitleDraw && _forceTitleFullscreenVertex ||
AnyTargetAddressMatches(
feedbackTargets,
"SHARPEMU_FORCE_FULLSCREEN_VERTEX_TARGETS");
var forceRasterState = _forceFullscreenPipeline ||
_forceDefaultRasterState ||
isTitleDraw && _forceTitleDefaultRasterState ||
AnyTargetAddressMatches(
feedbackTargets,
"SHARPEMU_FORCE_DEFAULT_RASTER_STATE_TARGETS");
var forceTitleSolidFragment =
_forceTitleSolidFragment &&
isTitleDraw;
var forceSolidFragment = forceTitleSolidFragment ||
_forceFullscreenPipeline ||
_forceSolidFragment ||
AnyTargetAddressMatches(
feedbackTargets,
"SHARPEMU_FORCE_SOLID_FRAGMENT_TARGETS");
var attributeFragmentLocation =
_forceAttributeFragmentLocation.GetValueOrDefault();
var forceAttributeFragment =
_forceAttributeFragmentLocation.HasValue &&
AnyTargetAddressMatches(
feedbackTargets,
"SHARPEMU_FORCE_ATTRIBUTE_FRAGMENT_TARGETS");
var vertexSpirv = forceFullscreenVertex
? SpirvFixedShaders.CreateFullscreenVertex(0)
: draw.VertexSpirv;
var fragmentSpirv = forceSolidFragment
? SpirvFixedShaders.CreateSolidFragment(1f, 0f, 1f, 1f)
: forceAttributeFragment
? SpirvFixedShaders.CreateAttributeFragment(attributeFragmentLocation)
: draw.PixelSpirv;
if (forceSolidFragment && !string.IsNullOrWhiteSpace(_fixedFragmentDumpPath))
{
File.WriteAllBytes(_fixedFragmentDumpPath, fragmentSpirv);
}
if (draw.RenderState.Blends.Count != renderTargetFormats.Count)
{
throw new InvalidOperationException(
"color attachment formats and blend states must have matching counts");
}
if (vertexSpirv.Length == 0 &&
!TryCompileFullscreenVertexShader(
draw.AttributeCount,
out vertexSpirv,
out var vertexError))
{
throw new InvalidOperationException($"translated vertex shader failed: {vertexError}");
}
var resources = new TranslatedDrawResources
{
DebugName = "SharpEmu draw",
Textures = new TextureResource[draw.Textures.Count],
GlobalMemoryBuffers =
new GlobalBufferResource[draw.GlobalMemoryBuffers.Count],
VertexBuffers = new VertexBufferResource[draw.VertexBuffers.Count],
VertexCount = GetDrawVertexCount(draw.PrimitiveType, draw.VertexCount, draw.IndexBuffer),
InstanceCount = Math.Max(draw.InstanceCount, 1),
Topology = GetPrimitiveTopology(draw.PrimitiveType),
Blends = draw.RenderState.Blends.ToArray(),
Scissor = draw.RenderState.Scissor,
Viewport = draw.RenderState.Viewport,
Raster = draw.RenderState.Raster,
Depth = draw.RenderState.Depth,
HasDepthAttachment = hasDepthAttachment,
TargetFormats = renderTargetFormats.ToArray(),
};
if (forceFullscreenVertex)
{
resources.VertexCount = 3;
resources.InstanceCount = 1;
resources.Topology = PrimitiveTopology.TriangleList;
}
if (forceRasterState)
{
resources.Blends = Enumerable.Repeat(
GuestBlendState.Default,
renderTargetFormats.Count).ToArray();
resources.Scissor = null;
resources.Viewport = null;
resources.Raster = GuestRasterState.Default;
resources.Depth = GuestDepthState.Default;
}
if (isTitleDraw && _forceTitleDefaultBlend)
{
resources.Blends = Enumerable.Repeat(
GuestBlendState.Default,
renderTargetFormats.Count).ToArray();
}
if (isTitleDraw && _forceTitleDefaultViewportScissor)
{
resources.Scissor = null;
resources.Viewport = null;
}
if (isTitleDraw && _forceTitleDisableCull)
{
resources.Raster = resources.Raster with
{
CullFront = false,
CullBack = false,
};
}
if (isTitleDraw && _forceTitleDisableDepth)
{
resources.Depth = GuestDepthState.Default;
}
if (isTitleDraw && _traceTitleState)
{
Console.Error.WriteLine(
$"[LOADER][TRACE] vk.title_state " +
$"viewport={resources.Viewport} scissor={resources.Scissor} " +
$"raster={resources.Raster} depth={resources.Depth} " +
$"blends=[{string.Join(',', resources.Blends)}]");
}
try
{
foreach (var texture in draw.Textures)
{
// Skip address-0 storage bindings here: the real resolution
// path uses a scratch image for those, but this warm-up pass
// called ResolveStorageGuestImage directly, which throws on
// address 0 and dropped the whole draw (Demon's Souls G-buffer
// normals/IDs passes -> lighting had no input -> black).
if (texture.IsStorage && texture.Address != 0)
{
_ = ResolveStorageGuestImage(texture);
}
}
for (var index = 0; index < draw.Textures.Count; index++)
{
var texture = draw.Textures[index];
var resolved = ResolveTextureResource(texture);
var feedbackTarget = !texture.IsStorage
? feedbackTargets?.FirstOrDefault(target =>
ReferenceEquals(resolved.GuestImage, target))
: null;
// ResolveTextureResource may deliberately decline an
// address alias when the descriptor is incompatible with
// the render-target image. Only snapshot an alias which
// actually resolved to the target; a separately uploaded
// texture has no Vulkan attachment feedback hazard.
resources.Textures[index] =
feedbackDepth is not null &&
!texture.IsStorage &&
ReferenceEquals(resolved.GuestDepth, feedbackDepth)
? CreateDepthFeedbackSnapshot(texture, feedbackDepth)
:
feedbackTarget is not null &&
!texture.IsStorage &&
ReferenceEquals(resolved.GuestImage, feedbackTarget)
? CreateRenderTargetFeedbackSnapshot(texture, feedbackTarget)
: resolved;
}
PrepareGuestBufferAllocations(draw.GlobalMemoryBuffers);
for (var index = 0; index < draw.GlobalMemoryBuffers.Count; index++)
{
resources.GlobalMemoryBuffers[index] =
CreateGlobalBufferResource(draw.GlobalMemoryBuffers[index]);
}
var sharedVertexResources = new Dictionary<
byte[], VertexBufferResource>(
System.Collections.Generic.ReferenceEqualityComparer.Instance);
for (var index = 0; index < draw.VertexBuffers.Count; index++)
{
var guestVertex = draw.VertexBuffers[index];
if (sharedVertexResources.TryGetValue(
guestVertex.Data,
out var sharedVertex))
{
resources.VertexBuffers[index] =
CreateVertexBufferAlias(sharedVertex, guestVertex);
}
else
{
var vertexResource =
CreateVertexBufferResource(guestVertex);
resources.VertexBuffers[index] = vertexResource;
sharedVertexResources.Add(guestVertex.Data, vertexResource);
}
}
if (draw.IndexBuffer is { Length: > 0 } indexBuffer)
{
resources.IndexBuffer = CreateHostBuffer(
indexBuffer.Data.AsSpan(0, indexBuffer.Length),
BufferUsageFlags.IndexBufferBit,
out resources.IndexMemory,
out _);
resources.Index32Bit = indexBuffer.Is32Bit;
if (indexBuffer.Pooled)
{
GuestDataPool.Return(indexBuffer.Data);
}
}
CreateTranslatedDescriptorResources(
resources,
ShaderStageFlags.VertexBit | ShaderStageFlags.FragmentBit);
CreateTranslatedPipeline(
resources,
vertexSpirv,
fragmentSpirv,
renderPass,
renderTargetFormats,
extent);
return resources;
}
catch
{
DestroyTranslatedDrawResources(resources);
throw;
}
finally
{
var returnedVertexData = new HashSet<byte[]>(
System.Collections.Generic.ReferenceEqualityComparer.Instance);
foreach (var vertex in draw.VertexBuffers)
{
if (vertex.Pooled && returnedVertexData.Add(vertex.Data))
{
GuestDataPool.Return(vertex.Data);
}
}
}
}
[MethodImpl(MethodImplOptions.NoInlining)]
private TranslatedDrawResources CreateComputeDispatchResources(
VulkanComputeGuestDispatch dispatch)
{
var traceResources = dispatch.Textures.Count >= 8;
if (traceResources)
{
TraceVulkanShader(
$"vk.compute_resources begin groups={dispatch.GroupCountX}x" +
$"{dispatch.GroupCountY}x{dispatch.GroupCountZ} textures={dispatch.Textures.Count}");
}
var resources = new TranslatedDrawResources
{
DebugName = BuildComputeDebugName(dispatch),
Textures = new TextureResource[dispatch.Textures.Count],
GlobalMemoryBuffers =
new GlobalBufferResource[dispatch.GlobalMemoryBuffers.Count],
};
try
{
for (var index = 0; index < dispatch.Textures.Count; index++)
{
var texture = dispatch.Textures[index];
// Address-zero storage descriptors are valid scratch bindings.
// ResolveTextureResource creates their transient image below;
// pre-resolving them as guest-backed images throws and drops
// the entire compute dispatch before that path can run.
if (texture.IsStorage && texture.Address != 0)
{
if (traceResources)
{
TraceVulkanShader(
$"vk.compute_resources storage[{index}] begin " +
$"addr=0x{texture.Address:X16} fmt={texture.Format} " +
$"size={texture.Width}x{texture.Height} " +
$"view_mips={texture.BaseMipLevel}+{texture.MipLevels} " +
$"resource_mips={texture.ResourceMipLevels} " +
$"relative_level={texture.MipLevel}");
}
_ = ResolveStorageGuestImage(texture);
if (traceResources)
{
TraceVulkanShader($"vk.compute_resources storage[{index}] ready");
}
}
}
if (traceResources)
{
TraceVulkanShader("vk.compute_resources resolve begin");
}
for (var index = 0; index < dispatch.Textures.Count; index++)
{
resources.Textures[index] =
ResolveTextureResource(dispatch.Textures[index]);
}
if (traceResources)
{
TraceVulkanShader("vk.compute_resources resolve ready");
}
PrepareGuestBufferAllocations(dispatch.GlobalMemoryBuffers);
for (var index = 0; index < dispatch.GlobalMemoryBuffers.Count; index++)
{
resources.GlobalMemoryBuffers[index] =
CreateGlobalBufferResource(dispatch.GlobalMemoryBuffers[index]);
}
if (traceResources)
{
TraceVulkanShader("vk.compute_resources descriptors begin");
}
CreateTranslatedDescriptorResources(resources, ShaderStageFlags.ComputeBit);
if (traceResources)
{
TraceVulkanShader("vk.compute_resources descriptors ready");
}
if (traceResources)
{
TraceVulkanShader(
$"vk.compute_resources pipeline begin " +
$"cs=0x{dispatch.ShaderAddress:X16} " +
$"spirv={dispatch.ComputeSpirv.Length} " +
$"textures={resources.Textures.Length} " +
$"globals={resources.GlobalMemoryBuffers.Length}");
}
CreateComputePipeline(resources, dispatch.ComputeSpirv);
if (traceResources)
{
TraceVulkanShader("vk.compute_resources pipeline ready");
}
return resources;
}
catch
{
DestroyTranslatedDrawResources(resources);
throw;
}
}
private static bool TryCompileFullscreenVertexShader(
uint attributeCount,
out byte[] spirv,
out string error)
{
spirv = [];
error = string.Empty;
if (attributeCount > 32)
{
error = $"too many interpolated attributes: {attributeCount}";
return false;
}
spirv = SpirvFixedShaders.CreateFullscreenVertex(attributeCount);
return true;
}
[MethodImpl(MethodImplOptions.NoInlining)]
private void CreateTranslatedDescriptorResources(
TranslatedDrawResources resources,
ShaderStageFlags stageFlags)
{
var textureCount = resources.Textures.Length;
var sampledImageCount = resources.Textures.Count(texture => !texture.IsStorage);
var storageImageCount = textureCount - sampledImageCount;
var globalBufferCount = resources.GlobalMemoryBuffers.Length;
var bindingCount = textureCount + (globalBufferCount == 0 ? 0 : 1);
var layout = GetOrCreateDescriptorLayout(resources, stageFlags, bindingCount);
resources.DescriptorSetLayout = layout.DescriptorSetLayout;
resources.PipelineLayout = layout.PipelineLayout;
resources.DescriptorLayoutCached = true;
if (bindingCount == 0)
{
return;
}
var setLayout = layout.DescriptorSetLayout;
var poolSizes = new DescriptorPoolSize[
(sampledImageCount == 0 ? 0 : 1) +
(storageImageCount == 0 ? 0 : 1) +
(globalBufferCount == 0 ? 0 : 1)];
var poolSizeIndex = 0;
if (sampledImageCount != 0)
{
poolSizes[poolSizeIndex++] = new DescriptorPoolSize
{
Type = DescriptorType.CombinedImageSampler,
DescriptorCount = (uint)sampledImageCount,
};
}
if (storageImageCount != 0)
{
poolSizes[poolSizeIndex++] = new DescriptorPoolSize
{
Type = DescriptorType.StorageImage,
DescriptorCount = (uint)storageImageCount,
};
}
if (globalBufferCount != 0)
{
poolSizes[poolSizeIndex] = new DescriptorPoolSize
{
Type = DescriptorType.StorageBuffer,
DescriptorCount = (uint)globalBufferCount,
};
}
if (_recycledDescriptorPools.TryPop(out var recycledPool))
{
Check(
_vk.ResetDescriptorPool(_device, recycledPool, 0),
"vkResetDescriptorPool");
resources.DescriptorPool = recycledPool;
}
else
{
// Generously sized so any draw's set fits, making the pool
// recyclable regardless of the draw's binding mix. AAA titles
// (e.g. Demon's Souls) bind well over 32 textures in a single
// descriptor set, so the sampled-image budget in particular
// must be large enough to avoid a per-draw dynamic fallback.
var genericPoolSizes = stackalloc DescriptorPoolSize[3];
genericPoolSizes[0] = new DescriptorPoolSize
{
Type = DescriptorType.CombinedImageSampler,
DescriptorCount = 256,
};
genericPoolSizes[1] = new DescriptorPoolSize
{
Type = DescriptorType.StorageImage,
DescriptorCount = 64,
};
genericPoolSizes[2] = new DescriptorPoolSize
{
Type = DescriptorType.StorageBuffer,
DescriptorCount = 64,
};
var poolInfo = new DescriptorPoolCreateInfo
{
SType = StructureType.DescriptorPoolCreateInfo,
MaxSets = 1,
PoolSizeCount = 3,
PPoolSizes = genericPoolSizes,
};
DescriptorPool descriptorPool;
Check(
_vk.CreateDescriptorPool(
_device,
&poolInfo,
null,
out descriptorPool),
"vkCreateDescriptorPool");
resources.DescriptorPool = descriptorPool;
}
var allocateInfo = new DescriptorSetAllocateInfo
{
SType = StructureType.DescriptorSetAllocateInfo,
DescriptorPool = resources.DescriptorPool,
DescriptorSetCount = 1,
PSetLayouts = &setLayout,
};
DescriptorSet descriptorSet;
Check(
_vk.AllocateDescriptorSets(_device, &allocateInfo, out descriptorSet),
"vkAllocateDescriptorSets");
resources.DescriptorSet = descriptorSet;
var imageInfos = new DescriptorImageInfo[textureCount];
var bufferInfos = new DescriptorBufferInfo[globalBufferCount];
var writes = new WriteDescriptorSet[bindingCount];
fixed (DescriptorImageInfo* imageInfoPointer = imageInfos)
fixed (DescriptorBufferInfo* bufferInfoPointer = bufferInfos)
fixed (WriteDescriptorSet* writePointer = writes)
{
var writeIndex = 0;
if (globalBufferCount != 0)
{
for (var index = 0; index < globalBufferCount; index++)
{
bufferInfoPointer[index] = new DescriptorBufferInfo
{
Buffer = resources.GlobalMemoryBuffers[index].Buffer,
Offset = resources.GlobalMemoryBuffers[index].Offset,
Range = resources.GlobalMemoryBuffers[index].Size,
};
}
writePointer[writeIndex++] = new WriteDescriptorSet
{
SType = StructureType.WriteDescriptorSet,
DstSet = resources.DescriptorSet,
DstBinding = 0,
DescriptorCount = (uint)globalBufferCount,
DescriptorType = DescriptorType.StorageBuffer,
PBufferInfo = bufferInfoPointer,
};
}
for (var index = 0; index < textureCount; index++)
{
var isStorage = resources.Textures[index].IsStorage;
if (!isStorage &&
resources.Textures[index].Sampler.Handle == 0)
{
resources.Textures[index].Sampler =
CreateSampler(resources.Textures[index].SamplerState);
}
imageInfoPointer[index] = new DescriptorImageInfo
{
Sampler = isStorage ? default : resources.Textures[index].Sampler,
ImageView = resources.Textures[index].View,
ImageLayout = isStorage ||
resources.Textures[index].GuestImage is { } guestImage &&
resources.Textures.Any(
texture =>
texture.IsStorage &&
texture.GuestImage == guestImage)
? ImageLayout.General
: ImageLayout.ShaderReadOnlyOptimal,
};
writePointer[writeIndex++] = new WriteDescriptorSet
{
SType = StructureType.WriteDescriptorSet,
DstSet = resources.DescriptorSet,
DstBinding = (uint)(index + 1),
DescriptorCount = 1,
DescriptorType = isStorage
? DescriptorType.StorageImage
: DescriptorType.CombinedImageSampler,
PImageInfo = &imageInfoPointer[index],
};
}
_vk.UpdateDescriptorSets(
_device,
(uint)bindingCount,
writePointer,
0,
null);
}
}
private void CreateTranslatedPipeline(
TranslatedDrawResources resources,
byte[] vertexSpirv,
byte[] fragmentSpirv,
RenderPass renderPass,
IReadOnlyList<Format> renderTargetFormats,
Extent2D extent)
{
var pipelineKey = new GraphicsPipelineKey(
GetShaderDigest(vertexSpirv),
GetShaderDigest(fragmentSpirv),
string.Join(',', renderTargetFormats.Select(format => (uint)format)),
resources.HasDepthAttachment,
resources.Topology,
string.Join(';', resources.Blends.Select(blend =>
$"{(blend.Enable ? 1 : 0)}:{blend.ColorSrcFactor}:{blend.ColorDstFactor}:" +
$"{blend.ColorFunc}:{blend.AlphaSrcFactor}:{blend.AlphaDstFactor}:" +
$"{blend.AlphaFunc}:{(blend.SeparateAlphaBlend ? 1 : 0)}:{blend.WriteMask}")),
GetResourceLayoutKey(resources),
GetVertexLayoutKey(resources),
resources.Raster,
resources.HasDepthAttachment ? resources.Depth : GuestDepthState.Default);
if (_graphicsPipelines.TryGetValue(pipelineKey, out var cachedPipeline))
{
resources.Pipeline = cachedPipeline;
resources.PipelineCached = true;
return;
}
var vertexModule = CreateShaderModule(vertexSpirv);
var fragmentModule = CreateShaderModule(fragmentSpirv);
var entryPoint = (byte*)SilkMarshal.StringToPtr("main");
try
{
var shaderStages = stackalloc PipelineShaderStageCreateInfo[2];
shaderStages[0] = new PipelineShaderStageCreateInfo
{
SType = StructureType.PipelineShaderStageCreateInfo,
Stage = ShaderStageFlags.VertexBit,
Module = vertexModule,
PName = entryPoint,
};
shaderStages[1] = new PipelineShaderStageCreateInfo
{
SType = StructureType.PipelineShaderStageCreateInfo,
Stage = ShaderStageFlags.FragmentBit,
Module = fragmentModule,
PName = entryPoint,
};
var vertexBindingDescriptions =
new VertexInputBindingDescription[resources.VertexBuffers.Length];
var vertexAttributeDescriptions =
new VertexInputAttributeDescription[resources.VertexBuffers.Length];
for (var index = 0; index < resources.VertexBuffers.Length; index++)
{
var vertexBuffer = resources.VertexBuffers[index];
vertexBindingDescriptions[index] = new VertexInputBindingDescription
{
Binding = (uint)index,
Stride = vertexBuffer.Stride == 0
? Math.Max(vertexBuffer.ComponentCount, 1) * sizeof(float)
: vertexBuffer.Stride,
InputRate = VertexInputRate.Vertex,
};
vertexAttributeDescriptions[index] = new VertexInputAttributeDescription
{
Location = vertexBuffer.Location,
Binding = (uint)index,
Format = ToVkVertexFormat(
vertexBuffer.DataFormat,
vertexBuffer.NumberFormat,
vertexBuffer.ComponentCount),
Offset = 0,
};
}
fixed (VertexInputBindingDescription* vertexBindingPointerBase = vertexBindingDescriptions)
fixed (VertexInputAttributeDescription* vertexAttributePointerBase = vertexAttributeDescriptions)
{
var vertexInput = new PipelineVertexInputStateCreateInfo
{
SType = StructureType.PipelineVertexInputStateCreateInfo,
VertexBindingDescriptionCount = (uint)vertexBindingDescriptions.Length,
PVertexBindingDescriptions = vertexBindingDescriptions.Length == 0
? null
: vertexBindingPointerBase,
VertexAttributeDescriptionCount = (uint)vertexAttributeDescriptions.Length,
PVertexAttributeDescriptions = vertexAttributeDescriptions.Length == 0
? null
: vertexAttributePointerBase,
};
var inputAssembly = new PipelineInputAssemblyStateCreateInfo
{
SType = StructureType.PipelineInputAssemblyStateCreateInfo,
Topology = resources.Topology,
// Metal always applies primitive restart to strip/fan
// topologies with the max index as the cut value, so
// match that here. Enabling it for lists is what makes
// MoltenVK warn ("Metal does not support disabling
// primitive restart"); lists never carry a restart
// index, so leaving it off for them is both correct
// and warning-free.
PrimitiveRestartEnable = RequiresPrimitiveRestart(resources.Topology),
};
var viewport = new Viewport(0, 0, extent.Width, extent.Height, 0, 1);
var scissor = new Rect2D(new Offset2D(0, 0), extent);
var viewportState = new PipelineViewportStateCreateInfo
{
SType = StructureType.PipelineViewportStateCreateInfo,
ViewportCount = 1,
PViewports = &viewport,
ScissorCount = 1,
PScissors = &scissor,
};
var raster = resources.Raster;
var cullMode = CullModeFlags.None;
if (raster.CullFront)
{
cullMode |= CullModeFlags.FrontBit;
}
if (raster.CullBack)
{
cullMode |= CullModeFlags.BackBit;
}
var rasterization = new PipelineRasterizationStateCreateInfo
{
SType = StructureType.PipelineRasterizationStateCreateInfo,
// Wireframe (PolygonMode.Line) needs the fillModeNonSolid
// device feature and is effectively unused by shipping
// titles, so fall back to a solid fill.
PolygonMode = PolygonMode.Fill,
CullMode = cullMode,
FrontFace = raster.FrontFaceClockwise
? FrontFace.Clockwise
: FrontFace.CounterClockwise,
LineWidth = 1,
};
var multisample = new PipelineMultisampleStateCreateInfo
{
SType = StructureType.PipelineMultisampleStateCreateInfo,
RasterizationSamples = SampleCountFlags.Count1Bit,
};
var colorBlendAttachments = stackalloc PipelineColorBlendAttachmentState[resources.Blends.Length];
for (var index = 0; index < resources.Blends.Length; index++)
{
var blend = resources.Blends[index];
colorBlendAttachments[index] = new PipelineColorBlendAttachmentState
{
BlendEnable = blend.Enable &&
IsBlendableFormat(renderTargetFormats[index]),
SrcColorBlendFactor = ToVkBlendFactor(blend.ColorSrcFactor),
DstColorBlendFactor = ToVkBlendFactor(blend.ColorDstFactor),
ColorBlendOp = ToVkBlendOp(blend.ColorFunc),
SrcAlphaBlendFactor = blend.SeparateAlphaBlend
? ToVkBlendFactor(blend.AlphaSrcFactor)
: ToVkBlendFactor(blend.ColorSrcFactor),
DstAlphaBlendFactor = blend.SeparateAlphaBlend
? ToVkBlendFactor(blend.AlphaDstFactor)
: ToVkBlendFactor(blend.ColorDstFactor),
AlphaBlendOp = blend.SeparateAlphaBlend
? ToVkBlendOp(blend.AlphaFunc)
: ToVkBlendOp(blend.ColorFunc),
ColorWriteMask = ToVkColorWriteMask(blend.WriteMask),
};
}
var colorBlend = new PipelineColorBlendStateCreateInfo
{
SType = StructureType.PipelineColorBlendStateCreateInfo,
AttachmentCount = (uint)resources.Blends.Length,
PAttachments = colorBlendAttachments,
};
var dynamicStateValues = stackalloc DynamicState[2];
dynamicStateValues[0] = DynamicState.Viewport;
dynamicStateValues[1] = DynamicState.Scissor;
var dynamicState = new PipelineDynamicStateCreateInfo
{
SType = StructureType.PipelineDynamicStateCreateInfo,
DynamicStateCount = 2,
PDynamicStates = dynamicStateValues,
};
var depth = resources.Depth;
var depthStencil = new PipelineDepthStencilStateCreateInfo
{
SType = StructureType.PipelineDepthStencilStateCreateInfo,
DepthTestEnable = depth.TestEnable,
DepthWriteEnable = depth.WriteEnable,
DepthCompareOp = ToVkCompareOp(depth.CompareOp),
DepthBoundsTestEnable = false,
StencilTestEnable = false,
};
var pipelineInfo = new GraphicsPipelineCreateInfo
{
SType = StructureType.GraphicsPipelineCreateInfo,
StageCount = 2,
PStages = shaderStages,
PVertexInputState = &vertexInput,
PInputAssemblyState = &inputAssembly,
PViewportState = &viewportState,
PRasterizationState = &rasterization,
PMultisampleState = &multisample,
PColorBlendState = &colorBlend,
PDepthStencilState = resources.HasDepthAttachment ? &depthStencil : null,
PDynamicState = &dynamicState,
Layout = resources.PipelineLayout,
RenderPass = renderPass,
Subpass = 0,
};
Pipeline pipeline;
Check(
_vk.CreateGraphicsPipelines(
_device,
_pipelineCache,
1,
&pipelineInfo,
null,
out pipeline),
"vkCreateGraphicsPipelines(translated)");
MarkPipelineCacheDirty();
resources.Pipeline = pipeline;
resources.PipelineCached = true;
_graphicsPipelines.Add(pipelineKey, pipeline);
Interlocked.Increment(ref _perfPipelineCreations);
SetDebugName(
ObjectType.Pipeline,
pipeline.Handle,
$"SharpEmu graphics ps={fragmentSpirv.Length}b attrs={resources.Textures.Length}");
}
}
finally
{
SilkMarshal.Free((nint)entryPoint);
_vk.DestroyShaderModule(_device, fragmentModule, null);
_vk.DestroyShaderModule(_device, vertexModule, null);
}
}
private DescriptorLayoutBundle GetOrCreateDescriptorLayout(
TranslatedDrawResources resources,
ShaderStageFlags stageFlags,
int bindingCount)
{
var key = new DescriptorLayoutKey(stageFlags, GetResourceLayoutKey(resources));
if (_descriptorLayouts.TryGetValue(key, out var cached))
{
return cached;
}
DescriptorSetLayout descriptorSetLayout = default;
if (bindingCount != 0)
{
var bindings = new DescriptorSetLayoutBinding[bindingCount];
var bindingOffset = 0;
if (resources.GlobalMemoryBuffers.Length != 0)
{
bindings[bindingOffset++] = new DescriptorSetLayoutBinding
{
Binding = 0,
DescriptorType = DescriptorType.StorageBuffer,
DescriptorCount = (uint)resources.GlobalMemoryBuffers.Length,
StageFlags = stageFlags,
};
}
for (var index = 0; index < resources.Textures.Length; index++)
{
bindings[bindingOffset + index] = new DescriptorSetLayoutBinding
{
Binding = (uint)(index + 1),
DescriptorType = resources.Textures[index].IsStorage
? DescriptorType.StorageImage
: DescriptorType.CombinedImageSampler,
DescriptorCount = 1,
StageFlags = stageFlags,
};
}
fixed (DescriptorSetLayoutBinding* bindingPointer = bindings)
{
var descriptorInfo = new DescriptorSetLayoutCreateInfo
{
SType = StructureType.DescriptorSetLayoutCreateInfo,
BindingCount = (uint)bindings.Length,
PBindings = bindingPointer,
};
Check(
_vk.CreateDescriptorSetLayout(
_device,
&descriptorInfo,
null,
out descriptorSetLayout),
"vkCreateDescriptorSetLayout");
}
}
var pipelineInfo = new PipelineLayoutCreateInfo
{
SType = StructureType.PipelineLayoutCreateInfo,
};
if (descriptorSetLayout.Handle != 0)
{
pipelineInfo.SetLayoutCount = 1;
pipelineInfo.PSetLayouts = &descriptorSetLayout;
}
var computePushConstantRange = new PushConstantRange
{
StageFlags = ShaderStageFlags.ComputeBit,
Offset = 0,
Size = 3 * sizeof(uint),
};
if ((stageFlags & ShaderStageFlags.ComputeBit) != 0)
{
pipelineInfo.PushConstantRangeCount = 1;
pipelineInfo.PPushConstantRanges = &computePushConstantRange;
}
PipelineLayout pipelineLayout;
Check(
_vk.CreatePipelineLayout(
_device,
&pipelineInfo,
null,
out pipelineLayout),
"vkCreatePipelineLayout");
var created = new DescriptorLayoutBundle(descriptorSetLayout, pipelineLayout);
_descriptorLayouts.Add(key, created);
return created;
}
private string GetShaderDigest(byte[] spirv)
{
if (_shaderDigests.TryGetValue(spirv, out var digest))
{
return digest;
}
digest = Convert.ToHexString(SHA256.HashData(spirv));
_shaderDigests.Add(spirv, digest);
return digest;
}
private static string GetResourceLayoutKey(TranslatedDrawResources resources) =>
resources.ResourceLayoutKey ??= BuildResourceLayoutKey(resources);
private static string GetVertexLayoutKey(TranslatedDrawResources resources) =>
resources.VertexLayoutKey ??= BuildVertexLayoutKey(resources);
private static string BuildResourceLayoutKey(TranslatedDrawResources resources)
{
var key = new StringBuilder();
key.Append(resources.GlobalMemoryBuffers.Length).Append(':');
foreach (var texture in resources.Textures)
{
key.Append(texture.IsStorage ? 'S' : 'T');
}
return key.ToString();
}
private static string BuildVertexLayoutKey(TranslatedDrawResources resources)
{
var key = new StringBuilder();
foreach (var buffer in resources.VertexBuffers)
{
key.Append(buffer.Location).Append(',')
.Append(buffer.ComponentCount).Append(',')
.Append(buffer.DataFormat).Append(',')
.Append(buffer.NumberFormat).Append(',')
.Append(buffer.Stride == 0
? Math.Max(buffer.ComponentCount, 1) * sizeof(float)
: buffer.Stride)
.Append(';');
}
return key.ToString();
}
[MethodImpl(MethodImplOptions.NoInlining)]
private void CreateComputePipeline(
TranslatedDrawResources resources,
byte[] computeSpirv)
{
var pipelineKey = new ComputePipelineKey(
GetShaderDigest(computeSpirv),
GetResourceLayoutKey(resources));
if (_computePipelines.TryGetValue(pipelineKey, out var cachedPipeline))
{
resources.Pipeline = cachedPipeline;
resources.PipelineCached = true;
return;
}
var computeModule = CreateShaderModule(computeSpirv);
var entryPoint = (byte*)SilkMarshal.StringToPtr("main");
try
{
var stage = new PipelineShaderStageCreateInfo
{
SType = StructureType.PipelineShaderStageCreateInfo,
Stage = ShaderStageFlags.ComputeBit,
Module = computeModule,
PName = entryPoint,
};
var pipelineInfo = new ComputePipelineCreateInfo
{
SType = StructureType.ComputePipelineCreateInfo,
Flags = PipelineCreateFlags.CreateDispatchBaseBit,
Stage = stage,
Layout = resources.PipelineLayout,
};
Pipeline pipeline;
Check(
_vk.CreateComputePipelines(
_device,
_pipelineCache,
1,
&pipelineInfo,
null,
out pipeline),
"vkCreateComputePipelines(translated)");
MarkPipelineCacheDirty();
resources.Pipeline = pipeline;
resources.PipelineCached = true;
SetDebugName(
ObjectType.Pipeline,
pipeline.Handle,
$"SharpEmu compute cs={computeSpirv.Length}b");
_computePipelines.Add(pipelineKey, pipeline);
}
finally
{
SilkMarshal.Free((nint)entryPoint);
_vk.DestroyShaderModule(_device, computeModule, null);
}
}
[MethodImpl(MethodImplOptions.NoInlining)]
private TextureResource ResolveTextureResource(GuestDrawTexture texture)
{
if (texture.IsStorage)
{
return ResolveStorageImageResource(texture);
}
if (texture.Address != 0 &&
TryResolveGuestDepthTexture(texture, out var depthTexture))
{
return depthTexture;
}
var vkFormat = GetTextureFormat(texture.Format, texture.NumberType);
if (texture.Address != 0 &&
TryResolveGuestImageAlias(texture, vkFormat, out var guestImage) &&
TryGetOrCreateGuestImageView(
guestImage,
vkFormat,
mipLevel: texture.BaseMipLevel,
levelCount: texture.MipLevels,
dstSelect: texture.DstSelect,
out var view))
{
if (ShouldTraceVulkanResources() &&
_tracedTextureCacheHits.Add(
(texture.Address, texture.Width, texture.Height, vkFormat)))
{
Console.Error.WriteLine(
$"[LOADER][TRACE] vk.texture_cache_hit addr=0x{texture.Address:X16} " +
$"size={texture.Width}x{texture.Height} " +
$"image_format={guestImage.Format} view_format={vkFormat}");
}
if (guestImage.Width != texture.Width ||
guestImage.Height != texture.Height)
{
TraceVulkanShader(
$"vk.texture_cache_alias addr=0x{texture.Address:X16} " +
$"texture={texture.Width}x{texture.Height} " +
$"image={guestImage.Width}x{guestImage.Height} " +
$"tile={texture.TileMode} format={vkFormat}");
}
if (string.Equals(
Environment.GetEnvironmentVariable("SHARPEMU_TRACE_GUEST_IMAGES"),
"alias",
StringComparison.OrdinalIgnoreCase) &&
_tracedGuestImageContents.Add(guestImage.Address))
{
// Deferred: reading back here would clobber the command
// buffer mid-recording; drained after the next present.
_pendingAliasImageDumps.Enqueue(guestImage);
}
if (TryCreateCpuTextureRefreshResource(
texture,
guestImage,
view,
out var refreshResource))
{
return refreshResource;
}
return new TextureResource
{
Address = texture.Address,
Image = guestImage.Image,
View = view,
Width = guestImage.Width,
Height = guestImage.Height,
RowLength = guestImage.Width,
DstSelect = texture.DstSelect,
SamplerState = texture.Sampler,
GuestImage = guestImage,
};
}
if (ShouldTraceVulkanResources() && texture.Address != 0)
{
if (_guestImages.TryGetValue(texture.Address, out var missImage))
{
Console.Error.WriteLine(
$"[LOADER][TRACE] vk.alias_miss addr=0x{texture.Address:X16} " +
$"reason={(IsCompatibleGuestImageAlias(texture, missImage) ? "format" : "size")} " +
$"tex={texture.Width}x{texture.Height}/f{texture.Format}/n{texture.NumberType}/vk{vkFormat} " +
$"img={missImage.Width}x{missImage.Height}/imgfmt{missImage.Format} " +
$"init={missImage.Initialized}");
}
else
{
Console.Error.WriteLine(
$"[LOADER][TRACE] vk.alias_miss addr=0x{texture.Address:X16} " +
$"reason=absent tex={texture.Width}x{texture.Height}/f{texture.Format}/n{texture.NumberType}");
}
}
return GetOrCreateCachedTextureResource(texture);
}
private bool TryCreateCpuTextureRefreshResource(
GuestDrawTexture texture,
GuestImageResource guestImage,
ImageView view,
out TextureResource resource)
{
resource = default!;
if (!guestImage.IsCpuBacked ||
guestImage.Width != texture.Width ||
guestImage.Height != texture.Height ||
guestImage.MipLevels != 1 ||
texture.RgbaPixels.Length == 0)
{
return false;
}
var rowLength = texture.TileMode == 0
? Math.Max(texture.Pitch, texture.Width)
: texture.Width;
var expectedSize = GetTextureByteCount(texture.Format, rowLength, texture.Height);
if (expectedSize == 0 || expectedSize > int.MaxValue)
{
return false;
}
var pixels = texture.RgbaPixels.Length == (int)expectedSize
? texture.RgbaPixels
: CreateFallbackTexturePixels(texture.Format, rowLength, texture.Height, expectedSize);
var fingerprint = ComputeTextureContentFingerprint(pixels);
if ((guestImage.Initialized || guestImage.InitialUploadPending) &&
guestImage.CpuContentFingerprint == fingerprint)
{
return false;
}
var uploadPixels = texture.Format == 13
? ExpandRgb32Pixels(pixels)
: pixels;
var debugName = TextureDebugName(texture, guestImage.Format);
var (stagingBuffer, stagingMemory) = CreateTextureStagingBuffer(
uploadPixels,
$"{debugName} refresh staging");
TraceVulkanShader(
$"vk.texture_refresh addr=0x{texture.Address:X16} " +
$"size={texture.Width}x{texture.Height} bytes={uploadPixels.Length}");
resource = new TextureResource
{
Address = texture.Address,
StagingBuffer = stagingBuffer,
StagingMemory = stagingMemory,
Image = guestImage.Image,
View = view,
Width = guestImage.Width,
Height = guestImage.Height,
RowLength = rowLength,
DstSelect = texture.DstSelect,
NeedsUpload = true,
SamplerState = texture.Sampler,
GuestImage = guestImage,
CpuContentFingerprint = fingerprint,
UpdatesCpuContent = true,
};
return true;
}
private bool TryResolveGuestImageAlias(
GuestDrawTexture texture,
Format viewFormat,
out GuestImageResource guestImage)
{
GuestImageResource? best = null;
var bestScore = int.MinValue;
var guestFormat = GetGuestTextureFormat(texture.Format, texture.NumberType);
void Consider(GuestImageResource candidate, bool isActive)
{
if (!IsUsableGuestImageAlias(texture, viewFormat, candidate))
{
return;
}
// Prefer an exact descriptor match. Initialization is important,
// but it must not make a differently sized alias outrank the image
// that the texture descriptor actually names.
var score = 0;
if (candidate.Width == texture.Width &&
candidate.Height == texture.Height)
{
score += 32;
}
if (candidate.Format == viewFormat)
{
score += 16;
}
if (candidate.GuestFormat == guestFormat)
{
score += 8;
}
if (candidate.Initialized)
{
score += 4;
}
if (candidate.MipLevels == texture.ResourceMipLevels)
{
score += 2;
}
if (isActive)
{
score += 1;
}
if (score > bestScore)
{
best = candidate;
bestScore = score;
}
}
if (_guestImages.TryGetValue(texture.Address, out var active))
{
Consider(active, isActive: true);
}
foreach (var (key, candidate) in _guestImageVariants)
{
if (key.Address == texture.Address)
{
Consider(candidate, isActive: false);
}
}
if (best is not null)
{
guestImage = best;
if (ShouldTraceVulkanResources())
{
Console.Error.WriteLine(
$"[LOADER][TRACE] vk.texture_variant_hit " +
$"addr=0x{texture.Address:X16} " +
$"tex={texture.Width}x{texture.Height}/{viewFormat} " +
$"image={best.Width}x{best.Height}/{best.Format} " +
$"initialized={best.Initialized}");
}
return true;
}
guestImage = null!;
return false;
}
private static bool IsUsableGuestImageAlias(
GuestDrawTexture texture,
Format viewFormat,
GuestImageResource guestImage) =>
texture.BaseMipLevel < guestImage.MipLevels &&
IsCompatibleGuestImageAlias(texture, guestImage) &&
IsCompatibleViewFormat(guestImage.Format, viewFormat);
private bool TryResolveGuestDepthTexture(
GuestDrawTexture texture,
out TextureResource resource)
{
foreach (var depth in _guestDepthImages.Values)
{
if (texture.Address != depth.Address &&
texture.Address != depth.ReadAddress &&
texture.Address != depth.WriteAddress)
{
continue;
}
if (texture.Width > depth.Width || texture.Height > depth.Height)
{
continue;
}
if (!depth.SampleViews.TryGetValue(texture.DstSelect, out var view))
{
var viewInfo = new ImageViewCreateInfo
{
SType = StructureType.ImageViewCreateInfo,
Image = depth.Image,
ViewType = ImageViewType.Type2D,
Format = DepthFormat,
Components = ToVkComponentMapping(texture.DstSelect),
SubresourceRange = new ImageSubresourceRange(
ImageAspectFlags.DepthBit,
0,
1,
0,
1),
};
Check(
_vk.CreateImageView(_device, &viewInfo, null, out view),
"vkCreateImageView(depth sample)");
SetDebugName(
ObjectType.ImageView,
view.Handle,
$"SharpEmu guest depth sample 0x{depth.Address:X16} " +
$"dst=0x{texture.DstSelect:X3}");
depth.SampleViews.Add(texture.DstSelect, view);
}
if (_tracedDepthTextureAliases.Add(
(texture.Address, texture.Width, texture.Height, texture.DstSelect)))
{
TraceVulkanShader(
$"vk.depth_texture_alias addr=0x{texture.Address:X16} " +
$"depth=0x{depth.Address:X16} " +
$"texture={texture.Width}x{texture.Height} " +
$"surface={depth.Width}x{depth.Height} dst=0x{texture.DstSelect:X3}");
}
resource = new TextureResource
{
Address = texture.Address,
Image = depth.Image,
View = view,
Width = depth.Width,
Height = depth.Height,
RowLength = depth.Width,
DstSelect = texture.DstSelect,
SamplerState = texture.Sampler,
GuestDepth = depth,
};
return true;
}
resource = null!;
return false;
}
private readonly Dictionary<TextureContentIdentity, TextureResource> _textureCache = new();
/// <summary>
/// Guest textures are static assets in the common case, but every draw
/// used to restage and reupload them (a fresh image, device memory and
/// staging buffer per draw). Cache the uploaded resource per descriptor
/// identity and invalidate through the CPU write tracker, so animated
/// or streamed texture memory still refreshes.
/// </summary>
private TextureResource GetOrCreateCachedTextureResource(GuestDrawTexture texture)
{
if (texture.Address == 0)
{
return CreateTextureResource(texture);
}
var key = new TextureContentIdentity(
texture.Address,
texture.Width,
texture.Height,
texture.Format,
texture.NumberType,
texture.DstSelect,
texture.TileMode,
texture.Pitch,
texture.Sampler);
if (_textureCache.TryGetValue(key, out var cached))
{
return cached;
}
// Empty pixels mean the submit thread skipped the guest-memory
// copy because this identity was marked cached; a miss here is
// an invalidation race (eviction, cache clear). Self-heal by
// reading the texels directly rather than rendering a fallback.
if (texture.RgbaPixels.Length == 0)
{
var refreshed = TryReadGuestTexturePixels(texture);
if (refreshed is null)
{
return CreateTextureResource(texture);
}
texture = texture with { RgbaPixels = refreshed };
}
var resource = CreateTextureResource(texture);
if (resource.OwnsStorage)
{
resource.Cached = true;
_textureCache[key] = resource;
MarkTextureContentCached(key);
SharpEmu.HLE.GuestImageWriteTracker.Track(
texture.Address,
(ulong)texture.RgbaPixels.Length,
CurrentGuestWorkSequenceForDiagnostics,
"vulkan.texture-cache");
}
return resource;
}
private void EvictDirtyCachedTextures()
{
if (_textureCache.Count == 0)
{
return;
}
List<TextureContentIdentity>? evicted = null;
foreach (var entry in _textureCache)
{
if (SharpEmu.HLE.GuestImageWriteTracker.ConsumeDirty(entry.Key.Address))
{
(evicted ??= []).Add(entry.Key);
}
}
if (evicted is null && _textureCache.Count <= 2048)
{
return;
}
// Destruction is deferred until every submission that may still
// reference the texture has completed (fences signal in queue
// order), so eviction never has to drain the GPU. An open batch
// is flushed first so the retire timeline exactly covers every
// recorded reference (nothing may guess which submission lands
// next on the shared queue).
if (_batchOpen)
{
FlushBatchedGuestCommands();
}
var retireTimeline = _submitTimeline;
if (_textureCache.Count > 2048)
{
foreach (var entry in _textureCache)
{
_deferredTextureDestroys.Enqueue((entry.Value, retireTimeline));
}
_textureCache.Clear();
ClearCachedTextureIdentities();
return;
}
foreach (var key in evicted!)
{
if (_textureCache.Remove(key, out var resource))
{
UnmarkTextureContentCached(key);
_deferredTextureDestroys.Enqueue((resource, retireTimeline));
SharpEmu.HLE.GuestImageWriteTracker.Rearm(key.Address);
}
}
}
private void DestroyCachedTextureResource(TextureResource texture)
{
texture.Cached = false;
if (texture.View.Handle != 0)
{
_vk.DestroyImageView(_device, texture.View, null);
}
if (texture.Image.Handle != 0 && texture.GuestImage is null)
{
_vk.DestroyImage(_device, texture.Image, null);
if (texture.ImageMemory.Handle != 0)
{
_vk.FreeMemory(_device, texture.ImageMemory, null);
}
}
if (texture.StagingBuffer.Handle != 0)
{
_vk.DestroyBuffer(_device, texture.StagingBuffer, null);
}
if (texture.StagingMemory.Handle != 0)
{
_vk.FreeMemory(_device, texture.StagingMemory, null);
}
}
private static bool IsCompatibleGuestImageAlias(
GuestDrawTexture texture,
GuestImageResource guestImage)
{
if (guestImage.Width == texture.Width &&
guestImage.Height == texture.Height)
{
return true;
}
if (texture.TileMode == 0 ||
texture.Width == 0 ||
texture.Height == 0)
{
return false;
}
return texture.Width <= guestImage.Width &&
texture.Height <= guestImage.Height;
}
[MethodImpl(MethodImplOptions.NoInlining)]
private TextureResource ResolveStorageImageResource(GuestDrawTexture texture)
{
if (texture.Address == 0)
{
return CreateStorageScratchResource(texture);
}
var guestImage = ResolveStorageGuestImage(texture);
var vkFormat = GetTextureFormat(texture.Format, texture.NumberType);
var selectedMipLevel = GetStorageMipLevel(texture);
var view = GetOrCreateGuestImageView(
guestImage,
vkFormat,
selectedMipLevel,
levelCount: 1);
var resource = new TextureResource
{
Address = texture.Address,
Image = guestImage.Image,
View = view,
Width = guestImage.Width,
Height = guestImage.Height,
RowLength = guestImage.Width,
DstSelect = texture.DstSelect,
IsStorage = true,
SamplerState = texture.Sampler,
GuestImage = guestImage,
};
if (!guestImage.Initialized &&
!guestImage.InitialUploadPending &&
texture.MipLevel == 0)
{
var expectedSize = GetTextureByteCount(
texture.Format,
texture.Width,
texture.Height);
if ((ulong)texture.RgbaPixels.Length == expectedSize &&
texture.RgbaPixels.AsSpan().IndexOfAnyExcept((byte)0) >= 0)
{
var uploadPixels = texture.Format == 13
? ExpandRgb32Pixels(texture.RgbaPixels)
: texture.RgbaPixels;
var uploadSize = (ulong)uploadPixels.Length;
resource.StagingBuffer = CreateBuffer(
uploadSize,
BufferUsageFlags.TransferSrcBit,
MemoryPropertyFlags.HostVisibleBit |
MemoryPropertyFlags.HostCoherentBit,
out resource.StagingMemory);
void* mapped;
Check(
_vk.MapMemory(
_device,
resource.StagingMemory,
0,
uploadSize,
0,
&mapped),
"vkMapMemory(storage texture)");
fixed (byte* source = uploadPixels)
{
System.Buffer.MemoryCopy(
source,
mapped,
uploadPixels.Length,
uploadPixels.Length);
}
_vk.UnmapMemory(_device, resource.StagingMemory);
resource.NeedsUpload = true;
guestImage.InitialUploadPending = true;
TraceVulkanShader(
$"vk.storage_upload addr=0x{texture.Address:X16} " +
$"size={texture.Width}x{texture.Height} " +
$"logical_bytes={expectedSize} upload_bytes={uploadSize}");
}
}
return resource;
}
private TextureResource CreateStorageScratchResource(GuestDrawTexture texture)
{
var width = Math.Max(texture.Width, 1);
var height = Math.Max(texture.Height, 1);
var vkFormat = GetTextureFormat(texture.Format, texture.NumberType);
var imageInfo = new ImageCreateInfo
{
SType = StructureType.ImageCreateInfo,
ImageType = ImageType.Type2D,
Format = vkFormat,
Extent = new Extent3D(width, height, 1),
MipLevels = 1,
ArrayLayers = 1,
Samples = SampleCountFlags.Count1Bit,
Tiling = ImageTiling.Optimal,
Usage =
ImageUsageFlags.SampledBit |
ImageUsageFlags.StorageBit |
ImageUsageFlags.TransferSrcBit |
ImageUsageFlags.TransferDstBit,
SharingMode = SharingMode.Exclusive,
InitialLayout = ImageLayout.Undefined,
};
Check(
_vk.CreateImage(_device, &imageInfo, null, out var image),
"vkCreateImage(storage scratch)");
_vk.GetImageMemoryRequirements(_device, image, out var requirements);
var allocationInfo = new MemoryAllocateInfo
{
SType = StructureType.MemoryAllocateInfo,
AllocationSize = requirements.Size,
MemoryTypeIndex = FindMemoryType(
requirements.MemoryTypeBits,
MemoryPropertyFlags.DeviceLocalBit),
};
Check(
_vk.AllocateMemory(_device, &allocationInfo, null, out var memory),
"vkAllocateMemory(storage scratch)");
Check(
_vk.BindImageMemory(_device, image, memory, 0),
"vkBindImageMemory(storage scratch)");
var viewInfo = new ImageViewCreateInfo
{
SType = StructureType.ImageViewCreateInfo,
Image = image,
ViewType = ImageViewType.Type2D,
Format = vkFormat,
Components = new ComponentMapping(
ComponentSwizzle.Identity,
ComponentSwizzle.Identity,
ComponentSwizzle.Identity,
ComponentSwizzle.Identity),
SubresourceRange = ColorSubresourceRange(),
};
Check(
_vk.CreateImageView(_device, &viewInfo, null, out var view),
"vkCreateImageView(storage scratch)");
SetDebugName(ObjectType.Image, image.Handle, $"SharpEmu scratch storage {width}x{height} {vkFormat}");
SetDebugName(ObjectType.ImageView, view.Handle, $"SharpEmu scratch storage {width}x{height} {vkFormat} view");
var guestImage = new GuestImageResource
{
Address = 0,
Width = width,
Height = height,
MipLevels = 1,
GuestFormat = GetGuestTextureFormat(texture.Format, texture.NumberType),
Format = vkFormat,
Image = image,
Memory = memory,
View = view,
};
return new TextureResource
{
Address = 0,
Image = image,
ImageMemory = memory,
View = view,
Width = width,
Height = height,
RowLength = width,
DstSelect = texture.DstSelect,
OwnsStorage = true,
IsStorage = true,
SamplerState = texture.Sampler,
GuestImage = guestImage,
};
}
private GuestImageResource ResolveStorageGuestImage(GuestDrawTexture texture)
{
if (texture.Address == 0)
{
throw new InvalidOperationException("Storage image has no guest address.");
}
var format = GetTextureFormat(texture.Format, texture.NumberType);
var guestImage = GetOrCreateGuestImage(
new GuestRenderTarget(
texture.Address,
texture.Width,
texture.Height,
texture.Format,
texture.NumberType,
texture.ResourceMipLevels),
format);
var selectedMipLevel = GetStorageMipLevel(texture);
if (selectedMipLevel >= guestImage.MipLevels)
{
throw new InvalidOperationException(
$"Storage mip {selectedMipLevel} (base {texture.BaseMipLevel} + relative " +
$"{texture.MipLevel}) exceeds image mip count {guestImage.MipLevels}.");
}
return guestImage;
}
private static uint GetStorageMipLevel(GuestDrawTexture texture)
{
// IMAGE_STORE targets BASE_LEVEL and IMAGE_STORE_MIP's operand is
// expressed in resource-view space, so Vulkan's absolute image
// subresource is descriptor base plus the instruction-relative
// mip. Sampled views achieve the same mapping through their view
// base in ResolveTextureResource.
var selectedMipLevel = (ulong)texture.BaseMipLevel + texture.MipLevel;
if (selectedMipLevel > uint.MaxValue)
{
throw new InvalidOperationException(
$"Storage mip overflow (base {texture.BaseMipLevel} + relative {texture.MipLevel}).");
}
return (uint)selectedMipLevel;
}
private TextureResource CreateTextureResource(GuestDrawTexture texture)
{
var width = Math.Max(texture.Width, 1);
var height = Math.Max(texture.Height, 1);
var rowLength = texture.TileMode == 0
? Math.Max(texture.Pitch, width)
: width;
var vkFormat = GetTextureFormat(texture.Format, texture.NumberType);
var expectedSize = GetTextureByteCount(texture.Format, rowLength, height);
if (ShouldTraceVulkanResources() &&
_tracedTextureUploads.Add((texture.Address, width, height, vkFormat)))
{
Console.Error.WriteLine(
$"[LOADER][TRACE] vk.texture addr=0x{texture.Address:X16} " +
$"fmt={texture.Format} num={texture.NumberType} vk={vkFormat} " +
$"size={width}x{height} row={rowLength} tile={texture.TileMode} " +
$"dst=0x{texture.DstSelect:X3} " +
$"bytes={texture.RgbaPixels.Length} expected={expectedSize}");
}
var pixels = texture.RgbaPixels.Length == (int)expectedSize
? texture.RgbaPixels
: CreateFallbackTexturePixels(texture.Format, rowLength, height, expectedSize);
if (AddressListContains("SHARPEMU_FORCE_WHITE_TEXTURE_TARGETS", texture.Address))
{
pixels = pixels.ToArray();
pixels.AsSpan().Fill(0xFF);
Console.Error.WriteLine(
$"[LOADER][TRACE] vk.texture_force_white addr=0x{texture.Address:X16} " +
$"size={width}x{height} bytes={pixels.Length}");
}
DumpTextureUpload(texture, pixels, rowLength, width, height);
TraceTextureUploadContents(texture, pixels, rowLength, width, height, vkFormat);
var uploadPixels = texture.Format == 13
? ExpandRgb32Pixels(pixels)
: pixels;
var contentFingerprint = ComputeTextureContentFingerprint(pixels);
var (stagingBuffer, stagingMemory) = CreateTextureStagingBuffer(
uploadPixels,
$"{TextureDebugName(texture, vkFormat)} staging");
var supportsAttachmentUsage = !IsBlockCompressedFormat(vkFormat);
var imageInfo = new ImageCreateInfo
{
SType = StructureType.ImageCreateInfo,
Flags = supportsAttachmentUsage
? ImageCreateFlags.CreateMutableFormatBit | ImageCreateFlags.CreateExtendedUsageBit
: 0,
ImageType = ImageType.Type2D,
Format = vkFormat,
Extent = new Extent3D(width, height, 1),
MipLevels = 1,
ArrayLayers = 1,
Samples = SampleCountFlags.Count1Bit,
Tiling = ImageTiling.Optimal,
Usage = supportsAttachmentUsage
? ImageUsageFlags.TransferDstBit |
ImageUsageFlags.SampledBit |
ImageUsageFlags.ColorAttachmentBit |
ImageUsageFlags.StorageBit |
ImageUsageFlags.TransferSrcBit
: ImageUsageFlags.TransferDstBit | ImageUsageFlags.SampledBit,
SharingMode = SharingMode.Exclusive,
InitialLayout = ImageLayout.Undefined,
};
Check(_vk.CreateImage(_device, &imageInfo, null, out var image), "vkCreateImage(texture)");
_vk.GetImageMemoryRequirements(_device, image, out var imageRequirements);
var memoryInfo = new MemoryAllocateInfo
{
SType = StructureType.MemoryAllocateInfo,
AllocationSize = imageRequirements.Size,
MemoryTypeIndex = FindMemoryType(
imageRequirements.MemoryTypeBits,
MemoryPropertyFlags.DeviceLocalBit),
};
Check(_vk.AllocateMemory(_device, &memoryInfo, null, out var imageMemory), "vkAllocateMemory(texture)");
Check(_vk.BindImageMemory(_device, image, imageMemory, 0), "vkBindImageMemory(texture)");
var viewInfo = new ImageViewCreateInfo
{
SType = StructureType.ImageViewCreateInfo,
Image = image,
ViewType = ImageViewType.Type2D,
Format = vkFormat,
Components = ToVkComponentMapping(texture.DstSelect),
SubresourceRange = ColorSubresourceRange(),
};
Check(_vk.CreateImageView(_device, &viewInfo, null, out var view), "vkCreateImageView(texture)");
var debugName = TextureDebugName(texture, vkFormat);
SetDebugName(ObjectType.Image, image.Handle, $"{debugName} image");
SetDebugName(ObjectType.ImageView, view.Handle, $"{debugName} view");
var resource = new TextureResource
{
Address = texture.Address,
StagingBuffer = stagingBuffer,
StagingMemory = stagingMemory,
Image = image,
ImageMemory = imageMemory,
View = view,
Width = width,
Height = height,
RowLength = rowLength,
DstSelect = texture.DstSelect,
NeedsUpload = true,
OwnsStorage = true,
SamplerState = texture.Sampler,
CpuContentFingerprint = contentFingerprint,
UpdatesCpuContent = texture.Address != 0,
};
if (texture.Address != 0 &&
!_guestImages.ContainsKey(texture.Address))
{
var guestFormat = GetGuestTextureFormat(texture.Format, texture.NumberType);
var guestImage = new GuestImageResource
{
Address = texture.Address,
Width = width,
Height = height,
MipLevels = 1,
GuestFormat = guestFormat,
Format = vkFormat,
Image = image,
Memory = imageMemory,
View = view,
InitialUploadPending = true,
IsCpuBacked = true,
CpuContentFingerprint = contentFingerprint,
};
_guestImages.Add(texture.Address, guestImage);
resource.OwnsStorage = false;
resource.GuestImage = guestImage;
lock (_gate)
{
if (guestFormat != 0)
{
_availableGuestImages[texture.Address] = guestFormat;
}
_guestImageExtents[texture.Address] =
(width, height, expectedSize);
}
}
return resource;
}
private (VkBuffer Buffer, DeviceMemory Memory) CreateTextureStagingBuffer(
byte[] pixels,
string debugName)
{
var size = (ulong)pixels.Length;
var buffer = CreateBuffer(
size,
BufferUsageFlags.TransferSrcBit,
MemoryPropertyFlags.HostVisibleBit | MemoryPropertyFlags.HostCoherentBit,
out var memory);
void* mapped;
Check(_vk.MapMemory(_device, memory, 0, size, 0, &mapped), "vkMapMemory(texture)");
fixed (byte* source = pixels)
{
System.Buffer.MemoryCopy(source, mapped, pixels.Length, pixels.Length);
}
_vk.UnmapMemory(_device, memory);
SetDebugName(ObjectType.Buffer, buffer.Handle, debugName);
return (buffer, memory);
}
private static ulong ComputeTextureContentFingerprint(ReadOnlySpan<byte> pixels)
{
const ulong offsetBasis = 14695981039346656037;
const ulong prime = 1099511628211;
var h0 = offsetBasis ^ (ulong)pixels.Length;
var h1 = offsetBasis;
var h2 = offsetBasis;
var h3 = offsetBasis;
var words = MemoryMarshal.Cast<byte, ulong>(pixels);
var index = 0;
var blockEnd = words.Length - (words.Length & 3);
for (; index < blockEnd; index += 4)
{
h0 = (h0 ^ words[index]) * prime;
h1 = (h1 ^ words[index + 1]) * prime;
h2 = (h2 ^ words[index + 2]) * prime;
h3 = (h3 ^ words[index + 3]) * prime;
}
for (; index < words.Length; index++)
{
h0 = (h0 ^ words[index]) * prime;
}
var hash = h0;
hash = (hash ^ h1) * prime;
hash = (hash ^ h2) * prime;
hash = (hash ^ h3) * prime;
foreach (var value in pixels[(words.Length * sizeof(ulong))..])
{
hash = (hash ^ value) * prime;
}
return hash;
}
/// <summary>
/// Creates a draw-local sampled image containing the render target's
/// value immediately before the draw. RDNA permits a pixel shader to
/// read an attachment while producing its replacement value, whereas
/// core Vulkan does not permit the same subresource to be both a
/// sampled image and a color attachment. Keeping the two images
/// distinct preserves the guest's read-before-write behavior without
/// a queue idle or an undefined Vulkan feedback loop.
/// </summary>
private TextureResource CreateRenderTargetFeedbackSnapshot(
GuestDrawTexture texture,
GuestImageResource source)
{
var image = default(Image);
var memory = default(DeviceMemory);
var view = default(ImageView);
try
{
var imageInfo = new ImageCreateInfo
{
SType = StructureType.ImageCreateInfo,
Flags =
ImageCreateFlags.CreateMutableFormatBit |
ImageCreateFlags.CreateExtendedUsageBit,
ImageType = ImageType.Type2D,
Format = source.Format,
Extent = new Extent3D(source.Width, source.Height, 1),
MipLevels = source.MipLevels,
ArrayLayers = 1,
Samples = SampleCountFlags.Count1Bit,
Tiling = ImageTiling.Optimal,
Usage =
ImageUsageFlags.TransferDstBit |
ImageUsageFlags.SampledBit,
SharingMode = SharingMode.Exclusive,
InitialLayout = ImageLayout.Undefined,
};
Check(
_vk.CreateImage(_device, &imageInfo, null, out image),
"vkCreateImage(render-target feedback snapshot)");
_vk.GetImageMemoryRequirements(_device, image, out var requirements);
var memoryInfo = new MemoryAllocateInfo
{
SType = StructureType.MemoryAllocateInfo,
AllocationSize = requirements.Size,
MemoryTypeIndex = FindMemoryType(
requirements.MemoryTypeBits,
MemoryPropertyFlags.DeviceLocalBit),
};
Check(
_vk.AllocateMemory(_device, &memoryInfo, null, out memory),
"vkAllocateMemory(render-target feedback snapshot)");
Check(
_vk.BindImageMemory(_device, image, memory, 0),
"vkBindImageMemory(render-target feedback snapshot)");
var viewFormat = GetTextureFormat(texture.Format, texture.NumberType);
if (!IsCompatibleViewFormat(source.Format, viewFormat))
{
throw new InvalidOperationException(
$"Feedback view format {viewFormat} is incompatible with " +
$"render-target format {source.Format}.");
}
var viewInfo = new ImageViewCreateInfo
{
SType = StructureType.ImageViewCreateInfo,
Image = image,
ViewType = ImageViewType.Type2D,
Format = viewFormat,
Components = ToVkComponentMapping(texture.DstSelect),
SubresourceRange = ColorSubresourceRange(0, source.MipLevels),
};
Check(
_vk.CreateImageView(_device, &viewInfo, null, out view),
"vkCreateImageView(render-target feedback snapshot)");
var debugName =
$"SharpEmu feedback 0x{source.Address:X16} " +
$"{source.Width}x{source.Height} {source.Format}->{viewFormat}";
SetDebugName(ObjectType.Image, image.Handle, $"{debugName} image");
SetDebugName(ObjectType.ImageView, view.Handle, $"{debugName} view");
TraceVulkanShader(
$"vk.feedback_snapshot_create addr=0x{source.Address:X16} " +
$"size={source.Width}x{source.Height} mips={source.MipLevels} " +
$"image_format={source.Format} view_format={viewFormat} " +
$"dst=0x{texture.DstSelect:X3}");
return new TextureResource
{
Address = texture.Address,
Image = image,
ImageMemory = memory,
View = view,
Width = source.Width,
Height = source.Height,
RowLength = source.Width,
DstSelect = texture.DstSelect,
OwnsStorage = true,
SamplerState = texture.Sampler,
FeedbackSource = source,
};
}
catch
{
if (view.Handle != 0)
{
_vk.DestroyImageView(_device, view, null);
}
if (image.Handle != 0)
{
_vk.DestroyImage(_device, image, null);
}
if (memory.Handle != 0)
{
_vk.FreeMemory(_device, memory, null);
}
throw;
}
}
private TextureResource CreateDepthFeedbackSnapshot(
GuestDrawTexture texture,
GuestDepthResource source)
{
var image = default(Image);
var memory = default(DeviceMemory);
var view = default(ImageView);
try
{
var imageInfo = new ImageCreateInfo
{
SType = StructureType.ImageCreateInfo,
ImageType = ImageType.Type2D,
Format = DepthFormat,
Extent = new Extent3D(source.Width, source.Height, 1),
MipLevels = 1,
ArrayLayers = 1,
Samples = SampleCountFlags.Count1Bit,
Tiling = ImageTiling.Optimal,
Usage = ImageUsageFlags.TransferDstBit | ImageUsageFlags.SampledBit,
SharingMode = SharingMode.Exclusive,
InitialLayout = ImageLayout.Undefined,
};
Check(
_vk.CreateImage(_device, &imageInfo, null, out image),
"vkCreateImage(depth feedback snapshot)");
_vk.GetImageMemoryRequirements(_device, image, out var requirements);
var memoryInfo = new MemoryAllocateInfo
{
SType = StructureType.MemoryAllocateInfo,
AllocationSize = requirements.Size,
MemoryTypeIndex = FindMemoryType(
requirements.MemoryTypeBits,
MemoryPropertyFlags.DeviceLocalBit),
};
Check(
_vk.AllocateMemory(_device, &memoryInfo, null, out memory),
"vkAllocateMemory(depth feedback snapshot)");
Check(
_vk.BindImageMemory(_device, image, memory, 0),
"vkBindImageMemory(depth feedback snapshot)");
var viewInfo = new ImageViewCreateInfo
{
SType = StructureType.ImageViewCreateInfo,
Image = image,
ViewType = ImageViewType.Type2D,
Format = DepthFormat,
Components = ToVkComponentMapping(texture.DstSelect),
SubresourceRange = new ImageSubresourceRange(
ImageAspectFlags.DepthBit,
0,
1,
0,
1),
};
Check(
_vk.CreateImageView(_device, &viewInfo, null, out view),
"vkCreateImageView(depth feedback snapshot)");
SetDebugName(
ObjectType.Image,
image.Handle,
$"SharpEmu depth feedback 0x{source.Address:X16} image");
SetDebugName(
ObjectType.ImageView,
view.Handle,
$"SharpEmu depth feedback 0x{source.Address:X16} view");
return new TextureResource
{
Address = texture.Address,
Image = image,
ImageMemory = memory,
View = view,
Width = source.Width,
Height = source.Height,
RowLength = source.Width,
DstSelect = texture.DstSelect,
OwnsStorage = true,
SamplerState = texture.Sampler,
DepthFeedbackSource = source,
};
}
catch
{
if (view.Handle != 0)
{
_vk.DestroyImageView(_device, view, null);
}
if (image.Handle != 0)
{
_vk.DestroyImage(_device, image, null);
}
if (memory.Handle != 0)
{
_vk.FreeMemory(_device, memory, null);
}
throw;
}
}
private void DumpTextureUpload(
GuestDrawTexture texture,
byte[] pixels,
uint rowLength,
uint width,
uint height)
{
if (!string.Equals(
Environment.GetEnvironmentVariable("SHARPEMU_DUMP_TEXTURES"),
"1",
StringComparison.Ordinal) ||
texture.IsFallback ||
texture.IsStorage ||
GetTextureBytesPerPixel(texture.Format) != 4 ||
width == 0 ||
height == 0 ||
!_dumpedTextures.Add((texture.Address, width, height, texture.Format)))
{
return;
}
var rowBytes = checked((int)rowLength * 4);
var visibleRowBytes = checked((int)width * 4);
if (pixels.Length < checked(rowBytes * (int)height))
{
return;
}
var directory = Path.Combine(AppContext.BaseDirectory, "texture-dumps");
Directory.CreateDirectory(directory);
var path = Path.Combine(
directory,
$"tex-{texture.Address:X16}-{width}x{height}-fmt{texture.Format}-row{rowLength}.bmp");
WriteRgbaBmp(path, pixels, rowBytes, visibleRowBytes, (int)width, (int)height);
}
private void TraceTextureUploadContents(
GuestDrawTexture texture,
byte[] pixels,
uint rowLength,
uint width,
uint height,
Format format)
{
if (!_traceGuestImageAddressFilterEnabled ||
!AddressListContains("SHARPEMU_TRACE_GUEST_IMAGE_ADDRS", texture.Address) ||
!_tracedTextureUploadContents.Add(
(texture.Address, width, height, texture.Format)))
{
return;
}
var bytesPerPixel = checked((uint)GetTextureBytesPerPixel(texture.Format));
var nonzeroBytes = 0L;
ulong hash = 14695981039346656037UL;
foreach (var value in pixels)
{
nonzeroBytes += value == 0 ? 0 : 1;
hash = (hash ^ value) * 1099511628211UL;
}
var centerOffset = checked(
((int)(height / 2) * (int)rowLength + (int)(width / 2)) *
(int)bytesPerPixel);
var center = centerOffset + bytesPerPixel <= pixels.Length
? Convert.ToHexString(
pixels.AsSpan(centerOffset, checked((int)bytesPerPixel)))
: "out-of-range";
Console.Error.WriteLine(
"[LOADER][TRACE] " +
$"vk.texture_upload_contents addr=0x{texture.Address:X16} " +
$"size={width}x{height} row={rowLength} format={format} " +
$"guest_format={texture.Format} nonzero_bytes={nonzeroBytes}/{pixels.Length} " +
$"nonblack_pixels={CountNonblackPixels(pixels, format, bytesPerPixel)}/{(ulong)rowLength * height} " +
$"center={center} sample_unique={CountSampledUniquePixels(pixels, bytesPerPixel)} " +
$"hash=0x{hash:X16}");
var directory =
Environment.GetEnvironmentVariable("SHARPEMU_GUEST_IMAGE_DUMP_DIR");
if (string.IsNullOrWhiteSpace(directory))
{
return;
}
Directory.CreateDirectory(directory);
var sequence = Interlocked.Increment(ref _guestImageDumpSequence);
var path = Path.Combine(
directory,
$"{sequence:D4}-texture-0x{texture.Address:X16}-{width}x{height}-" +
$"row{rowLength}-fmt{texture.Format}-{format}.rgba");
File.WriteAllBytes(path, pixels);
}
private static void WriteRgbaBmp(
string path,
byte[] rgba,
int sourceRowBytes,
int visibleRowBytes,
int width,
int height)
{
const int fileHeaderSize = 14;
const int infoHeaderSize = 40;
const int bytesPerPixel = 4;
var pixelBytes = checked(width * height * bytesPerPixel);
var fileSize = fileHeaderSize + infoHeaderSize + pixelBytes;
var output = new byte[fileSize];
output[0] = (byte)'B';
output[1] = (byte)'M';
WriteUInt32(output, 2, (uint)fileSize);
WriteUInt32(output, 10, fileHeaderSize + infoHeaderSize);
WriteUInt32(output, 14, infoHeaderSize);
WriteInt32(output, 18, width);
WriteInt32(output, 22, -height);
WriteUInt16(output, 26, 1);
WriteUInt16(output, 28, 32);
WriteUInt32(output, 34, (uint)pixelBytes);
var destinationOffset = fileHeaderSize + infoHeaderSize;
for (var y = 0; y < height; y++)
{
var sourceOffset = y * sourceRowBytes;
for (var x = 0; x < visibleRowBytes; x += bytesPerPixel)
{
var destination = destinationOffset + y * visibleRowBytes + x;
output[destination + 0] = rgba[sourceOffset + x + 2];
output[destination + 1] = rgba[sourceOffset + x + 1];
output[destination + 2] = rgba[sourceOffset + x + 0];
output[destination + 3] = rgba[sourceOffset + x + 3];
}
}
File.WriteAllBytes(path, output);
}
private static void WriteUInt16(byte[] output, int offset, ushort value)
{
output[offset + 0] = (byte)value;
output[offset + 1] = (byte)(value >> 8);
}
private static void WriteUInt32(byte[] output, int offset, uint value)
{
output[offset + 0] = (byte)value;
output[offset + 1] = (byte)(value >> 8);
output[offset + 2] = (byte)(value >> 16);
output[offset + 3] = (byte)(value >> 24);
}
private static void WriteInt32(byte[] output, int offset, int value) =>
WriteUInt32(output, offset, unchecked((uint)value));
private Sampler CreateSampler(GuestSampler sampler)
{
if (_samplers.TryGetValue(sampler, out var cachedSampler))
{
return cachedSampler;
}
var minLod = DecodeSamplerMipFilter(sampler) == 0
? 0f
: DecodeSamplerMinLod(sampler);
var maxLod = DecodeSamplerMipFilter(sampler) == 0
? 0f
: DecodeSamplerMaxLod(sampler);
var samplerInfo = new SamplerCreateInfo
{
SType = StructureType.SamplerCreateInfo,
MagFilter = ToVkFilter(DecodeSamplerMagFilter(sampler)),
MinFilter = ToVkFilter(DecodeSamplerMinFilter(sampler)),
MipmapMode = ToVkMipFilter(DecodeSamplerMipFilter(sampler)),
AddressModeU = ToVkSamplerAddressMode(DecodeSamplerClampX(sampler)),
AddressModeV = ToVkSamplerAddressMode(DecodeSamplerClampY(sampler)),
AddressModeW = ToVkSamplerAddressMode(DecodeSamplerClampZ(sampler)),
MipLodBias = DecodeSamplerLodBias(sampler),
CompareEnable = DecodeSamplerDepthCompare(sampler) != 0,
CompareOp = ToVkCompareOp(DecodeSamplerDepthCompare(sampler)),
MinLod = minLod,
MaxLod = Math.Max(minLod, maxLod),
BorderColor = ToVkBorderColor(DecodeSamplerBorderColor(sampler)),
};
Sampler vkSampler;
Check(
_vk.CreateSampler(_device, &samplerInfo, null, out vkSampler),
"vkCreateSampler(texture)");
_samplers.Add(sampler, vkSampler);
return vkSampler;
}
private static ComponentMapping ToVkComponentMapping(uint dstSelect)
{
return new ComponentMapping(
ToVkComponentSwizzle(dstSelect & 0x7),
ToVkComponentSwizzle((dstSelect >> 3) & 0x7),
ToVkComponentSwizzle((dstSelect >> 6) & 0x7),
ToVkComponentSwizzle((dstSelect >> 9) & 0x7));
}
private static ComponentSwizzle ToVkComponentSwizzle(uint selector) =>
selector switch
{
0 => ComponentSwizzle.Zero,
1 => ComponentSwizzle.One,
4 => ComponentSwizzle.R,
5 => ComponentSwizzle.G,
6 => ComponentSwizzle.B,
7 => ComponentSwizzle.A,
_ => ComponentSwizzle.Identity,
};
private static byte[] ExpandRgb32Pixels(byte[] pixels)
{
var texelCount = pixels.Length / 12;
var expanded = new byte[checked(texelCount * 16)];
for (var texel = 0; texel < texelCount; texel++)
{
System.Buffer.BlockCopy(pixels, texel * 12, expanded, texel * 16, 12);
expanded[texel * 16 + 14] = 0x80;
expanded[texel * 16 + 15] = 0x3F;
}
return expanded;
}
private GlobalBufferResource CreateGlobalBufferResource(
GuestMemoryBuffer guestBuffer)
{
if (guestBuffer.BaseAddress == 0)
{
return CreateTransientGlobalBufferResource(guestBuffer);
}
var size = (ulong)Math.Max(guestBuffer.Length, sizeof(uint));
var endAddress = checked(guestBuffer.BaseAddress + size);
GuestBufferAllocation? allocation = null;
var allocationPriority = -1;
// This runs for every bound global buffer. Preserve the previous
// stable queue preference without allocating LINQ sort state.
foreach (var candidate in _guestBufferAllocations)
{
if (candidate.BaseAddress > guestBuffer.BaseAddress ||
candidate.BaseAddress + candidate.Size < endAddress)
{
continue;
}
var candidatePriority = string.Equals(
candidate.QueueName,
_activeGuestQueue.Name,
StringComparison.Ordinal)
? 2
: string.Equals(
candidate.QueueName,
SharedReadOnlyGuestBufferQueue,
StringComparison.Ordinal)
? 1
: 0;
if (candidatePriority > allocationPriority)
{
allocation = candidate;
allocationPriority = candidatePriority;
}
}
if (allocation is null)
{
throw new InvalidOperationException(
$"no Vulkan guest buffer allocation covers " +
$"0x{guestBuffer.BaseAddress:X16}-0x{endAddress:X16}");
}
var guestOffset = guestBuffer.BaseAddress - allocation.BaseAddress;
var descriptorOffset = guestOffset &
~(GuestStorageBufferOffsetAlignment - 1);
var byteBias = guestOffset - descriptorOffset;
if (descriptorOffset % _minStorageBufferOffsetAlignment != 0)
{
throw new InvalidOperationException(
$"guest buffer alias offset 0x{descriptorOffset:X} is not aligned to Vulkan's " +
$"minStorageBufferOffsetAlignment={_minStorageBufferOffsetAlignment}");
}
var expectedBias = guestBuffer.BaseAddress &
(GuestStorageBufferOffsetAlignment - 1);
if (byteBias != expectedBias)
{
throw new InvalidOperationException(
$"guest buffer allocation base 0x{allocation.BaseAddress:X16} " +
$"does not satisfy alias alignment " +
$"{GuestStorageBufferOffsetAlignment}");
}
var source = guestBuffer.Data.AsSpan(0, guestBuffer.Length);
var shadow = allocation.Shadow.AsSpan(checked((int)guestOffset), guestBuffer.Length);
if (!source.SequenceEqual(shadow))
{
// HOST_COHERENT does not permit racing a mapped CPU write with
// an in-flight shader access. Retire prior users, publish their
// dirty ranges to guest memory, then upload the current guest
// bytes (which may be newer than the parser's captured array).
if (string.Equals(
allocation.QueueName,
SharedReadOnlyGuestBufferQueue,
StringComparison.Ordinal))
{
// Shared read-only storage can still be referenced by any
// logical guest queue, so replacing its mapped contents
// requires retiring every prior reader.
WaitForAllGuestSubmissionsForCpuVisibility();
WriteBackAllDirtyGuestBuffers();
}
else
{
// Writable aliases are private to one logical guest queue.
// Retiring unrelated queues here recreates the global FIFO
// and turns routine buffer refreshes into queue-wide stalls.
WaitForActiveGuestQueueSubmissionsForCpuVisibility();
WriteBackAllDirtyGuestBuffers(_activeGuestQueue.Name);
}
// Populate the cached shadow copy first and write it out to the
// mapped allocation in one pass. The mapped memory is
// HOST_VISIBLE|HOST_COHERENT (write-combined on most drivers),
// so CPU reads from it are uncached and orders of magnitude
// slower than heap reads — never use it as a copy source.
if (_guestMemory?.TryRead(guestBuffer.BaseAddress, shadow) != true)
{
source.CopyTo(shadow);
}
shadow.CopyTo(new Span<byte>(
(void*)(allocation.Mapped + checked((nint)guestOffset)),
guestBuffer.Length));
}
if (ShouldTraceVulkanResources() &&
_tracedGlobalBuffers.Add((guestBuffer.BaseAddress, guestBuffer.Length)))
{
Console.Error.WriteLine(
$"[LOADER][TRACE] vk.global_buffer base=0x{guestBuffer.BaseAddress:X16} " +
$"bytes={guestBuffer.Length}");
}
if (guestBuffer.Pooled)
{
GuestDataPool.Return(guestBuffer.Data);
}
return new GlobalBufferResource
{
BaseAddress = guestBuffer.BaseAddress,
Writable = guestBuffer.Writable,
WriteBackToGuest = guestBuffer.WriteBackToGuest,
Buffer = allocation.Buffer,
Memory = allocation.Memory,
Mapped = allocation.Mapped + checked((nint)guestOffset),
Offset = descriptorOffset,
Size = checked((size + byteBias + 3) & ~3UL),
GuestOffset = guestOffset,
GuestSize = size,
Allocation = allocation,
};
}
private GlobalBufferResource CreateTransientGlobalBufferResource(
GuestMemoryBuffer guestBuffer)
{
var buffer = CreateHostBuffer(
guestBuffer.Data.AsSpan(0, guestBuffer.Length),
BufferUsageFlags.StorageBufferBit,
out var memory,
out var mapped);
if (guestBuffer.Pooled)
{
GuestDataPool.Return(guestBuffer.Data);
}
return new GlobalBufferResource
{
BaseAddress = 0,
Writable = false,
WriteBackToGuest = false,
Buffer = buffer,
Memory = memory,
Mapped = mapped,
Offset = 0,
Size = (ulong)Math.Max(guestBuffer.Length, sizeof(uint)),
GuestOffset = 0,
GuestSize = (ulong)Math.Max(guestBuffer.Length, sizeof(uint)),
};
}
private void PrepareGuestBufferAllocations(
IReadOnlyList<GuestMemoryBuffer> buffers)
{
if (buffers.Count == 0)
{
return;
}
var ranges = new List<(ulong Start, ulong End, bool Writable)>(buffers.Count);
foreach (var buffer in buffers)
{
if (buffer.BaseAddress == 0)
{
continue;
}
var size = (ulong)Math.Max(buffer.Length, sizeof(uint));
var alignedStart = buffer.BaseAddress &
~(GuestStorageBufferOffsetAlignment - 1);
var paddedEnd = checked(buffer.BaseAddress + size + 3) & ~3UL;
ranges.Add((
alignedStart,
paddedEnd,
buffer.Writable && buffer.WriteBackToGuest));
}
if (ranges.Count == 0)
{
return;
}
ranges.Sort(static (left, right) => left.Start.CompareTo(right.Start));
var merged = new List<(ulong Start, ulong End, bool Writable)>(ranges.Count);
foreach (var range in ranges)
{
if (merged.Count == 0 || range.Start > merged[^1].End)
{
merged.Add(range);
continue;
}
var previous = merged[^1];
merged[^1] = (
previous.Start,
Math.Max(previous.End, range.End),
previous.Writable || range.Writable);
}
foreach (var range in merged)
{
EnsureGuestBufferAllocation(
range.Start,
range.End,
range.Writable
? _activeGuestQueue.Name
: SharedReadOnlyGuestBufferQueue);
}
}
private void EnsureGuestBufferAllocation(
ulong requestedStart,
ulong requestedEnd,
string queueName)
{
var start = requestedStart;
var end = requestedEnd;
List<GuestBufferAllocation> overlaps;
do
{
overlaps = _guestBufferAllocations
.Where(allocation =>
string.Equals(
allocation.QueueName,
queueName,
StringComparison.Ordinal) &&
allocation.BaseAddress < end &&
start < allocation.BaseAddress + allocation.Size)
.ToList();
var expandedStart = overlaps.Aggregate(
start,
static (value, allocation) => Math.Min(value, allocation.BaseAddress));
var expandedEnd = overlaps.Aggregate(
end,
static (value, allocation) =>
Math.Max(value, allocation.BaseAddress + allocation.Size));
if (expandedStart == start && expandedEnd == end)
{
break;
}
start = expandedStart;
end = expandedEnd;
}
while (true);
if (overlaps.Count == 1 &&
overlaps[0].BaseAddress <= requestedStart &&
overlaps[0].BaseAddress + overlaps[0].Size >= requestedEnd)
{
return;
}
if (overlaps.Count > 0)
{
// Growing/merging an aliased allocation is rare. Synchronize
// only this structural transition so no in-flight descriptor
// can observe storage being replaced underneath it.
WaitForAllGuestSubmissionsForCpuVisibility();
WriteBackAllDirtyGuestBuffers();
}
var replacement = CreateGuestBufferAllocation(start, end, queueName);
foreach (var overlap in overlaps)
{
_guestBufferAllocations.Remove(overlap);
DestroyGuestBufferAllocation(overlap);
}
_guestBufferAllocations.Add(replacement);
_guestBufferAllocations.Sort(static (left, right) =>
{
var queueOrder = string.CompareOrdinal(left.QueueName, right.QueueName);
return queueOrder != 0
? queueOrder
: left.BaseAddress.CompareTo(right.BaseAddress);
});
TraceVulkanShader(
$"vk.guest_buffer_allocation queue={queueName} " +
$"base=0x{start:X16} bytes={replacement.Size} " +
$"merged={overlaps.Count}");
}
private GuestBufferAllocation CreateGuestBufferAllocation(
ulong start,
ulong end,
string queueName)
{
var size = checked(end - start);
if (size == 0 || size > int.MaxValue)
{
throw new InvalidOperationException(
$"guest buffer allocation is outside the supported host span: " +
$"base=0x{start:X16} bytes={size}");
}
var buffer = CreateBuffer(
size,
BufferUsageFlags.StorageBufferBit,
MemoryPropertyFlags.HostVisibleBit | MemoryPropertyFlags.HostCoherentBit,
out var memory);
void* mapped;
Check(_vk.MapMemory(_device, memory, 0, size, 0, &mapped), "vkMapMemory(guest buffer)");
var shadow = new byte[checked((int)size)];
_ = _guestMemory?.TryRead(start, shadow);
shadow.CopyTo(new Span<byte>(mapped, shadow.Length));
SetDebugName(
ObjectType.Buffer,
buffer.Handle,
$"SharpEmu guest VA 0x{start:X16}-0x{end:X16}");
return new GuestBufferAllocation
{
QueueName = queueName,
BaseAddress = start,
Size = size,
Buffer = buffer,
Memory = memory,
Mapped = (nint)mapped,
Shadow = shadow,
};
}
private void DestroyGuestBufferAllocation(GuestBufferAllocation allocation)
{
if (allocation.Mapped != 0)
{
_vk.UnmapMemory(_device, allocation.Memory);
}
_vk.DestroyBuffer(_device, allocation.Buffer, null);
_vk.FreeMemory(_device, allocation.Memory, null);
}
private VertexBufferResource CreateVertexBufferResource(
GuestVertexBuffer guestBuffer)
{
ReadOnlySpan<byte> source = guestBuffer.Data.AsSpan(0, guestBuffer.Length);
byte[]? forcedVertexColors = null;
if (_forceTitleVertexColorWhite &&
guestBuffer.Location == 0 &&
guestBuffer.ComponentCount == 4 &&
guestBuffer.DataFormat == 10 &&
guestBuffer.NumberFormat == 0 &&
guestBuffer.Stride == 16 &&
guestBuffer.OffsetBytes == 12 &&
guestBuffer.Length == 67568)
{
forcedVertexColors = source.ToArray();
for (var offset = 12; offset + 3 < forcedVertexColors.Length; offset += 16)
{
forcedVertexColors[offset] = 0xFF;
forcedVertexColors[offset + 1] = 0xFF;
forcedVertexColors[offset + 2] = 0xFF;
}
source = forcedVertexColors;
Console.Error.WriteLine(
$"[LOADER][TRACE] vk.vertex_force_title_color_white " +
$"base=0x{guestBuffer.BaseAddress:X16} bytes={guestBuffer.Length}");
}
var buffer = CreateHostBuffer(
source,
BufferUsageFlags.VertexBufferBit,
out var memory,
out _);
var size = (ulong)Math.Max(guestBuffer.Length, sizeof(uint));
if (_setDebugUtilsObjectName is not null)
{
SetDebugName(
ObjectType.Buffer,
buffer.Handle,
$"SharpEmu vertex loc{guestBuffer.Location} " +
$"0x{guestBuffer.BaseAddress:X16} {guestBuffer.Length}b");
}
if (_tracedVertexBufferCount++ < 64)
{
TraceVulkanShader(
$"vk.vertex_buffer loc={guestBuffer.Location} " +
$"base=0x{guestBuffer.BaseAddress:X16} stride={guestBuffer.Stride} " +
$"offset={guestBuffer.OffsetBytes} comps={guestBuffer.ComponentCount} " +
$"fmt={guestBuffer.DataFormat}/num={guestBuffer.NumberFormat} " +
$"bytes={guestBuffer.Length}");
}
return CreateVertexBufferResource(
buffer,
memory,
size,
guestBuffer,
ownsBuffer: true);
}
private static VertexBufferResource CreateVertexBufferResource(
VkBuffer buffer,
DeviceMemory memory,
ulong size,
GuestVertexBuffer guestBuffer,
bool ownsBuffer)
{
return new VertexBufferResource
{
Buffer = buffer,
Memory = memory,
OwnsBuffer = ownsBuffer,
Size = size,
Location = guestBuffer.Location,
ComponentCount = guestBuffer.ComponentCount,
DataFormat = guestBuffer.DataFormat,
NumberFormat = guestBuffer.NumberFormat,
Stride = guestBuffer.Stride,
OffsetBytes = guestBuffer.OffsetBytes,
};
}
private static VertexBufferResource CreateVertexBufferAlias(
VertexBufferResource shared,
GuestVertexBuffer guestBuffer) => new()
{
Buffer = shared.Buffer,
Memory = shared.Memory,
OwnsBuffer = false,
Size = shared.Size,
Location = guestBuffer.Location,
ComponentCount = guestBuffer.ComponentCount,
DataFormat = guestBuffer.DataFormat,
NumberFormat = guestBuffer.NumberFormat,
Stride = guestBuffer.Stride,
OffsetBytes = guestBuffer.OffsetBytes,
};
private VkBuffer CreateHostBuffer(
ReadOnlySpan<byte> data,
BufferUsageFlags usage,
out DeviceMemory memory,
out nint mapped)
{
var size = (ulong)Math.Max(data.Length, sizeof(uint));
var capacity = BitOperations.RoundUpToPowerOf2(size);
var key = new VulkanHostBufferPoolKey(usage, capacity);
VulkanHostBufferAllocation allocation;
if (_hostBufferPool.TryRent(key, out var pooled))
{
allocation = pooled;
}
else
{
var buffer = CreateBuffer(
capacity,
usage,
MemoryPropertyFlags.HostVisibleBit | MemoryPropertyFlags.HostCoherentBit,
out var allocatedMemory);
// Persistently mapped: map/unmap per draw was a measurable
// share of the per-draw fixed cost, and HOST_COHERENT memory
// may legally stay mapped for its lifetime.
void* persistentMapping;
Check(
_vk.MapMemory(_device, allocatedMemory, 0, capacity, 0, &persistentMapping),
"vkMapMemory(host persistent)");
allocation = new VulkanHostBufferAllocation(
buffer,
allocatedMemory,
key,
(nint)persistentMapping);
_hostBufferPool.Register(allocation);
}
memory = allocation.Memory;
mapped = allocation.Mapped;
fixed (byte* source = data)
{
System.Buffer.MemoryCopy(
source,
(void*)allocation.Mapped,
checked((long)allocation.Key.Capacity),
data.Length);
}
return allocation.Buffer;
}
private void RecycleHostBuffer(VkBuffer buffer, DeviceMemory memory)
{
if (buffer.Handle == 0)
{
return;
}
if (_hostBufferPool.Return(buffer, memory))
{
return;
}
_vk.DestroyBuffer(_device, buffer, null);
if (memory.Handle != 0)
{
_vk.FreeMemory(_device, memory, null);
}
}
private void DestroyHostBufferAllocation(VulkanHostBufferAllocation allocation)
{
_vk.UnmapMemory(_device, allocation.Memory);
_vk.DestroyBuffer(_device, allocation.Buffer, null);
_vk.FreeMemory(_device, allocation.Memory, null);
}
private static PrimitiveTopology GetPrimitiveTopology(uint primitiveType) =>
primitiveType switch
{
1 => PrimitiveTopology.PointList,
2 => PrimitiveTopology.LineList,
3 => PrimitiveTopology.LineStrip,
5 => PrimitiveTopology.TriangleFan,
6 => PrimitiveTopology.TriangleStrip,
GuestPrimitiveRectList => PrimitiveTopology.TriangleStrip,
_ => PrimitiveTopology.TriangleList,
};
// Strip and fan topologies are the ones for which a restart index
// splits primitives; list topologies never restart.
private static bool RequiresPrimitiveRestart(PrimitiveTopology topology) =>
topology is PrimitiveTopology.LineStrip
or PrimitiveTopology.TriangleStrip
or PrimitiveTopology.TriangleFan;
private static Format ToVkVertexFormat(
uint dataFormat,
uint numberFormat,
uint componentCount) =>
(dataFormat, numberFormat) switch
{
(1, 0) => Format.R8Unorm,
(1, 1) => Format.R8SNorm,
(1, 4) => Format.R8Uint,
(1, 5) => Format.R8Sint,
(1, 9) => Format.R8Srgb,
(2, 0) => Format.R16Unorm,
(2, 1) => Format.R16SNorm,
(2, 4) => Format.R16Uint,
(2, 5) => Format.R16Sint,
(2, 7) => Format.R16Sfloat,
(3, 0) => Format.R8G8Unorm,
(3, 1) => Format.R8G8SNorm,
(3, 4) => Format.R8G8Uint,
(3, 5) => Format.R8G8Sint,
(3, 9) => Format.R8G8Srgb,
(4, 4) => Format.R32Uint,
(4, 5) => Format.R32Sint,
(4, 7) => Format.R32Sfloat,
(5, 0) => Format.R16G16Unorm,
(5, 1) => Format.R16G16SNorm,
(5, 2) => Format.R16G16Uscaled,
(5, 3) => Format.R16G16Sscaled,
(5, 4) => Format.R16G16Uint,
(5, 5) => Format.R16G16Sint,
(5, 7) => Format.R16G16Sfloat,
(6, 7) => Format.B10G11R11UfloatPack32,
(7, 7) => Format.B10G11R11UfloatPack32,
(8, 0) => Format.A2B10G10R10UnormPack32,
(8, 1) => Format.A2B10G10R10SNormPack32,
(8, 2) => Format.A2B10G10R10UscaledPack32,
(8, 3) => Format.A2B10G10R10SscaledPack32,
(8, 4) => Format.A2B10G10R10UintPack32,
(8, 5) => Format.A2B10G10R10SintPack32,
// RDNA COLOR_2_10_10_10 stores component 0 (R) in bits
// 0..9 and A in 30..31. Vulkan names that exact bit layout
// A2B10G10R10_PACK32 (the packed name is MSB-to-LSB).
(9, 0) => Format.A2B10G10R10UnormPack32,
(9, 1) => Format.A2B10G10R10SNormPack32,
(9, 2) => Format.A2B10G10R10UscaledPack32,
(9, 3) => Format.A2B10G10R10SscaledPack32,
(9, 4) => Format.A2B10G10R10UintPack32,
(9, 5) => Format.A2B10G10R10SintPack32,
(10, 0) => Format.R8G8B8A8Unorm,
(10, 1) => Format.R8G8B8A8SNorm,
(10, 2) => Format.R8G8B8A8Uscaled,
(10, 3) => Format.R8G8B8A8Sscaled,
(10, 4) => Format.R8G8B8A8Uint,
(10, 5) => Format.R8G8B8A8Sint,
(10, 9) => Format.R8G8B8A8Srgb,
(11, 4) => Format.R32G32Uint,
(11, 5) => Format.R32G32Sint,
(11, 7) => Format.R32G32Sfloat,
(12, 0) => Format.R16G16B16A16Unorm,
(12, 1) => Format.R16G16B16A16SNorm,
(12, 2) => Format.R16G16B16A16Uscaled,
(12, 3) => Format.R16G16B16A16Sscaled,
(12, 4) => Format.R16G16B16A16Uint,
(12, 5) => Format.R16G16B16A16Sint,
(12, 6) => Format.R16G16B16A16SNorm,
(12, 7) => Format.R16G16B16A16Sfloat,
(13, 4) => Format.R32G32B32Uint,
(13, 5) => Format.R32G32B32Sint,
(13, 7) => Format.R32G32B32Sfloat,
(14, 4) => Format.R32G32B32A32Uint,
(14, 5) => Format.R32G32B32A32Sint,
(14, 7) => Format.R32G32B32A32Sfloat,
(16, 0) => Format.B5G6R5UnormPack16,
(17, 0) => Format.R5G5B5A1UnormPack16,
(19, 0) => Format.R4G4B4A4UnormPack16,
(34, 7) => Format.E5B9G9R9UfloatPack32,
_ => ToVkFloatVertexFormat(componentCount),
};
private static Format ToVkFloatVertexFormat(uint componentCount) =>
componentCount switch
{
1 => Format.R32Sfloat,
2 => Format.R32G32Sfloat,
3 => Format.R32G32B32Sfloat,
4 => Format.R32G32B32A32Sfloat,
_ => Format.R32Sfloat,
};
private static ulong GetVertexBindingOffset(VertexBufferResource vertexBuffer)
{
if (vertexBuffer.OffsetBytes < vertexBuffer.Size)
{
return vertexBuffer.OffsetBytes;
}
TraceVulkanShader(
$"vk.vertex_offset_oob loc={vertexBuffer.Location} " +
$"offset={vertexBuffer.OffsetBytes} size={vertexBuffer.Size}");
return 0;
}
private static uint GetDrawVertexCount(
uint primitiveType,
uint vertexCount,
GuestIndexBuffer? indexBuffer)
{
if (primitiveType == GuestPrimitiveRectList && indexBuffer is null)
{
return 4;
}
return vertexCount;
}
private static BlendFactor ToVkBlendFactor(uint factor) =>
factor switch
{
0 => BlendFactor.Zero,
1 => BlendFactor.One,
2 => BlendFactor.SrcColor,
3 => BlendFactor.OneMinusSrcColor,
4 => BlendFactor.SrcAlpha,
5 => BlendFactor.OneMinusSrcAlpha,
6 => BlendFactor.DstAlpha,
7 => BlendFactor.OneMinusDstAlpha,
8 => BlendFactor.DstColor,
9 => BlendFactor.OneMinusDstColor,
10 => BlendFactor.SrcAlphaSaturate,
13 => BlendFactor.ConstantColor,
14 => BlendFactor.OneMinusConstantColor,
15 => BlendFactor.Src1Color,
16 => BlendFactor.OneMinusSrc1Color,
17 => BlendFactor.Src1Alpha,
18 => BlendFactor.OneMinusSrc1Alpha,
19 => BlendFactor.ConstantAlpha,
20 => BlendFactor.OneMinusConstantAlpha,
_ => BlendFactor.One,
};
private static BlendOp ToVkBlendOp(uint function) =>
function switch
{
0 => BlendOp.Add,
1 => BlendOp.Subtract,
2 => BlendOp.Min,
3 => BlendOp.Max,
4 => BlendOp.ReverseSubtract,
_ => BlendOp.Add,
};
private static uint DecodeSamplerClampX(GuestSampler sampler) =>
sampler.Word0 & 0x7u;
private static uint DecodeSamplerClampY(GuestSampler sampler) =>
(sampler.Word0 >> 3) & 0x7u;
private static uint DecodeSamplerClampZ(GuestSampler sampler) =>
(sampler.Word0 >> 6) & 0x7u;
private static uint DecodeSamplerDepthCompare(GuestSampler sampler) =>
(sampler.Word0 >> 12) & 0x7u;
private static float DecodeSamplerMinLod(GuestSampler sampler) =>
(sampler.Word1 & 0xFFFu) / 256.0f;
private static float DecodeSamplerMaxLod(GuestSampler sampler) =>
((sampler.Word1 >> 12) & 0xFFFu) / 256.0f;
private static float DecodeSamplerLodBias(GuestSampler sampler)
{
var raw = sampler.Word2 & 0x3FFFu;
var signed = (short)((raw ^ 0x2000u) - 0x2000u);
return signed / 256.0f;
}
private static uint DecodeSamplerMagFilter(GuestSampler sampler) =>
(sampler.Word2 >> 20) & 0x3u;
private static uint DecodeSamplerMinFilter(GuestSampler sampler) =>
(sampler.Word2 >> 22) & 0x3u;
private static uint DecodeSamplerMipFilter(GuestSampler sampler) =>
(sampler.Word2 >> 26) & 0x3u;
private static uint DecodeSamplerBorderColor(GuestSampler sampler) =>
(sampler.Word3 >> 30) & 0x3u;
private static SamplerAddressMode ToVkSamplerAddressMode(uint mode) =>
mode switch
{
0 => SamplerAddressMode.Repeat,
1 => SamplerAddressMode.MirroredRepeat,
2 => SamplerAddressMode.ClampToEdge,
3 or 5 or 7 => SamplerAddressMode.MirrorClampToEdge,
4 or 6 => SamplerAddressMode.ClampToBorder,
_ => SamplerAddressMode.ClampToEdge,
};
private static Filter ToVkFilter(uint filter) =>
filter is 1 or 3 ? Filter.Linear : Filter.Nearest;
private static SamplerMipmapMode ToVkMipFilter(uint filter) =>
filter == 2 ? SamplerMipmapMode.Linear : SamplerMipmapMode.Nearest;
private static CompareOp ToVkCompareOp(uint compare) =>
compare switch
{
1 => CompareOp.Less,
2 => CompareOp.Equal,
3 => CompareOp.LessOrEqual,
4 => CompareOp.Greater,
5 => CompareOp.NotEqual,
6 => CompareOp.GreaterOrEqual,
7 => CompareOp.Always,
_ => CompareOp.Never,
};
private static BorderColor ToVkBorderColor(uint color) =>
color switch
{
1 => BorderColor.FloatTransparentBlack,
2 => BorderColor.FloatOpaqueWhite,
_ => BorderColor.FloatOpaqueBlack,
};
private static ColorComponentFlags ToVkColorWriteMask(uint mask)
{
var flags = default(ColorComponentFlags);
if ((mask & 1u) != 0)
{
flags |= ColorComponentFlags.RBit;
}
if ((mask & 2u) != 0)
{
flags |= ColorComponentFlags.GBit;
}
if ((mask & 4u) != 0)
{
flags |= ColorComponentFlags.BBit;
}
if ((mask & 8u) != 0)
{
flags |= ColorComponentFlags.ABit;
}
return flags;
}
private static GuestRect ClampScissor(GuestRect? scissor, Extent2D extent)
{
if (scissor is not { } rect)
{
return new GuestRect(0, 0, extent.Width, extent.Height);
}
var left = Math.Clamp(rect.X, 0, checked((int)extent.Width));
var top = Math.Clamp(rect.Y, 0, checked((int)extent.Height));
var right = Math.Clamp(
rect.X + checked((int)rect.Width),
left,
checked((int)extent.Width));
var bottom = Math.Clamp(
rect.Y + checked((int)rect.Height),
top,
checked((int)extent.Height));
return new GuestRect(
left,
top,
checked((uint)(right - left)),
checked((uint)(bottom - top)));
}
private static readonly float ViewportDebugEpsilon = float.TryParse(
Environment.GetEnvironmentVariable("SHARPEMU_VIEWPORT_EPSILON"),
System.Globalization.NumberStyles.Float,
System.Globalization.CultureInfo.InvariantCulture,
out var viewportEpsilon)
? viewportEpsilon
: 0f;
private static Viewport ClampViewport(GuestViewport? viewport, Extent2D extent)
{
if (viewport is not { } rect)
{
return new Viewport(0, 0, extent.Width, extent.Height, 0, 1);
}
// Do NOT trim the rectangle to the render target: Vulkan allows
// viewports that extend beyond the framebuffer (rendering is
// confined by the scissor), and trimming changes the guest's
// scale and offset. That skews texel addressing on 1:1 draws -
// source rows get skipped or duplicated - which shredded the
// game's pre-composed tile surfaces. Only guard what the spec
// requires: a positive width and hardware viewport bounds.
const float bound = 32767f;
var x = Math.Clamp(rect.X, -bound, bound);
var y = Math.Clamp(rect.Y, -bound, bound);
var width = Math.Clamp(rect.Width, 1e-3f, bound);
var height = Math.Clamp(rect.Height, -bound, bound);
if (height == 0f)
{
height = extent.Height;
}
var minDepth = Math.Clamp(rect.MinDepth, 0f, 1f);
var maxDepth = Math.Clamp(rect.MaxDepth, minDepth, 1f);
return new Viewport(x, y, width, height, minDepth, maxDepth);
}
private static byte[] CreateFallbackTexturePixels(uint format, uint width, uint height, ulong expectedSize)
{
if (format is 9 or 10)
{
return CreateBlackFrame(width, height);
}
return new byte[checked((int)expectedSize)];
}
private static ulong GetTextureBytesPerPixel(uint format) =>
format switch
{
1 => 1UL,
2 => 2UL,
3 => 2UL,
4 => 4UL,
5 => 4UL,
6 => 4UL,
7 => 4UL,
9 => 4UL,
10 => 4UL,
11 => 8UL,
12 => 8UL,
13 => 12UL,
14 => 16UL,
_ => 4UL,
};
private static ulong GetTextureByteCount(uint format, uint width, uint height)
{
var blockBytes = format switch
{
// BC1 (169/170) and BC4 (175/176) are 8 bytes per 4x4 block;
// BC2/BC3/BC5/BC6H/BC7 are 16 bytes per block.
169 or 170 or 175 or 176 => 8UL,
171 or 172 or 173 or 174 or
177 or 178 or 179 or 180 or 181 or 182 => 16UL,
_ => 0UL,
};
return blockBytes == 0
? checked((ulong)width * height * GetTextureBytesPerPixel(format))
: checked(((ulong)width + 3) / 4 * (((ulong)height + 3) / 4) * blockBytes);
}
private bool SupportsColorAttachment(Format format)
{
_vk.GetPhysicalDeviceFormatProperties(_physicalDevice, format, out var properties);
return (properties.OptimalTilingFeatures & FormatFeatureFlags.ColorAttachmentBit) != 0;
}
internal static Format GetTextureFormat(uint format, uint numberType) =>
(format, numberType) switch
{
(9, _) => Format.A2B10G10R10UnormPack32,
(1, 0) => Format.R8Unorm,
(1, 1) => Format.R8SNorm,
(1, 2) => Format.R8Uscaled,
(1, 3) => Format.R8Sscaled,
(1, 4) => Format.R8Uint,
(1, 5) => Format.R8Sint,
(2, 7) => Format.R16Sfloat,
(2, 0) => Format.R16Unorm,
(2, 1) => Format.R16SNorm,
(2, 2) => Format.R16Uscaled,
(2, 3) => Format.R16Sscaled,
(2, 4) => Format.R16Uint,
(2, 5) => Format.R16Sint,
(3, 0) => Format.R8G8Unorm,
(3, 1) => Format.R8G8SNorm,
(3, 2) => Format.R8G8Uscaled,
(3, 3) => Format.R8G8Sscaled,
(3, 4) => Format.R8G8Uint,
(3, 5) => Format.R8G8Sint,
(4, 4) => Format.R32Uint,
(4, 5) => Format.R32Sint,
(4, 7) => Format.R32Sfloat,
(5, 0) => Format.R16G16Unorm,
(5, 4) => Format.R16G16Uint,
(5, 5) => Format.R16G16Sint,
(5, 7) => Format.R16G16Sfloat,
(6, 7) => Format.B10G11R11UfloatPack32,
(7, 7) => Format.B10G11R11UfloatPack32,
(8, 0) => Format.A2B10G10R10UnormPack32,
(8, 1) => Format.A2B10G10R10SNormPack32,
(8, 2) => Format.A2B10G10R10UscaledPack32,
(8, 3) => Format.A2B10G10R10SscaledPack32,
(8, 4) => Format.A2B10G10R10UintPack32,
(8, 5) => Format.A2B10G10R10SintPack32,
(10, 0) => Format.R8G8B8A8Unorm,
(10, 4) => Format.R8G8B8A8Uint,
(10, 5) => Format.R8G8B8A8Sint,
(10, 9) => Format.R8G8B8A8Srgb,
(1, 9) => Format.R8Srgb,
(3, 9) => Format.R8G8Srgb,
(11, 4) => Format.R32G32Uint,
(11, 5) => Format.R32G32Sint,
(11, 7) => Format.R32G32Sfloat,
(12, 0) => Format.R16G16B16A16Unorm,
(12, 4) => Format.R16G16B16A16Uint,
(12, 5) => Format.R16G16B16A16Sint,
(12, 7) => Format.R16G16B16A16Sfloat,
(13, 4) => Format.R32G32B32A32Uint,
(13, 5) => Format.R32G32B32A32Sint,
(13, _) => Format.R32G32B32A32Sfloat,
(14, 4) => Format.R32G32B32A32Uint,
(14, 5) => Format.R32G32B32A32Sint,
(14, 7) => Format.R32G32B32A32Sfloat,
(16, 0) => Format.B5G6R5UnormPack16,
(17, 0) => Format.R5G5B5A1UnormPack16,
(19, 0) => Format.R4G4B4A4UnormPack16,
(34, 7) => Format.E5B9G9R9UfloatPack32,
(169, _) => Format.BC1RgbaUnormBlock,
(170, _) => Format.BC1RgbaSrgbBlock,
(171, _) => Format.BC2UnormBlock,
(172, _) => Format.BC2SrgbBlock,
(173, _) => Format.BC3UnormBlock,
(174, _) => Format.BC3SrgbBlock,
(175, 1) => Format.BC4SNormBlock,
(175, _) => Format.BC4UnormBlock,
(176, _) => Format.BC4SNormBlock,
(177, 1) => Format.BC5SNormBlock,
(177, _) => Format.BC5UnormBlock,
(178, _) => Format.BC5SNormBlock,
(179, _) => Format.BC6HUfloatBlock,
(180, _) => Format.BC6HSfloatBlock,
(181, _) => Format.BC7UnormBlock,
(182, _) => Format.BC7SrgbBlock,
_ => Format.R8G8B8A8Unorm,
};
private static Format GetRenderTargetFormat(uint format, uint numberType) =>
(format, numberType) switch
{
(4, 4) => Format.R32Uint,
(4, 5) => Format.R32Sint,
(4, 7) => Format.R32Sfloat,
(5, 4) => Format.R16G16Uint,
(5, 5) => Format.R16G16Sint,
(5, 7) => Format.R16G16Sfloat,
(6, 7) => Format.B10G11R11UfloatPack32,
(7, 7) => Format.B10G11R11UfloatPack32,
(9, _) => Format.A2B10G10R10UnormPack32,
(10, 9) => Format.R8G8B8A8Srgb,
(10, 4) => Format.R8G8B8A8Uint,
(10, 5) => Format.R8G8B8A8Sint,
(10, _) => Format.R8G8B8A8Unorm,
(11, 7) => Format.R32G32Sfloat,
(12, 4) => Format.R16G16B16A16Uint,
(12, 5) => Format.R16G16B16A16Sint,
(12, 7) => Format.R16G16B16A16Sfloat,
(13, 7) => Format.R32G32B32A32Sfloat,
(14, 7) => Format.R32G32B32A32Sfloat,
(_, 0) => GetTextureFormat(format, numberType),
(_, 9) => GetTextureFormat(format, numberType),
_ => Format.Undefined,
};
private static bool IsBlockCompressedFormat(Format format) =>
format is Format.BC1RgbaUnormBlock or
Format.BC1RgbaSrgbBlock or
Format.BC2UnormBlock or
Format.BC2SrgbBlock or
Format.BC3UnormBlock or
Format.BC3SrgbBlock or
Format.BC4UnormBlock or
Format.BC4SNormBlock or
Format.BC5UnormBlock or
Format.BC5SNormBlock or
Format.BC6HUfloatBlock or
Format.BC6HSfloatBlock or
Format.BC7UnormBlock or
Format.BC7SrgbBlock;
private VkBuffer CreateBuffer(
ulong size,
BufferUsageFlags usage,
MemoryPropertyFlags memoryFlags,
out DeviceMemory memory)
{
var bufferInfo = new BufferCreateInfo
{
SType = StructureType.BufferCreateInfo,
Size = size,
Usage = usage,
SharingMode = SharingMode.Exclusive,
};
Check(_vk.CreateBuffer(_device, &bufferInfo, null, out var buffer), "vkCreateBuffer");
_vk.GetBufferMemoryRequirements(_device, buffer, out var requirements);
var memoryInfo = new MemoryAllocateInfo
{
SType = StructureType.MemoryAllocateInfo,
AllocationSize = requirements.Size,
MemoryTypeIndex = FindMemoryType(requirements.MemoryTypeBits, memoryFlags),
};
Check(_vk.AllocateMemory(_device, &memoryInfo, null, out memory), "vkAllocateMemory");
Check(_vk.BindBufferMemory(_device, buffer, memory, 0), "vkBindBufferMemory");
return buffer;
}
private void CreateStagingBuffer(ulong size)
{
_stagingBuffer = CreateBuffer(
size,
BufferUsageFlags.TransferSrcBit | BufferUsageFlags.TransferDstBit,
MemoryPropertyFlags.HostVisibleBit | MemoryPropertyFlags.HostCoherentBit,
out _stagingMemory);
_stagingSize = size;
}
private uint FindMemoryType(uint typeBits, MemoryPropertyFlags requiredFlags)
{
_vk.GetPhysicalDeviceMemoryProperties(_physicalDevice, out var properties);
var memoryTypes = &properties.MemoryTypes.Element0;
for (uint index = 0; index < properties.MemoryTypeCount; index++)
{
if ((typeBits & (1u << (int)index)) != 0 &&
(memoryTypes[index].PropertyFlags & requiredFlags) == requiredFlags)
{
return index;
}
}
throw new InvalidOperationException("No compatible Vulkan host-visible memory type was found.");
}
private const uint MaxComputeZSlicesPerSubmission = 8;
// An indirect guest dispatch above this size is not credible frame work
// (at the minimum 64-thread group used by the captured title this is
// already over one billion invocations). Treat it as poisoned
// indirect-command data and quarantine it instead of feeding a host
// API a multi-billion-workgroup command. This is validation, not a
// clamp: the raw dimensions remain visible in the trace so the
// producer can be fixed without changing the guest value.
private const ulong MaxCredibleGuestWorkgroupsPerDispatch = 16UL * 1024 * 1024;
private void ExecuteComputeDispatch(VulkanComputeGuestDispatch work)
{
var perfStart = Stopwatch.GetTimestamp();
Interlocked.Increment(ref _perfDrawCount);
PerfOverlay.RecordDraw();
try
{
ExecuteComputeDispatchCore(work);
}
finally
{
Interlocked.Add(
ref _perfDrawTicks,
Stopwatch.GetTimestamp() - perfStart);
}
}
private void ExecuteComputeDispatchCore(VulkanComputeGuestDispatch work)
{
FlushBatchedGuestCommands();
if (_deviceLost)
{
return;
}
if (_skipAllCompute ||
AddressListContains("SHARPEMU_SKIP_COMPUTE_CS", work.ShaderAddress) ||
(_skipTallComputeZ > 0 && work.GroupCountZ >= _skipTallComputeZ))
{
TraceVulkanShader(
$"vk.compute_skip cs=0x{work.ShaderAddress:X16} " +
$"groups={work.GroupCountX}x{work.GroupCountY}x{work.GroupCountZ} " +
$"textures={work.Textures.Count}");
return;
}
if (!TryValidateComputeDispatch(work, out var validationError))
{
LogRejectedComputeDispatch(work, validationError);
return;
}
TranslatedDrawResources? resources = null;
CommandBuffer commandBuffer = default;
var submitted = false;
var chunksSubmitted = 0;
try
{
EnsureGuestSubmissionCapacity();
resources = CreateComputeDispatchResources(work);
var batchCount = Math.Max(
1u,
(uint)Math.Ceiling(work.GroupCountZ / (double)MaxComputeZSlicesPerSubmission));
var threadLimits = stackalloc uint[3]
{
work.ThreadCountX,
work.ThreadCountY,
work.ThreadCountZ,
};
for (var batchIndex = 0u; batchIndex < batchCount; batchIndex++)
{
var zStart = batchIndex * MaxComputeZSlicesPerSubmission;
var zCount = Math.Min(MaxComputeZSlicesPerSubmission, work.GroupCountZ - zStart);
var isFirstBatch = batchIndex == 0;
var isLastBatch = batchIndex == batchCount - 1;
if (!isFirstBatch)
{
// Each chunk is its own queue submission; without
// this the in-flight submission cap only applies to
// the first chunk of a tall dispatch.
EnsureGuestSubmissionCapacity();
}
commandBuffer = AllocateGuestCommandBuffer();
_commandBuffer = commandBuffer;
var beginInfo = new CommandBufferBeginInfo
{
SType = StructureType.CommandBufferBeginInfo,
Flags = CommandBufferUsageFlags.OneTimeSubmitBit,
};
Check(
_vk.BeginCommandBuffer(_commandBuffer, &beginInfo),
"vkBeginCommandBuffer(compute)");
BeginDebugLabel(_commandBuffer, resources.DebugName);
if (isFirstBatch)
{
RecordGlobalBufferVisibilityBarrier(
_commandBuffer,
resources,
PipelineStageFlags.ComputeShaderBit);
RecordTextureUploads(resources, PipelineStageFlags.ComputeShaderBit);
RecordStorageImagesForWrite(resources, PipelineStageFlags.ComputeShaderBit);
}
else
{
// Chunks are submitted without CPU waits; this
// barrier orders them against the previous chunk's
// shader writes on the same queue.
var chunkBarrier = new MemoryBarrier
{
SType = StructureType.MemoryBarrier,
SrcAccessMask = AccessFlags.ShaderWriteBit,
DstAccessMask = AccessFlags.ShaderReadBit | AccessFlags.ShaderWriteBit,
};
_vk.CmdPipelineBarrier(
_commandBuffer,
PipelineStageFlags.ComputeShaderBit,
PipelineStageFlags.ComputeShaderBit,
0,
1,
&chunkBarrier,
0,
null,
0,
null);
}
_vk.CmdBindPipeline(
_commandBuffer,
PipelineBindPoint.Compute,
resources.Pipeline);
if (resources.DescriptorSet.Handle != 0)
{
var descriptorSet = resources.DescriptorSet;
_vk.CmdBindDescriptorSets(
_commandBuffer,
PipelineBindPoint.Compute,
resources.PipelineLayout,
0,
1,
&descriptorSet,
0,
null);
}
_vk.CmdPushConstants(
_commandBuffer,
resources.PipelineLayout,
ShaderStageFlags.ComputeBit,
0,
3 * sizeof(uint),
threadLimits);
RecordChunkedComputeDispatch(_commandBuffer, work, zStart, zCount);
if (isLastBatch)
{
RecordStorageImagesForRead(resources, PipelineStageFlags.ComputeShaderBit);
}
EndDebugLabel(_commandBuffer);
Check(_vk.EndCommandBuffer(_commandBuffer), "vkEndCommandBuffer(compute)");
TraceVulkanShader(
$"vk.compute_submit cs=0x{work.ShaderAddress:X16} " +
$"batch={batchIndex}/{batchCount} z={zStart}..{zStart + zCount}");
if (isLastBatch)
{
SubmitGuestCommandBuffer(
commandBuffer,
[resources],
GetTraceImages(resources, shaderAddress: work.ShaderAddress));
submitted = true;
}
else
{
SubmitGuestCommandBuffer(
commandBuffer,
[],
[],
referencedResources: [resources]);
chunksSubmitted++;
commandBuffer = default;
}
}
MarkSampledImagesInitialized(resources);
MarkStorageImagesInitialized(resources, traceContents: false);
if (work.WritesGlobalMemory)
{
// The CPU submit thread may immediately consume an indirect
// argument written by this dispatch. Wait for the specific
// guest fences and publish only dirty ranges; a queue-wide
// idle unnecessarily serialized presentation work too.
WaitForActiveGuestQueueSubmissionsForCpuVisibility();
WriteBackAllDirtyGuestBuffers(_activeGuestQueue.Name);
}
TraceVulkanShader(
$"vk.compute_dispatch groups={work.GroupCountX}x" +
$"{work.GroupCountY}x{work.GroupCountZ} " +
$"base={work.BaseGroupX}x{work.BaseGroupY}x{work.BaseGroupZ} " +
$"textures={work.Textures.Count} cs=0x{work.ShaderAddress:X16} " +
$"batches={batchCount}");
}
catch (Exception exception)
{
if (TryMarkDeviceLost(exception))
{
return;
}
Console.Error.WriteLine(
$"[LOADER][ERROR] Vulkan compute dispatch failed " +
$"cs=0x{work.ShaderAddress:X16}: {exception.Message}");
}
finally
{
_commandBuffer = _presentationCommandBuffer;
if (!submitted && commandBuffer.Handle != 0)
{
_vk.FreeCommandBuffers(
_device,
_commandPool,
1,
&commandBuffer);
}
if (!submitted && resources is not null)
{
if (chunksSubmitted > 0)
{
// Earlier chunks were submitted with empty resource
// lists and may still execute against these
// pipelines/images; destroy only after every
// submission issued so far has completed.
_deferredResourceDestroys.Enqueue((resources, _submitTimeline));
}
else
{
DestroyTranslatedDrawResources(resources);
}
}
}
}
private bool TryValidateComputeDispatch(
VulkanComputeGuestDispatch work,
out string error)
{
if (work.LocalSizeX == 0 || work.LocalSizeY == 0 || work.LocalSizeZ == 0)
{
error = "zero-local-size";
return false;
}
if (work.LocalSizeX > _maxComputeWorkGroupSizeX ||
work.LocalSizeY > _maxComputeWorkGroupSizeY ||
work.LocalSizeZ > _maxComputeWorkGroupSizeZ)
{
error =
$"local-size-exceeds-device({work.LocalSizeX}x{work.LocalSizeY}x{work.LocalSizeZ}>" +
$"{_maxComputeWorkGroupSizeX}x{_maxComputeWorkGroupSizeY}x{_maxComputeWorkGroupSizeZ})";
return false;
}
var localInvocations =
(ulong)work.LocalSizeX * work.LocalSizeY * work.LocalSizeZ;
if (localInvocations > _maxComputeWorkGroupInvocations)
{
error =
$"local-invocations-exceed-device({localInvocations}>" +
$"{_maxComputeWorkGroupInvocations})";
return false;
}
if ((ulong)work.BaseGroupX + work.GroupCountX > _maxComputeWorkGroupCountX ||
(ulong)work.BaseGroupY + work.GroupCountY > _maxComputeWorkGroupCountY ||
(ulong)work.BaseGroupZ + work.GroupCountZ > _maxComputeWorkGroupCountZ)
{
error =
$"group-range-exceeds-device(base={work.BaseGroupX}x{work.BaseGroupY}x{work.BaseGroupZ}," +
$"count={work.GroupCountX}x{work.GroupCountY}x{work.GroupCountZ}," +
$"limit={_maxComputeWorkGroupCountX}x{_maxComputeWorkGroupCountY}x" +
$"{_maxComputeWorkGroupCountZ})";
return false;
}
if (work.IsIndirect)
{
ulong totalWorkgroups;
try
{
totalWorkgroups = checked(
(ulong)work.GroupCountX * work.GroupCountY * work.GroupCountZ);
}
catch (OverflowException)
{
error = "indirect-workgroup-count-overflow";
return false;
}
if (totalWorkgroups > MaxCredibleGuestWorkgroupsPerDispatch)
{
error =
$"poisoned-indirect-workgroup-count({totalWorkgroups}>" +
$"{MaxCredibleGuestWorkgroupsPerDispatch})";
return false;
}
}
error = string.Empty;
return true;
}
private void LogRejectedComputeDispatch(
VulkanComputeGuestDispatch work,
string reason)
{
if (_rejectedComputeDispatches.Count >= 256 ||
!_rejectedComputeDispatches.Add(
(work.ShaderAddress,
work.GroupCountX,
work.GroupCountY,
work.GroupCountZ,
reason)))
{
return;
}
Console.Error.WriteLine(
$"[LOADER][WARN] vk.compute_reject cs=0x{work.ShaderAddress:X16} " +
$"source={(work.IsIndirect ? "indirect" : "direct")} " +
$"groups={work.GroupCountX}x{work.GroupCountY}x{work.GroupCountZ} " +
$"base={work.BaseGroupX}x{work.BaseGroupY}x{work.BaseGroupZ} " +
$"local={work.LocalSizeX}x{work.LocalSizeY}x{work.LocalSizeZ} " +
$"reason={reason}");
}
private void RecordGlobalBufferVisibilityBarrier(
CommandBuffer commandBuffer,
TranslatedDrawResources resources,
PipelineStageFlags destinationStages)
{
if (resources.GlobalMemoryBuffers.Length == 0)
{
return;
}
// Queue submission order alone is not a shader-memory dependency.
// This makes stores through any aliased guest view available to
// later vertex/fragment/compute reads and writes on the same queue.
var barrier = new MemoryBarrier
{
SType = StructureType.MemoryBarrier,
SrcAccessMask = AccessFlags.ShaderWriteBit,
DstAccessMask = AccessFlags.ShaderReadBit | AccessFlags.ShaderWriteBit,
};
_vk.CmdPipelineBarrier(
commandBuffer,
PipelineStageFlags.AllCommandsBit,
destinationStages,
0,
1,
&barrier,
0,
null,
0,
null);
}
private static void MarkGuestBufferDirty(
GuestBufferAllocation allocation,
ulong offset,
ulong length)
{
if (length == 0)
{
return;
}
var start = offset;
var end = checked(offset + length);
for (var index = allocation.DirtyRanges.Count - 1; index >= 0; index--)
{
var existing = allocation.DirtyRanges[index];
var existingEnd = existing.Offset + existing.Length;
if (end < existing.Offset || existingEnd < start)
{
continue;
}
start = Math.Min(start, existing.Offset);
end = Math.Max(end, existingEnd);
allocation.DirtyRanges.RemoveAt(index);
}
allocation.DirtyRanges.Add(new DirtyGuestBufferRange(start, end - start));
}
private void WriteBackAllDirtyGuestBuffers(string? queueName = null)
{
var memory = _guestMemory;
if (memory is null)
{
return;
}
foreach (var allocation in _guestBufferAllocations)
{
if (queueName is not null &&
!string.Equals(allocation.QueueName, queueName, StringComparison.Ordinal))
{
continue;
}
if (allocation.DirtyRanges.Count != 0 &&
allocation.LastUseTimeline > _completedTimeline)
{
// A mapped HOST_COHERENT allocation still cannot be read
// by the CPU while a shader may be writing it. Callers
// normally retire the relevant fences first; keep this
// helper fail-closed if a future path forgets to do so.
TraceVulkanShader(
$"vk.global_writeback_deferred base=0x{allocation.BaseAddress:X16} " +
$"last_use={allocation.LastUseTimeline} completed={_completedTimeline}");
continue;
}
for (var index = allocation.DirtyRanges.Count - 1; index >= 0; index--)
{
var range = allocation.DirtyRanges[index];
if (range.Length == 0 || range.Length > int.MaxValue)
{
continue;
}
var mappedBytes = new ReadOnlySpan<byte>(
(void*)(allocation.Mapped + checked((nint)range.Offset)),
checked((int)range.Length));
var shadowBytes = allocation.Shadow.AsSpan(
checked((int)range.Offset),
mappedBytes.Length);
var guestAddress = allocation.BaseAddress + range.Offset;
var changedBytes = 0UL;
var changedRuns = 0;
var changedPages = 0;
var writtenRuns = 0;
var writtenPages = 0;
var failedRuns = 0;
var unreadablePages = 0;
var fallbackWrites = 0;
var firstChangedOffset = -1;
allocation.DirtyRanges.RemoveAt(index);
// A writable descriptor only identifies a potential write
// range. Publishing the entire mapped view would overwrite
// unrelated live CPU data with its old snapshot. Compare
// against the last synchronized image. For each changed
// page, start with current guest bytes and overlay only the
// shader changes before one bounded write. This preserves
// live CPU changes in unchanged bytes without degenerating
// into millions of writes for alternating output patterns.
const int pageSize = 4096;
const int unreadableMergeGap = 16;
var livePageBuffer = GuestDataPool.Rent(pageSize);
var pageRuns = new List<(int Start, int Length)>(64);
try
{
for (var pageStart = 0;
pageStart < mappedBytes.Length;
pageStart += pageSize)
{
var pageEnd = Math.Min(pageStart + pageSize, mappedBytes.Length);
pageRuns.Clear();
var cursor = pageStart;
while (cursor < pageEnd)
{
while (cursor < pageEnd &&
mappedBytes[cursor] == shadowBytes[cursor])
{
cursor++;
}
if (cursor == pageEnd)
{
break;
}
var runStart = cursor;
while (cursor < pageEnd &&
mappedBytes[cursor] != shadowBytes[cursor])
{
cursor++;
}
var runLength = cursor - runStart;
pageRuns.Add((runStart, runLength));
changedRuns++;
changedBytes += (ulong)runLength;
if (firstChangedOffset < 0)
{
firstChangedOffset = runStart;
}
}
if (pageRuns.Count == 0)
{
continue;
}
changedPages++;
var pageLength = pageEnd - pageStart;
var livePage = livePageBuffer.AsSpan(0, pageLength);
if (memory.TryRead(guestAddress + (ulong)pageStart, livePage))
{
foreach (var run in pageRuns)
{
mappedBytes.Slice(run.Start, run.Length).CopyTo(
livePage.Slice(run.Start - pageStart, run.Length));
}
if (memory.TryWrite(guestAddress + (ulong)pageStart, livePage))
{
foreach (var run in pageRuns)
{
mappedBytes.Slice(run.Start, run.Length).CopyTo(
shadowBytes.Slice(run.Start, run.Length));
}
writtenPages++;
writtenRuns += pageRuns.Count;
continue;
}
foreach (var run in pageRuns)
{
failedRuns++;
MarkGuestBufferDirty(
allocation,
range.Offset + (ulong)run.Start,
(ulong)run.Length);
}
continue;
}
// A partial/unreadable edge cannot be safely
// reconstructed as a page. Fall back to bounded
// changed spans, coalescing only tiny gaps.
unreadablePages++;
for (var runIndex = 0; runIndex < pageRuns.Count; runIndex++)
{
var firstRunIndex = runIndex;
var mergedStart = pageRuns[runIndex].Start;
var mergedEnd = mergedStart + pageRuns[runIndex].Length;
while (runIndex + 1 < pageRuns.Count &&
pageRuns[runIndex + 1].Start - mergedEnd <=
unreadableMergeGap)
{
runIndex++;
mergedEnd = pageRuns[runIndex].Start +
pageRuns[runIndex].Length;
}
var lastRunIndex = runIndex;
var mergedLength = mergedEnd - mergedStart;
var mergedLive = livePageBuffer.AsSpan(0, mergedLength);
if (memory.TryRead(
guestAddress + (ulong)mergedStart,
mergedLive))
{
for (var overlayIndex = firstRunIndex;
overlayIndex <= lastRunIndex;
overlayIndex++)
{
var run = pageRuns[overlayIndex];
mappedBytes.Slice(run.Start, run.Length).CopyTo(
mergedLive.Slice(
run.Start - mergedStart,
run.Length));
}
fallbackWrites++;
if (memory.TryWrite(
guestAddress + (ulong)mergedStart,
mergedLive))
{
for (var overlayIndex = firstRunIndex;
overlayIndex <= lastRunIndex;
overlayIndex++)
{
var run = pageRuns[overlayIndex];
mappedBytes.Slice(run.Start, run.Length).CopyTo(
shadowBytes.Slice(run.Start, run.Length));
}
writtenRuns += lastRunIndex - firstRunIndex + 1;
continue;
}
}
// Even the merged span crosses an unreadable
// edge. Exact changed runs remain safe because
// they never carry stale gap bytes.
for (var exactIndex = firstRunIndex;
exactIndex <= lastRunIndex;
exactIndex++)
{
var run = pageRuns[exactIndex];
var changed = mappedBytes.Slice(run.Start, run.Length);
fallbackWrites++;
if (memory.TryWrite(
guestAddress + (ulong)run.Start,
changed))
{
changed.CopyTo(shadowBytes.Slice(
run.Start,
run.Length));
writtenRuns++;
}
else
{
failedRuns++;
MarkGuestBufferDirty(
allocation,
range.Offset + (ulong)run.Start,
(ulong)run.Length);
}
}
}
}
}
finally
{
GuestDataPool.Return(livePageBuffer);
}
var probe = mappedBytes[..Math.Min(mappedBytes.Length, 256)];
var nonzero = 0;
foreach (var value in probe)
{
nonzero += value == 0 ? 0 : 1;
}
var firstForRange = _tracedGlobalWritebacks.Count < 256 &&
_tracedGlobalWritebacks.Add((guestAddress, range.Length));
var traceSmallMutation = range.Length <= 4096 &&
_tracedSmallGlobalWritebackEvents++ < 1024;
var traceLargeMutation = range.Length >= 1024 * 1024 &&
_tracedLargeGlobalWritebackEvents++ < 256;
if (firstForRange || traceSmallMutation || traceLargeMutation)
{
var head = firstChangedOffset >= 0
? mappedBytes.Slice(
firstChangedOffset,
Math.Min(mappedBytes.Length - firstChangedOffset, 32))
: ReadOnlySpan<byte>.Empty;
TraceVulkanShader(
$"vk.global_writeback base=0x{guestAddress:X16} " +
$"potential_bytes={mappedBytes.Length} changed_bytes={changedBytes} " +
$"changed_runs={changedRuns} changed_pages={changedPages} " +
$"written_pages={writtenPages} written_runs={writtenRuns} " +
$"unreadable_pages={unreadablePages} " +
$"fallback_writes={fallbackWrites} failed_runs={failedRuns} " +
$"probe_nonzero={nonzero}/{probe.Length} " +
$"changed_head={Convert.ToHexString(head)}");
}
}
}
}
private void RecordChunkedComputeDispatch(
CommandBuffer commandBuffer,
VulkanComputeGuestDispatch work,
uint zStart,
uint zCount)
{
const uint maxWorkgroupsPerCommand = 4096;
ulong commandCount = 0;
var maxXChunk = Math.Max(
1u,
Math.Min(
work.GroupCountX,
Math.Min(_maxComputeWorkGroupCountX, maxWorkgroupsPerCommand)));
for (var x = 0u; x < work.GroupCountX;)
{
var countX = Math.Min(maxXChunk, work.GroupCountX - x);
var xyBudget = Math.Max(maxWorkgroupsPerCommand / countX, 1u);
var maxYChunk = Math.Max(
1u,
Math.Min(
work.GroupCountY,
Math.Min(_maxComputeWorkGroupCountY, xyBudget)));
for (var y = 0u; y < work.GroupCountY;)
{
var countY = Math.Min(maxYChunk, work.GroupCountY - y);
var xyzBudget = Math.Max(xyBudget / countY, 1u);
var maxZChunk = Math.Max(
1u,
Math.Min(
zCount,
Math.Min(_maxComputeWorkGroupCountZ, xyzBudget)));
for (var z = 0u; z < zCount;)
{
var countZ = Math.Min(maxZChunk, zCount - z);
_vk.CmdDispatchBase(
commandBuffer,
checked(work.BaseGroupX + x),
checked(work.BaseGroupY + y),
checked(work.BaseGroupZ + zStart + z),
countX,
countY,
countZ);
commandCount++;
z += countZ;
}
y += countY;
}
x += countX;
}
if (commandCount > 1)
{
TraceVulkanShader(
$"vk.compute_chunked cs=0x{work.ShaderAddress:X16} " +
$"groups={work.GroupCountX}x{work.GroupCountY}x{work.GroupCountZ} " +
$"base={work.BaseGroupX}x{work.BaseGroupY}x{work.BaseGroupZ} " +
$"z_range={zStart}..{zStart + zCount} commands={commandCount} " +
$"command_budget={maxWorkgroupsPerCommand} " +
$"device_limit={_maxComputeWorkGroupCountX}x" +
$"{_maxComputeWorkGroupCountY}x{_maxComputeWorkGroupCountZ}");
}
}
private void ExecuteOffscreenDraw(VulkanOffscreenGuestDraw work)
{
if (_deviceLost || work.Targets.Count == 0)
{
return;
}
var perfStart = System.Diagnostics.Stopwatch.GetTimestamp();
Interlocked.Increment(ref _perfDrawCount);
PerfOverlay.RecordDraw();
try
{
ExecuteOffscreenDrawCore(work);
}
finally
{
// Single atomic add per draw: staging -start/+end separately
// let the stats window reset land between them and report
// huge negative draw_ms values.
Interlocked.Add(
ref _perfDrawTicks,
System.Diagnostics.Stopwatch.GetTimestamp() - perfStart);
}
}
private void ExecuteOffscreenDrawCore(VulkanOffscreenGuestDraw work)
{
if (work.Targets.Count > _maxColorAttachments)
{
Console.Error.WriteLine(
$"[LOADER][WARN] Vulkan skipped MRT draw requesting {work.Targets.Count} color attachments; " +
$"the selected device supports {_maxColorAttachments}.");
ReturnPooledGuestData(work.Draw);
return;
}
var targetFormats = new VulkanRenderTargetFormat[work.Targets.Count];
for (var index = 0; index < targetFormats.Length; index++)
{
var target = work.Targets[index];
if (!TryDecodeRenderTargetFormat(target.Format, target.NumberType, out targetFormats[index]) ||
!SupportsColorAttachment(targetFormats[index].Format))
{
Console.Error.WriteLine(
$"[LOADER][WARN] Vulkan skipped MRT draw with unsupported color target " +
$"format={target.Format} number_type={target.NumberType}.");
ReturnPooledGuestData(work.Draw);
return;
}
}
if (work.Draw.RenderState.Blends.Count != targetFormats.Length)
{
Console.Error.WriteLine(
"[LOADER][WARN] Vulkan skipped MRT draw with mismatched attachment/blend counts.");
ReturnPooledGuestData(work.Draw);
return;
}
for (var index = 0; index < targetFormats.Length; index++)
{
if (targetFormats[index].IsInteger &&
work.Draw.RenderState.Blends[index].Enable)
{
Console.Error.WriteLine(
"[LOADER][WARN] Vulkan skipped MRT draw with blending enabled for an integer attachment.");
ReturnPooledGuestData(work.Draw);
return;
}
}
if (!_supportsIndependentBlend)
{
for (var index = 1; index < work.Draw.RenderState.Blends.Count; index++)
{
if (work.Draw.RenderState.Blends[index] !=
work.Draw.RenderState.Blends[0])
{
Console.Error.WriteLine(
"[LOADER][WARN] Vulkan skipped MRT draw requiring unsupported independentBlend.");
ReturnPooledGuestData(work.Draw);
return;
}
}
}
var formats = new Format[targetFormats.Length];
for (var index = 0; index < targetFormats.Length; index++)
{
formats[index] = targetFormats[index].Format;
}
var hasStorageFeedback = false;
foreach (var texture in work.Draw.Textures)
{
if (!texture.IsStorage || texture.Address == 0)
{
continue;
}
foreach (var target in work.Targets)
{
if (target.Address != 0 && target.Address == texture.Address)
{
hasStorageFeedback = true;
break;
}
}
if (hasStorageFeedback)
{
break;
}
}
if (hasStorageFeedback)
{
Console.Error.WriteLine(
$"[LOADER][WARN] Vulkan skipped storage render-target feedback loop " +
$"targets={string.Join(',', work.Targets.Where(target => target.Address != 0).Select(target => $"0x{target.Address:X16}"))}; " +
"sampled aliases use ordered snapshots");
ReturnPooledGuestData(work.Draw);
return;
}
var targets = new GuestImageResource[work.Targets.Count];
EnsureGuestSubmissionCapacity();
for (var index = 0; index < targets.Length; index++)
{
var targetDescriptor = work.Targets[index].Address == 0 &&
work.DepthTarget is { } depthOnlyTarget
? GetDepthOnlyColorTarget(depthOnlyTarget)
: work.Targets[index];
targets[index] = GetOrCreateGuestImage(targetDescriptor, formats[index]);
if (work.Targets[index].Address != 0 &&
TakeGuestImageInitialData(work.Targets[index].Address) is { } initialData &&
!targets[index].Initialized &&
(ulong)initialData.Length ==
(ulong)targets[index].Width * targets[index].Height * 4)
{
UploadGuestImageInitialData(targets[index], initialData);
}
}
var firstTarget = targets[0];
TranslatedDrawResources? resources = null;
CommandBuffer commandBuffer = default;
var submitted = false;
RenderPass transientRenderPass = default;
Framebuffer transientFramebuffer = default;
try
{
var extent = new Extent2D(firstTarget.Width, firstTarget.Height);
var draw = work.Draw;
var clearDepthForDraw = draw.RenderState.Depth.ClearEnable;
if (work.DepthTarget?.ReadOnly == true && draw.RenderState.Depth.WriteEnable)
{
draw = draw with
{
RenderState = draw.RenderState with
{
Depth = draw.RenderState.Depth with { WriteEnable = false },
},
};
}
GuestDepthResource? depth = null;
DepthFramebufferResource? depthFramebuffer = null;
if (ShouldAttachGuestDepth(
work.DepthTarget,
draw.RenderState.Depth) &&
work.DepthTarget is { } depthTarget)
{
var effectiveDepthTarget = depthTarget;
if (depthTarget.Width < firstTarget.Width || depthTarget.Height < firstTarget.Height)
{
var matchingTexture = work.Draw.Textures.FirstOrDefault(texture =>
texture.Address == depthTarget.Address &&
texture.Width >= firstTarget.Width &&
texture.Height >= firstTarget.Height);
if (matchingTexture is not null)
{
effectiveDepthTarget = depthTarget with
{
Width = matchingTexture.Width,
Height = matchingTexture.Height,
};
}
else if (depthTarget.Width == 1 && depthTarget.Height == 1)
{
// Some Gen5 streams leave DB_DEPTH_SIZE_XY at its
// clear-state value while binding a full-size DB
// surface. The color attachment and viewport still
// define the render extent; treating that stale 1x1
// value literally discards the entire draw.
effectiveDepthTarget = depthTarget with
{
Width = firstTarget.Width,
Height = firstTarget.Height,
};
}
if (effectiveDepthTarget != depthTarget &&
_tracedDepthExtentFallbacks.Add(
(depthTarget.Address,
effectiveDepthTarget.Width,
effectiveDepthTarget.Height)))
{
Console.Error.WriteLine(
$"[LOADER][WARN] Vulkan repaired stale guest depth extent " +
$"addr=0x{depthTarget.Address:X16} " +
$"{depthTarget.Width}x{depthTarget.Height} -> " +
$"{effectiveDepthTarget.Width}x{effectiveDepthTarget.Height}");
}
}
depth = GetOrCreateGuestDepth(effectiveDepthTarget);
PrepareFirstUseDepth(depth, draw.RenderState.Depth);
if (clearDepthForDraw)
{
depth.GuestClearDepth = effectiveDepthTarget.ClearDepth;
depth.ClearDepth = effectiveDepthTarget.ClearDepth;
}
if (targets.Length == 1)
{
depthFramebuffer = GetOrCreateDepthFramebuffer(firstTarget, depth);
}
}
if (clearDepthForDraw)
{
// DB_RENDER_CONTROL.DEPTH_CLEAR_ENABLE makes this a DB
// clear operation. The draw still produces color, but its
// interpolated vertex Z is not the guest clear value.
draw = draw with
{
RenderState = draw.RenderState with
{
Depth = draw.RenderState.Depth with
{
TestEnable = false,
WriteEnable = false,
ClearEnable = false,
},
},
};
}
var renderPass = depthFramebuffer is null
? firstTarget.Initialized
? firstTarget.RenderPass
: firstTarget.InitialRenderPass
: firstTarget.Initialized
? depth!.Initialized && !clearDepthForDraw
? depthFramebuffer.LoadRenderPass
: depthFramebuffer.DepthClearRenderPass
: depth!.Initialized && !clearDepthForDraw
? depthFramebuffer.ColorClearRenderPass
: depthFramebuffer.BothClearRenderPass;
var framebuffer = depthFramebuffer?.Framebuffer ?? firstTarget.Framebuffer;
if (targets.Length > 1)
{
(renderPass, framebuffer) = CreateRenderPassAndFramebuffer(
formats,
targets.Select(target => target.MipViews.Length > 0
? target.MipViews[0]
: target.View).ToArray(),
firstTarget.Width,
firstTarget.Height,
targets.Select(target =>
target.Initialized || target.InitialUploadPending).ToArray(),
depth,
depth?.Initialized == true && !clearDepthForDraw);
transientRenderPass = renderPass;
transientFramebuffer = framebuffer;
}
resources = CreateTranslatedDrawResources(
draw,
renderPass,
formats,
extent,
targets,
hasDepthAttachment: depth is not null,
feedbackDepth: depth);
resources.TransientRenderPass = transientRenderPass;
resources.TransientFramebuffer = transientFramebuffer;
transientRenderPass = default;
transientFramebuffer = default;
resources.DebugName =
$"SharpEmu offscreen mrt={targets.Length} " +
$"first=0x{work.Targets[0].Address:X16} " +
$"{firstTarget.Width}x{firstTarget.Height}";
commandBuffer = BeginBatchedGuestCommands();
_commandBuffer = commandBuffer;
// Lifetime: recorded commands reference these resources, so
// they join the batch before recording and are destroyed only
// after the batch's fence signals.
_batchResources.Add(resources);
submitted = true;
BeginDebugLabel(_commandBuffer, resources.DebugName);
var hasStorageImages = false;
foreach (var texture in resources.Textures)
{
if (texture is null)
{
continue;
}
hasStorageImages |= texture.IsStorage;
}
CloseOpenTranslatedRenderPass();
RecordGlobalBufferVisibilityBarrier(
_commandBuffer,
resources,
PipelineStageFlags.VertexShaderBit |
PipelineStageFlags.FragmentShaderBit);
RecordRenderTargetFeedbackSnapshots(
resources,
PipelineStageFlags.FragmentShaderBit);
RecordDepthFeedbackSnapshots(
resources,
PipelineStageFlags.FragmentShaderBit);
RecordTextureUploads(resources, PipelineStageFlags.FragmentShaderBit);
RecordStorageImagesForWrite(resources, PipelineStageFlags.FragmentShaderBit);
var toColorAttachments = stackalloc ImageMemoryBarrier[targets.Length];
var anyPriorContents = false;
for (var index = 0; index < targets.Length; index++)
{
var hasPriorContents =
targets[index].Initialized || targets[index].InitialUploadPending;
anyPriorContents |= hasPriorContents;
toColorAttachments[index] = new ImageMemoryBarrier
{
SType = StructureType.ImageMemoryBarrier,
SrcAccessMask = hasPriorContents ? AccessFlags.ShaderReadBit : 0,
DstAccessMask = AccessFlags.ColorAttachmentWriteBit,
OldLayout = hasPriorContents
? ImageLayout.ShaderReadOnlyOptimal
: ImageLayout.Undefined,
NewLayout = ImageLayout.ColorAttachmentOptimal,
SrcQueueFamilyIndex = Vk.QueueFamilyIgnored,
DstQueueFamilyIndex = Vk.QueueFamilyIgnored,
Image = targets[index].Image,
SubresourceRange = ColorSubresourceRange(),
};
}
_vk.CmdPipelineBarrier(
_commandBuffer,
anyPriorContents
? PipelineStageFlags.AllCommandsBit
: PipelineStageFlags.TopOfPipeBit,
PipelineStageFlags.ColorAttachmentOutputBit,
0,
0,
null,
0,
null,
(uint)targets.Length,
toColorAttachments);
if (depth is not null &&
depth.Layout == ImageLayout.ShaderReadOnlyOptimal)
{
var toDepthAttachment = new ImageMemoryBarrier
{
SType = StructureType.ImageMemoryBarrier,
SrcAccessMask = AccessFlags.ShaderReadBit,
DstAccessMask =
AccessFlags.DepthStencilAttachmentReadBit |
AccessFlags.DepthStencilAttachmentWriteBit,
OldLayout = ImageLayout.ShaderReadOnlyOptimal,
NewLayout = ImageLayout.DepthStencilAttachmentOptimal,
SrcQueueFamilyIndex = Vk.QueueFamilyIgnored,
DstQueueFamilyIndex = Vk.QueueFamilyIgnored,
Image = depth.Image,
SubresourceRange = new ImageSubresourceRange(
ImageAspectFlags.DepthBit, 0, 1, 0, 1),
};
_vk.CmdPipelineBarrier(
_commandBuffer,
PipelineStageFlags.FragmentShaderBit |
PipelineStageFlags.ComputeShaderBit,
PipelineStageFlags.EarlyFragmentTestsBit |
PipelineStageFlags.LateFragmentTestsBit,
0,
0,
null,
0,
null,
1,
&toDepthAttachment);
}
BeginTranslatedRenderPass(
renderPass,
framebuffer,
extent,
colorAttachmentCount: targets.Length,
hasDepthAttachment: depth is not null,
clearDepth: depth?.ClearDepth ?? 1f);
RecordTranslatedDrawInPass(resources, extent);
_vk.CmdEndRenderPass(_commandBuffer);
var toShaderRead = stackalloc ImageMemoryBarrier[targets.Length];
for (var index = 0; index < targets.Length; index++)
{
toShaderRead[index] = new ImageMemoryBarrier
{
SType = StructureType.ImageMemoryBarrier,
SrcAccessMask = AccessFlags.ColorAttachmentWriteBit,
DstAccessMask = AccessFlags.ShaderReadBit,
OldLayout = ImageLayout.ColorAttachmentOptimal,
NewLayout = ImageLayout.ShaderReadOnlyOptimal,
SrcQueueFamilyIndex = Vk.QueueFamilyIgnored,
DstQueueFamilyIndex = Vk.QueueFamilyIgnored,
Image = targets[index].Image,
SubresourceRange = ColorSubresourceRange(),
};
}
_vk.CmdPipelineBarrier(
_commandBuffer,
PipelineStageFlags.ColorAttachmentOutputBit,
PipelineStageFlags.FragmentShaderBit,
0,
0,
null,
0,
null,
(uint)targets.Length,
toShaderRead);
if (hasStorageImages)
{
RecordStorageImagesForRead(resources, PipelineStageFlags.FragmentShaderBit);
}
EndDebugLabel(_commandBuffer);
var traceImages = GetTraceImages(resources, targets, work.ShaderAddress);
_batchTraceImages.AddRange(traceImages);
if (++_batchDrawCount >= 64 ||
(_traceGuestImageShaderFilterEnabled && traceImages.Count != 0))
{
FlushBatchedGuestCommands();
}
foreach (var target in targets)
{
target.Initialized = true;
target.InitialUploadPending = false;
}
if (depth is not null)
{
depth.Initialized = true;
depth.Layout = ImageLayout.DepthStencilAttachmentOptimal;
if (clearDepthForDraw)
{
depth.InitializationSource = "guest-depth-clear";
}
else if (draw.RenderState.Depth.WriteEnable)
{
depth.InitializationSource = "translated-depth-write";
}
}
MarkSampledImagesInitialized(resources);
MarkStorageImagesInitialized(resources, traceContents: false);
if (work.PublishTarget)
{
for (var index = 0; index < targets.Length; index++)
{
var guestTextureFormat = VulkanVideoPresenter.GetGuestTextureFormat(
work.Targets[index].Format,
work.Targets[index].NumberType);
if (guestTextureFormat == 0)
{
continue;
}
lock (_gate)
{
_availableGuestImages[targets[index].Address] = guestTextureFormat;
}
}
}
var tracePixelSpirv = false;
if (_tracePixelSpirvBytes > 0 &&
_tracePixelSpirvBytes == work.Draw.PixelSpirv.Length)
{
var pixelWriteCount = _pixelSpirvWriteCounts.TryGetValue(
_tracePixelSpirvBytes,
out var previousPixelWriteCount)
? previousPixelWriteCount + 1
: 1;
_pixelSpirvWriteCounts[_tracePixelSpirvBytes] = pixelWriteCount;
tracePixelSpirv =
pixelWriteCount == _tracePixelSpirvOccurrence;
}
var traceTitleDraw =
!_tracedTitleDraw &&
_traceTitleDrawEnabled &&
IsTitleDraw(work.Draw.VertexBuffers);
_tracedTitleDraw |= traceTitleDraw;
foreach (var target in targets)
{
var traceAddressWrite =
ShouldTraceGuestImageWriteForDiagnostics(target.Address);
var traceSmallWrites = _traceGuestWritesMode == "small" &&
target.Width <= 512 && target.Height <= 256;
var traceLargeWrites =
(_traceGuestWritesMode == "large" ||
_traceLargeGuestWriteOrdinal != 0) &&
target.Width >= 2560 && target.Height >= 1440;
if (traceAddressWrite || traceSmallWrites ||
traceLargeWrites || tracePixelSpirv || traceTitleDraw)
{
var writeCount = _tracedGuestWriteCounts.TryGetValue(
target.Address,
out var previousCount)
? previousCount + 1
: 1;
_tracedGuestWriteCounts[target.Address] = writeCount;
var shouldTraceWrite = tracePixelSpirv || traceTitleDraw
? true
: traceAddressWrite && _traceGuestWriteOrdinal > 0
? writeCount == _traceGuestWriteOrdinal
: _traceLargeGuestWriteOrdinal != 0
? writeCount == _traceLargeGuestWriteOrdinal
: writeCount <=
(traceLargeWrites ? 2 : traceSmallWrites ? 48 : 3);
if (traceAddressWrite || shouldTraceWrite)
{
var sampledTextures = string.Join(
',',
work.Draw.Textures.Select(texture =>
$"0x{texture.Address:X}:{texture.Width}x{texture.Height}:" +
$"f{texture.Format}:n{texture.NumberType}:" +
$"storage={(texture.IsStorage ? 1 : 0)}"));
var pixelDigest = Convert.ToHexString(
SHA256.HashData(work.Draw.PixelSpirv).AsSpan(0, 4));
Console.Error.WriteLine(
$"[LOADER][TRACE] vk.guest_write_sample " +
$"addr=0x{target.Address:X16} write={writeCount} " +
$"vs_bytes={work.Draw.VertexSpirv.Length} " +
$"ps_bytes={work.Draw.PixelSpirv.Length} ps_hash={pixelDigest} " +
$"vertices={work.Draw.VertexCount} instances={work.Draw.InstanceCount} " +
$"primitive=0x{work.Draw.PrimitiveType:X} " +
$"readback={(shouldTraceWrite ? 1 : 0)} textures=[{sampledTextures}]");
}
if (shouldTraceWrite)
{
_commandBuffer = _presentationCommandBuffer;
FlushBatchedGuestCommands();
Check(
_vk.QueueWaitIdle(_queue),
"vkQueueWaitIdle(guest write trace)");
TraceGuestImageContents(target);
}
}
}
if (_traceVulkanShaderEnabled)
{
TraceVulkanShader(
$"vk.offscreen_draw mrt={targets.Length} " +
$"size={firstTarget.Width}x{firstTarget.Height} " +
$"textures={work.Draw.Textures.Count}");
}
}
catch (Exception exception)
{
if (TryMarkDeviceLost(exception))
{
return;
}
lock (_gate)
{
foreach (var target in work.Targets)
{
if (!_guestImages.TryGetValue(target.Address, out var failedTarget) ||
!failedTarget.Initialized)
{
_availableGuestImages.Remove(target.Address);
}
}
}
Console.Error.WriteLine(
$"[LOADER][ERROR] Vulkan offscreen draw failed " +
$"mrt={work.Targets.Count}: {exception.Message}");
}
finally
{
_commandBuffer = _presentationCommandBuffer;
// The command buffer is the shared batch; it is submitted and
// freed by FlushBatchedGuestCommands. Resources joined the
// batch list before recording, so only pre-recording failures
// (submitted still false) own their cleanup here.
if (!submitted && resources is not null)
{
DestroyTranslatedDrawResources(resources);
}
if (transientFramebuffer.Handle != 0)
{
_vk.DestroyFramebuffer(_device, transientFramebuffer, null);
}
if (transientRenderPass.Handle != 0)
{
_vk.DestroyRenderPass(_device, transientRenderPass, null);
}
}
}
[MethodImpl(MethodImplOptions.NoInlining)]
private void ExecuteGuestImageWrite(VulkanGuestImageWrite work)
{
if (_deviceLost || !_guestImages.TryGetValue(work.Address, out var target))
{
return;
}
if (work.Pixels is { } pixels)
{
if (pixels.Length > 0)
{
UploadGuestImageInitialData(target, pixels);
}
return;
}
// Recorded into the shared batch command buffer: recording order
// preserves queue-order semantics against earlier batched draws,
// and the fill no longer costs a submit + full queue drain.
var commandBuffer = BeginBatchedGuestCommands();
CloseOpenTranslatedRenderPass();
var toTransferDst = new ImageMemoryBarrier
{
SType = StructureType.ImageMemoryBarrier,
SrcAccessMask = target.Initialized ? AccessFlags.ShaderReadBit : 0,
DstAccessMask = AccessFlags.TransferWriteBit,
OldLayout = target.Initialized
? ImageLayout.ShaderReadOnlyOptimal
: ImageLayout.Undefined,
NewLayout = ImageLayout.TransferDstOptimal,
SrcQueueFamilyIndex = Vk.QueueFamilyIgnored,
DstQueueFamilyIndex = Vk.QueueFamilyIgnored,
Image = target.Image,
SubresourceRange = ColorSubresourceRange(0, target.MipLevels),
};
_vk.CmdPipelineBarrier(
commandBuffer,
target.Initialized
? PipelineStageFlags.FragmentShaderBit
: PipelineStageFlags.TopOfPipeBit,
PipelineStageFlags.TransferBit,
0,
0,
null,
0,
null,
1,
&toTransferDst);
var clearValue = new ClearColorValue(
(work.FillValue & 0xFF) / 255f,
((work.FillValue >> 8) & 0xFF) / 255f,
((work.FillValue >> 16) & 0xFF) / 255f,
((work.FillValue >> 24) & 0xFF) / 255f);
var range = ColorSubresourceRange(0, target.MipLevels);
_vk.CmdClearColorImage(
commandBuffer,
target.Image,
ImageLayout.TransferDstOptimal,
&clearValue,
1,
&range);
var toShaderRead = new ImageMemoryBarrier
{
SType = StructureType.ImageMemoryBarrier,
SrcAccessMask = AccessFlags.TransferWriteBit,
DstAccessMask = AccessFlags.ShaderReadBit,
OldLayout = ImageLayout.TransferDstOptimal,
NewLayout = ImageLayout.ShaderReadOnlyOptimal,
SrcQueueFamilyIndex = Vk.QueueFamilyIgnored,
DstQueueFamilyIndex = Vk.QueueFamilyIgnored,
Image = target.Image,
SubresourceRange = ColorSubresourceRange(0, target.MipLevels),
};
_vk.CmdPipelineBarrier(
commandBuffer,
PipelineStageFlags.TransferBit,
PipelineStageFlags.FragmentShaderBit,
0,
0,
null,
0,
null,
1,
&toShaderRead);
target.Initialized = true;
}
private void UploadGuestImageInitialData(GuestImageResource target, byte[] pixels)
{
var byteCount = (ulong)pixels.Length;
var staging = CreateBuffer(
byteCount,
BufferUsageFlags.TransferSrcBit,
MemoryPropertyFlags.HostVisibleBit | MemoryPropertyFlags.HostCoherentBit,
out var stagingMemory);
try
{
void* mapped;
Check(
_vk.MapMemory(_device, stagingMemory, 0, byteCount, 0, &mapped),
"vkMapMemory(guest image init)");
fixed (byte* source = pixels)
{
System.Buffer.MemoryCopy(source, mapped, pixels.Length, pixels.Length);
}
_vk.UnmapMemory(_device, stagingMemory);
// Recorded into the shared batch; the staging buffer joins
// the batch's retire list and is destroyed when the batch
// fence signals, so the upload costs no queue drain.
var commandBuffer = BeginBatchedGuestCommands();
CloseOpenTranslatedRenderPass();
var toTransferDst = new ImageMemoryBarrier
{
SType = StructureType.ImageMemoryBarrier,
SrcAccessMask = target.Initialized ? AccessFlags.ShaderReadBit : 0,
DstAccessMask = AccessFlags.TransferWriteBit,
OldLayout = target.Initialized
? ImageLayout.ShaderReadOnlyOptimal
: ImageLayout.Undefined,
NewLayout = ImageLayout.TransferDstOptimal,
SrcQueueFamilyIndex = Vk.QueueFamilyIgnored,
DstQueueFamilyIndex = Vk.QueueFamilyIgnored,
Image = target.Image,
SubresourceRange = ColorSubresourceRange(0, target.MipLevels),
};
_vk.CmdPipelineBarrier(
commandBuffer,
target.Initialized
? PipelineStageFlags.FragmentShaderBit
: PipelineStageFlags.TopOfPipeBit,
PipelineStageFlags.TransferBit,
0,
0,
null,
0,
null,
1,
&toTransferDst);
var copyRegion = new BufferImageCopy
{
BufferOffset = 0,
BufferRowLength = 0,
BufferImageHeight = 0,
ImageSubresource = new ImageSubresourceLayers(
ImageAspectFlags.ColorBit,
0,
0,
1),
ImageOffset = default,
ImageExtent = new Extent3D(target.Width, target.Height, 1),
};
_vk.CmdCopyBufferToImage(
commandBuffer,
staging,
target.Image,
ImageLayout.TransferDstOptimal,
1,
&copyRegion);
var toShaderRead = new ImageMemoryBarrier
{
SType = StructureType.ImageMemoryBarrier,
SrcAccessMask = AccessFlags.TransferWriteBit,
DstAccessMask = AccessFlags.ShaderReadBit,
OldLayout = ImageLayout.TransferDstOptimal,
NewLayout = ImageLayout.ShaderReadOnlyOptimal,
SrcQueueFamilyIndex = Vk.QueueFamilyIgnored,
DstQueueFamilyIndex = Vk.QueueFamilyIgnored,
Image = target.Image,
SubresourceRange = ColorSubresourceRange(0, target.MipLevels),
};
_vk.CmdPipelineBarrier(
commandBuffer,
PipelineStageFlags.TransferBit,
PipelineStageFlags.FragmentShaderBit,
0,
0,
null,
0,
null,
1,
&toShaderRead);
target.Initialized = true;
_batchRetireBuffers.Add((staging, stagingMemory));
staging = default;
stagingMemory = default;
if (_traceGuestImageEvents)
{
Console.Error.WriteLine(
$"[GIMG] seeded addr=0x{target.Address:X} " +
$"{target.Width}x{target.Height}");
}
}
finally
{
if (staging.Handle != 0)
{
_vk.DestroyBuffer(_device, staging, null);
}
if (stagingMemory.Handle != 0)
{
_vk.FreeMemory(_device, stagingMemory, null);
}
}
}
private GuestImageResource GetOrCreateGuestImage(
GuestRenderTarget target,
Format format)
{
var mipLevels = ClampMipLevels(target.Width, target.Height, target.MipLevels);
var guestFormat = GetGuestTextureFormat(target.Format, target.NumberType);
var requestedKey = new GuestImageVariantKey(
target.Address,
target.Width,
target.Height,
mipLevels,
guestFormat,
format);
if (_guestImages.TryGetValue(target.Address, out var existing))
{
if (existing.Width == target.Width &&
existing.Height == target.Height &&
existing.MipLevels == mipLevels &&
existing.GuestFormat == guestFormat &&
existing.Format == format)
{
existing.IsCpuBacked = false;
existing.CpuContentFingerprint = 0;
if (existing.RenderPass.Handle == 0)
{
var attachmentView = existing.MipViews.Length > 0
? existing.MipViews[0]
: existing.View;
var promoted = CreateRenderPassAndFramebuffer(
existing.Format,
attachmentView,
existing.Width,
existing.Height);
existing.RenderPass = promoted.RenderPass;
existing.InitialRenderPass = promoted.InitialRenderPass;
existing.Framebuffer = promoted.Framebuffer;
var promotedRenderPass = promoted.RenderPass;
var promotedInitialRenderPass = promoted.InitialRenderPass;
var promotedFramebuffer = promoted.Framebuffer;
var promotedName = GuestImageDebugName(target, format);
SetDebugName(ObjectType.RenderPass, promotedRenderPass.Handle, $"{promotedName} renderpass");
SetDebugName(ObjectType.RenderPass, promotedInitialRenderPass.Handle, $"{promotedName} initial-renderpass");
SetDebugName(ObjectType.Framebuffer, promotedFramebuffer.Handle, $"{promotedName} framebuffer");
}
return existing;
}
if (_traceGuestImageEvents)
{
Console.Error.WriteLine(
$"[GIMG] recreate addr=0x{target.Address:X} " +
$"old={existing.Width}x{existing.Height}/{existing.Format}/m{existing.MipLevels} " +
$"new={target.Width}x{target.Height}/{format}/m{mipLevels} " +
$"initialized={existing.Initialized}");
}
_guestImageVariants.Add(
new GuestImageVariantKey(
existing.Address,
existing.Width,
existing.Height,
existing.MipLevels,
existing.GuestFormat,
existing.Format),
existing);
_guestImages.Remove(target.Address);
lock (_gate)
{
_availableGuestImages.Remove(target.Address);
_guestImageExtents.Remove(target.Address);
}
// Address-filtered readback diagnostics should inspect the
// replacement image too; a resized/reformatted allocation at
// the same guest address is a different piece of evidence.
_tracedGuestImageContents.Remove(target.Address);
SharpEmu.HLE.GuestImageWriteTracker.Untrack(target.Address);
}
if (_guestImageVariants.Remove(requestedKey, out var retained))
{
retained.IsCpuBacked = false;
retained.CpuContentFingerprint = 0;
_guestImages.Add(target.Address, retained);
lock (_gate)
{
_guestImageExtents[target.Address] = (
target.Width,
target.Height,
GetTextureByteCount(target.Format, target.Width, target.Height));
}
if (target.Width <= 1920 && target.Height <= 1080)
{
SharpEmu.HLE.GuestImageWriteTracker.Track(
target.Address,
(ulong)target.Width * target.Height * GetTextureBytesPerPixel(target.Format),
CurrentGuestWorkSequenceForDiagnostics,
"vulkan.render-target");
}
if (_traceGuestImageEvents)
{
Console.Error.WriteLine(
$"[GIMG] retained addr=0x{target.Address:X} " +
$"{target.Width}x{target.Height} fmt={format} " +
$"initialized={retained.Initialized}");
}
return retained;
}
var imageInfo = new ImageCreateInfo
{
SType = StructureType.ImageCreateInfo,
Flags =
ImageCreateFlags.CreateMutableFormatBit |
ImageCreateFlags.CreateExtendedUsageBit,
ImageType = ImageType.Type2D,
Format = format,
Extent = new Extent3D(target.Width, target.Height, 1),
MipLevels = mipLevels,
ArrayLayers = 1,
Samples = SampleCountFlags.Count1Bit,
Tiling = ImageTiling.Optimal,
Usage =
ImageUsageFlags.ColorAttachmentBit |
ImageUsageFlags.SampledBit |
ImageUsageFlags.StorageBit |
ImageUsageFlags.TransferSrcBit |
ImageUsageFlags.TransferDstBit,
SharingMode = SharingMode.Exclusive,
InitialLayout = ImageLayout.Undefined,
};
Check(_vk.CreateImage(_device, &imageInfo, null, out var image), "vkCreateImage(offscreen)");
_vk.GetImageMemoryRequirements(_device, image, out var requirements);
var allocationInfo = new MemoryAllocateInfo
{
SType = StructureType.MemoryAllocateInfo,
AllocationSize = requirements.Size,
MemoryTypeIndex = FindMemoryType(
requirements.MemoryTypeBits,
MemoryPropertyFlags.DeviceLocalBit),
};
Check(
_vk.AllocateMemory(_device, &allocationInfo, null, out var memory),
"vkAllocateMemory(offscreen)");
Check(_vk.BindImageMemory(_device, image, memory, 0), "vkBindImageMemory(offscreen)");
// Rendering and uploads only define the mips they touch; define the whole
// chain once so full-chain sampled binds never read Undefined layout.
TransitionNewGuestImageToSampled(image, mipLevels);
var viewInfo = new ImageViewCreateInfo
{
SType = StructureType.ImageViewCreateInfo,
Image = image,
ViewType = ImageViewType.Type2D,
Format = format,
Components = new ComponentMapping(
ComponentSwizzle.Identity,
ComponentSwizzle.Identity,
ComponentSwizzle.Identity,
ComponentSwizzle.Identity),
SubresourceRange = ColorSubresourceRange(0, mipLevels),
};
Check(
_vk.CreateImageView(_device, &viewInfo, null, out var view),
"vkCreateImageView(offscreen)");
var mipViews = new ImageView[mipLevels];
for (uint mipLevel = 0; mipLevel < mipLevels; mipLevel++)
{
viewInfo.SubresourceRange = ColorSubresourceRange(mipLevel, 1);
ImageView mipView;
Check(
_vk.CreateImageView(
_device,
&viewInfo,
null,
out mipView),
"vkCreateImageView(offscreen mip)");
mipViews[mipLevel] = mipView;
}
var (renderPass, initialRenderPass, framebuffer) =
CreateRenderPassAndFramebuffer(
format,
mipViews[0],
target.Width,
target.Height);
var resource = new GuestImageResource
{
Address = target.Address,
Width = target.Width,
Height = target.Height,
MipLevels = mipLevels,
GuestFormat = guestFormat,
Format = format,
Image = image,
Memory = memory,
View = view,
MipViews = mipViews,
RenderPass = renderPass,
InitialRenderPass = initialRenderPass,
Framebuffer = framebuffer,
};
var debugName = GuestImageDebugName(target, format);
SetDebugName(ObjectType.Image, image.Handle, $"{debugName} image");
SetDebugName(ObjectType.ImageView, view.Handle, $"{debugName} view");
for (var mipLevel = 0; mipLevel < mipViews.Length; mipLevel++)
{
SetDebugName(
ObjectType.ImageView,
mipViews[mipLevel].Handle,
$"{debugName} mip{mipLevel}");
}
SetDebugName(ObjectType.RenderPass, renderPass.Handle, $"{debugName} renderpass");
SetDebugName(ObjectType.RenderPass, initialRenderPass.Handle, $"{debugName} initial-renderpass");
SetDebugName(ObjectType.Framebuffer, framebuffer.Handle, $"{debugName} framebuffer");
_guestImages.Add(target.Address, resource);
lock (_gate)
{
_guestImageExtents[target.Address] = (
target.Width,
target.Height,
GetTextureByteCount(target.Format, target.Width, target.Height));
}
if (target.Width <= 1920 && target.Height <= 1080)
{
SharpEmu.HLE.GuestImageWriteTracker.Track(
target.Address,
(ulong)target.Width * target.Height * GetTextureBytesPerPixel(target.Format),
CurrentGuestWorkSequenceForDiagnostics,
"vulkan.render-target");
}
if (_traceGuestImageEvents)
{
Console.Error.WriteLine(
$"[GIMG] created-as-rt addr=0x{target.Address:X} " +
$"{target.Width}x{target.Height} fmt={format}");
}
return resource;
}
private (RenderPass RenderPass, RenderPass InitialRenderPass, Framebuffer Framebuffer)
CreateRenderPassAndFramebuffer(
Format format,
ImageView attachmentView,
uint width,
uint height)
{
var load = CreateRenderPassAndFramebuffer(
[format], [attachmentView], width, height, [true], null, false);
var initial = CreateRenderPassAndFramebuffer(
[format], [attachmentView], width, height, [false], null, false);
_vk.DestroyFramebuffer(_device, initial.Framebuffer, null);
return (load.RenderPass, initial.RenderPass, load.Framebuffer);
}
private (RenderPass RenderPass, Framebuffer Framebuffer) CreateRenderPassAndFramebuffer(
IReadOnlyList<Format> formats,
IReadOnlyList<ImageView> attachmentViews,
uint width,
uint height) =>
CreateRenderPassAndFramebuffer(
formats,
attachmentViews,
width,
height,
Enumerable.Repeat(true, formats.Count).ToArray(),
null,
false);
private (RenderPass RenderPass, Framebuffer Framebuffer) CreateRenderPassAndFramebuffer(
IReadOnlyList<Format> formats,
IReadOnlyList<ImageView> attachmentViews,
uint width,
uint height,
IReadOnlyList<bool> initialized,
GuestDepthResource? depth,
bool depthInitialized)
{
if (formats.Count == 0 ||
formats.Count != attachmentViews.Count ||
formats.Count != initialized.Count)
{
throw new InvalidOperationException(
"render target formats, views, and initialization states must have matching counts");
}
var attachmentCount = formats.Count + (depth is null ? 0 : 1);
var attachments = stackalloc AttachmentDescription[attachmentCount];
var colorReferences = stackalloc AttachmentReference[formats.Count];
var views = stackalloc ImageView[attachmentCount];
for (var index = 0; index < formats.Count; index++)
{
attachments[index] = new AttachmentDescription
{
Format = formats[index],
Samples = SampleCountFlags.Count1Bit,
LoadOp = initialized[index]
? AttachmentLoadOp.Load
: AttachmentLoadOp.Clear,
StoreOp = AttachmentStoreOp.Store,
StencilLoadOp = AttachmentLoadOp.DontCare,
StencilStoreOp = AttachmentStoreOp.DontCare,
InitialLayout = ImageLayout.ColorAttachmentOptimal,
FinalLayout = ImageLayout.ColorAttachmentOptimal,
};
colorReferences[index] = new AttachmentReference
{
Attachment = (uint)index,
Layout = ImageLayout.ColorAttachmentOptimal,
};
views[index] = attachmentViews[index];
}
AttachmentReference depthReference = default;
if (depth is not null)
{
attachments[formats.Count] = new AttachmentDescription
{
Format = DepthFormat,
Samples = SampleCountFlags.Count1Bit,
LoadOp = depthInitialized
? AttachmentLoadOp.Load
: AttachmentLoadOp.Clear,
StoreOp = AttachmentStoreOp.Store,
StencilLoadOp = AttachmentLoadOp.DontCare,
StencilStoreOp = AttachmentStoreOp.DontCare,
InitialLayout = depthInitialized
? ImageLayout.DepthStencilAttachmentOptimal
: ImageLayout.Undefined,
FinalLayout = ImageLayout.DepthStencilAttachmentOptimal,
};
depthReference = new AttachmentReference
{
Attachment = (uint)formats.Count,
Layout = ImageLayout.DepthStencilAttachmentOptimal,
};
views[formats.Count] = depth.View;
}
var subpass = new SubpassDescription
{
PipelineBindPoint = PipelineBindPoint.Graphics,
ColorAttachmentCount = (uint)formats.Count,
PColorAttachments = colorReferences,
PDepthStencilAttachment = depth is null ? null : &depthReference,
};
var renderPassInfo = new RenderPassCreateInfo
{
SType = StructureType.RenderPassCreateInfo,
AttachmentCount = (uint)attachmentCount,
PAttachments = attachments,
SubpassCount = 1,
PSubpasses = &subpass,
};
Check(
_vk.CreateRenderPass(_device, &renderPassInfo, null, out var renderPass),
"vkCreateRenderPass(offscreen)");
var framebufferInfo = new FramebufferCreateInfo
{
SType = StructureType.FramebufferCreateInfo,
RenderPass = renderPass,
AttachmentCount = (uint)attachmentCount,
PAttachments = views,
Width = width,
Height = height,
Layers = 1,
};
Check(
_vk.CreateFramebuffer(_device, &framebufferInfo, null, out var framebuffer),
"vkCreateFramebuffer(offscreen)");
return (renderPass, framebuffer);
}
private (Image Image, DeviceMemory Memory, ImageView View) CreateDepthAttachment(
uint width,
uint height)
{
var imageInfo = new ImageCreateInfo
{
SType = StructureType.ImageCreateInfo,
ImageType = ImageType.Type2D,
Format = DepthFormat,
Extent = new Extent3D(Math.Max(width, 1), Math.Max(height, 1), 1),
MipLevels = 1,
ArrayLayers = 1,
Samples = SampleCountFlags.Count1Bit,
Tiling = ImageTiling.Optimal,
Usage =
ImageUsageFlags.DepthStencilAttachmentBit |
ImageUsageFlags.SampledBit |
ImageUsageFlags.TransferSrcBit |
ImageUsageFlags.TransferDstBit,
SharingMode = SharingMode.Exclusive,
InitialLayout = ImageLayout.Undefined,
};
Check(_vk.CreateImage(_device, &imageInfo, null, out var image), "vkCreateImage(depth)");
_vk.GetImageMemoryRequirements(_device, image, out var requirements);
var memoryInfo = new MemoryAllocateInfo
{
SType = StructureType.MemoryAllocateInfo,
AllocationSize = requirements.Size,
MemoryTypeIndex = FindMemoryType(requirements.MemoryTypeBits, MemoryPropertyFlags.DeviceLocalBit),
};
Check(_vk.AllocateMemory(_device, &memoryInfo, null, out var memory), "vkAllocateMemory(depth)");
Check(_vk.BindImageMemory(_device, image, memory, 0), "vkBindImageMemory(depth)");
var viewInfo = new ImageViewCreateInfo
{
SType = StructureType.ImageViewCreateInfo,
Image = image,
ViewType = ImageViewType.Type2D,
Format = DepthFormat,
SubresourceRange = new ImageSubresourceRange(ImageAspectFlags.DepthBit, 0, 1, 0, 1),
};
Check(_vk.CreateImageView(_device, &viewInfo, null, out var view), "vkCreateImageView(depth)");
return (image, memory, view);
}
private GuestDepthResource GetOrCreateGuestDepth(GuestDepthTarget target)
{
var key = new GuestDepthKey(
target.Address,
target.ReadAddress,
target.Width,
target.Height,
target.GuestFormat,
target.SwizzleMode);
if (_guestDepthImages.TryGetValue(key, out var existing))
{
existing.GuestClearDepth = target.ClearDepth;
if (!existing.Initialized && existing.InitializationSource == "none")
{
existing.ClearDepth = target.ClearDepth;
}
return existing;
}
var (image, memory, view) = CreateDepthAttachment(target.Width, target.Height);
var resource = new GuestDepthResource
{
Key = key,
Address = target.Address,
ReadAddress = target.ReadAddress,
WriteAddress = target.WriteAddress,
Width = target.Width,
Height = target.Height,
GuestFormat = target.GuestFormat,
SwizzleMode = target.SwizzleMode,
Image = image,
Memory = memory,
View = view,
GuestClearDepth = target.ClearDepth,
ClearDepth = target.ClearDepth,
};
SetDebugName(
ObjectType.Image,
image.Handle,
$"SharpEmu guest depth 0x{target.Address:X16} {target.Width}x{target.Height}");
SetDebugName(
ObjectType.ImageView,
view.Handle,
$"SharpEmu guest depth view 0x{target.Address:X16}");
_guestDepthImages.Add(key, resource);
if (_traceGuestImageEvents || _traceVulkanShaderEnabled)
{
Console.Error.WriteLine(
$"[GIMG] created-depth addr=0x{target.Address:X} " +
$"read=0x{target.ReadAddress:X} write=0x{target.WriteAddress:X} " +
$"{target.Width}x{target.Height} zfmt={target.GuestFormat} " +
$"sw={target.SwizzleMode} clear={target.ClearDepth:0.######}");
}
return resource;
}
private static void PrepareFirstUseDepth(
GuestDepthResource depth,
GuestDepthState state)
{
if (depth.Initialized || depth.InitializationSource != "none")
{
return;
}
var effectiveClear = depth.GuestClearDepth;
var source = "guest-clear";
if (state.TestEnable && !state.WriteEnable)
{
switch (state.CompareOp)
{
case 1: // Less
case 3: // LessOrEqual
effectiveClear = 1f;
source = "neutral-first-use";
break;
case 4: // Greater
case 6: // GreaterOrEqual
effectiveClear = 0f;
source = "neutral-first-use";
break;
case 7: // Always
break;
default: // Never, Equal, NotEqual
source = "guest-clear-ambiguous";
Console.Error.WriteLine(
$"[LOADER][WARN] Vulkan has no neutral first-use depth clear " +
$"addr=0x{depth.Address:X16} compare={state.CompareOp}; " +
$"using guest clear {depth.GuestClearDepth:0.######}.");
break;
}
}
depth.ClearDepth = effectiveClear;
depth.InitializationSource = source;
if (_traceDepthInitialization)
{
Console.Error.WriteLine(
$"[LOADER][TRACE] vk.depth_init " +
$"addr=0x{depth.Address:X16} size={depth.Width}x{depth.Height} " +
$"source={source} guest_clear={depth.GuestClearDepth:0.######} " +
$"effective_clear={effectiveClear:0.######} compare={state.CompareOp} " +
$"test={(state.TestEnable ? 1 : 0)} write={(state.WriteEnable ? 1 : 0)} " +
$"initialized=0");
}
}
private GuestRenderTarget GetDepthOnlyColorTarget(GuestDepthTarget depth)
{
var key = new GuestDepthKey(
depth.Address,
depth.ReadAddress,
depth.Width,
depth.Height,
depth.GuestFormat,
depth.SwizzleMode);
if (!_depthOnlyColorAddresses.TryGetValue(key, out var address))
{
address = _nextDepthOnlyColorAddress;
_nextDepthOnlyColorAddress = checked(_nextDepthOnlyColorAddress + 0x1000_0000UL);
_depthOnlyColorAddresses.Add(key, address);
}
// The translated fragment module still declares a color output,
// even for a guest depth-only pass. A private, never-published
// color attachment keeps that output legal while the persistent
// guest DB surface remains the only observable result.
return new GuestRenderTarget(
address,
depth.Width,
depth.Height,
Format: 10,
NumberType: 0);
}
private DepthFramebufferResource GetOrCreateDepthFramebuffer(
GuestImageResource color,
GuestDepthResource depth)
{
if (color.DepthFramebuffers.TryGetValue(depth.Key, out var existing))
{
return existing;
}
if (depth.Width < color.Width || depth.Height < color.Height)
{
throw new InvalidOperationException(
$"guest depth 0x{depth.Address:X16} extent {depth.Width}x{depth.Height} " +
$"is smaller than color target 0x{color.Address:X16} " +
$"{color.Width}x{color.Height}");
}
var attachmentView = color.MipViews.Length > 0 ? color.MipViews[0] : color.View;
var loadRenderPass = CreateDepthRenderPass(
color.Format,
clearColor: false,
clearDepth: false);
var colorClearRenderPass = CreateDepthRenderPass(
color.Format,
clearColor: true,
clearDepth: false);
var depthClearRenderPass = CreateDepthRenderPass(
color.Format,
clearColor: false,
clearDepth: true);
var bothClearRenderPass = CreateDepthRenderPass(
color.Format,
clearColor: true,
clearDepth: true);
var attachments = stackalloc ImageView[2];
attachments[0] = attachmentView;
attachments[1] = depth.View;
var framebufferInfo = new FramebufferCreateInfo
{
SType = StructureType.FramebufferCreateInfo,
RenderPass = loadRenderPass,
AttachmentCount = 2,
PAttachments = attachments,
Width = color.Width,
Height = color.Height,
Layers = 1,
};
Check(
_vk.CreateFramebuffer(_device, &framebufferInfo, null, out var framebuffer),
"vkCreateFramebuffer(offscreen depth)");
var resource = new DepthFramebufferResource
{
Depth = depth,
LoadRenderPass = loadRenderPass,
ColorClearRenderPass = colorClearRenderPass,
DepthClearRenderPass = depthClearRenderPass,
BothClearRenderPass = bothClearRenderPass,
Framebuffer = framebuffer,
};
var name = $"SharpEmu color 0x{color.Address:X16} depth 0x{depth.Address:X16}";
SetDebugName(ObjectType.RenderPass, loadRenderPass.Handle, $"{name} load");
SetDebugName(ObjectType.RenderPass, colorClearRenderPass.Handle, $"{name} color-clear");
SetDebugName(ObjectType.RenderPass, depthClearRenderPass.Handle, $"{name} depth-clear");
SetDebugName(ObjectType.RenderPass, bothClearRenderPass.Handle, $"{name} both-clear");
SetDebugName(ObjectType.Framebuffer, framebuffer.Handle, $"{name} framebuffer");
color.DepthFramebuffers.Add(depth.Key, resource);
return resource;
}
private RenderPass CreateDepthRenderPass(
Format colorFormat,
bool clearColor,
bool clearDepth)
{
var attachments = stackalloc AttachmentDescription[2];
attachments[0] = new AttachmentDescription
{
Format = colorFormat,
Samples = SampleCountFlags.Count1Bit,
LoadOp = clearColor ? AttachmentLoadOp.Clear : AttachmentLoadOp.Load,
StoreOp = AttachmentStoreOp.Store,
StencilLoadOp = AttachmentLoadOp.DontCare,
StencilStoreOp = AttachmentStoreOp.DontCare,
InitialLayout = ImageLayout.ColorAttachmentOptimal,
FinalLayout = ImageLayout.ColorAttachmentOptimal,
};
attachments[1] = new AttachmentDescription
{
Format = DepthFormat,
Samples = SampleCountFlags.Count1Bit,
LoadOp = clearDepth ? AttachmentLoadOp.Clear : AttachmentLoadOp.Load,
StoreOp = AttachmentStoreOp.Store,
StencilLoadOp = AttachmentLoadOp.DontCare,
StencilStoreOp = AttachmentStoreOp.DontCare,
InitialLayout = clearDepth
? ImageLayout.Undefined
: ImageLayout.DepthStencilAttachmentOptimal,
FinalLayout = ImageLayout.DepthStencilAttachmentOptimal,
};
var colorReference = new AttachmentReference
{
Attachment = 0,
Layout = ImageLayout.ColorAttachmentOptimal,
};
var depthReference = new AttachmentReference
{
Attachment = 1,
Layout = ImageLayout.DepthStencilAttachmentOptimal,
};
var subpass = new SubpassDescription
{
PipelineBindPoint = PipelineBindPoint.Graphics,
ColorAttachmentCount = 1,
PColorAttachments = &colorReference,
PDepthStencilAttachment = &depthReference,
};
var dependency = new SubpassDependency
{
SrcSubpass = Vk.SubpassExternal,
DstSubpass = 0,
SrcStageMask = PipelineStageFlags.LateFragmentTestsBit,
DstStageMask =
PipelineStageFlags.EarlyFragmentTestsBit |
PipelineStageFlags.LateFragmentTestsBit,
SrcAccessMask = AccessFlags.DepthStencilAttachmentWriteBit,
DstAccessMask =
AccessFlags.DepthStencilAttachmentReadBit |
AccessFlags.DepthStencilAttachmentWriteBit,
DependencyFlags = DependencyFlags.ByRegionBit,
};
var createInfo = new RenderPassCreateInfo
{
SType = StructureType.RenderPassCreateInfo,
AttachmentCount = 2,
PAttachments = attachments,
SubpassCount = 1,
PSubpasses = &subpass,
DependencyCount = 1,
PDependencies = &dependency,
};
Check(
_vk.CreateRenderPass(_device, &createInfo, null, out var renderPass),
"vkCreateRenderPass(offscreen depth)");
return renderPass;
}
private static uint ClampMipLevels(uint width, uint height, uint requestedMipLevels)
{
var largestDimension = Math.Max(width, height);
uint maximumMipLevels = 1;
while (largestDimension > 1)
{
largestDimension >>= 1;
maximumMipLevels++;
}
return Math.Min(Math.Max(requestedMipLevels, 1u), maximumMipLevels);
}
private void DestroyGuestImage(GuestImageResource resource)
{
foreach (var depthFramebuffer in resource.DepthFramebuffers.Values)
{
DestroyDepthFramebuffer(depthFramebuffer);
}
resource.DepthFramebuffers.Clear();
foreach (var view in resource.FormatViews.Values)
{
if (view.Handle != 0)
{
_vk.DestroyImageView(_device, view, null);
}
}
resource.FormatViews.Clear();
if (resource.Framebuffer.Handle != 0)
{
_vk.DestroyFramebuffer(_device, resource.Framebuffer, null);
}
if (resource.RenderPass.Handle != 0)
{
_vk.DestroyRenderPass(_device, resource.RenderPass, null);
}
if (resource.InitialRenderPass.Handle != 0)
{
_vk.DestroyRenderPass(_device, resource.InitialRenderPass, null);
}
if (resource.View.Handle != 0)
{
_vk.DestroyImageView(_device, resource.View, null);
}
foreach (var mipView in resource.MipViews)
{
if (mipView.Handle != 0)
{
_vk.DestroyImageView(_device, mipView, null);
}
}
if (resource.Image.Handle != 0)
{
_vk.DestroyImage(_device, resource.Image, null);
}
if (resource.Memory.Handle != 0)
{
_vk.FreeMemory(_device, resource.Memory, null);
}
}
private void DestroyDepthFramebuffer(DepthFramebufferResource resource)
{
if (resource.Framebuffer.Handle != 0)
{
_vk.DestroyFramebuffer(_device, resource.Framebuffer, null);
}
if (resource.LoadRenderPass.Handle != 0)
{
_vk.DestroyRenderPass(_device, resource.LoadRenderPass, null);
}
if (resource.ColorClearRenderPass.Handle != 0)
{
_vk.DestroyRenderPass(_device, resource.ColorClearRenderPass, null);
}
if (resource.DepthClearRenderPass.Handle != 0)
{
_vk.DestroyRenderPass(_device, resource.DepthClearRenderPass, null);
}
if (resource.BothClearRenderPass.Handle != 0)
{
_vk.DestroyRenderPass(_device, resource.BothClearRenderPass, null);
}
}
private void DestroyGuestDepth(GuestDepthResource resource)
{
foreach (var sampleView in resource.SampleViews.Values)
{
if (sampleView.Handle != 0)
{
_vk.DestroyImageView(_device, sampleView, null);
}
}
resource.SampleViews.Clear();
if (resource.View.Handle != 0)
{
_vk.DestroyImageView(_device, resource.View, null);
}
if (resource.Image.Handle != 0)
{
_vk.DestroyImage(_device, resource.Image, null);
}
if (resource.Memory.Handle != 0)
{
_vk.FreeMemory(_device, resource.Memory, null);
}
}
private bool TryGetOrCreateGuestImageView(
GuestImageResource resource,
Format format,
uint mipLevel,
uint levelCount,
uint dstSelect,
out ImageView view)
{
try
{
view = GetOrCreateGuestImageView(resource, format, mipLevel, levelCount, dstSelect);
return true;
}
catch (Exception exception)
{
view = default;
TraceVulkanShader(
$"vk.texture_alias_view_failed addr=0x{resource.Address:X16} " +
$"image_format={resource.Format} view_format={format}: {exception.Message}");
return false;
}
}
private ImageView GetOrCreateGuestImageView(
GuestImageResource resource,
Format format,
uint mipLevel,
uint levelCount,
uint dstSelect = 0xFAC)
{
if (mipLevel >= resource.MipLevels)
{
throw new InvalidOperationException(
$"View mip {mipLevel} exceeds image mip count {resource.MipLevels}.");
}
levelCount = Math.Max(levelCount, 1);
levelCount = Math.Min(levelCount, resource.MipLevels - mipLevel);
if (format == resource.Format && dstSelect == 0xFAC)
{
if (mipLevel == 0 && levelCount == resource.MipLevels)
{
return resource.View;
}
if (levelCount == 1)
{
return resource.MipViews[mipLevel];
}
}
if (!IsCompatibleViewFormat(resource.Format, format))
{
throw new InvalidOperationException(
$"Incompatible image view format {format} for image {resource.Format}.");
}
var key = (format, mipLevel, levelCount, dstSelect);
if (resource.FormatViews.TryGetValue(key, out var existing))
{
return existing;
}
var viewInfo = new ImageViewCreateInfo
{
SType = StructureType.ImageViewCreateInfo,
Image = resource.Image,
ViewType = ImageViewType.Type2D,
Format = format,
Components = ToVkComponentMapping(dstSelect),
SubresourceRange = ColorSubresourceRange(mipLevel, levelCount),
};
ImageView view;
Check(
_vk.CreateImageView(_device, &viewInfo, null, out view),
"vkCreateImageView(guest alias)");
resource.FormatViews.Add(key, view);
SetDebugName(
ObjectType.ImageView,
view.Handle,
$"SharpEmu guest 0x{resource.Address:X16} alias {format} mip{mipLevel}+{levelCount}");
TraceVulkanShader(
$"vk.texture_alias_view addr=0x{resource.Address:X16} " +
$"image_format={resource.Format} view_format={format} " +
$"mip={mipLevel} levels={levelCount} dst=0x{dstSelect:X3}");
return view;
}
internal static bool IsCompatibleViewFormat(Format imageFormat, Format viewFormat)
{
if (imageFormat == viewFormat)
{
return true;
}
var imageClass = GetFormatCompatibilityClass(imageFormat);
return imageClass != 0 && imageClass == GetFormatCompatibilityClass(viewFormat);
}
private static uint GetFormatCompatibilityClass(Format format) =>
format switch
{
Format.R8Unorm or
Format.R8Uint or
Format.R8Sint => 8,
Format.R16Sfloat => 16,
Format.R32Uint or
Format.R32Sint or
Format.R32Sfloat or
Format.R16G16Unorm or
Format.R16G16Uint or
Format.R16G16Sint or
Format.R16G16Sfloat or
Format.R8G8B8A8Unorm or
Format.R8G8B8A8Srgb or
Format.R8G8B8A8Uint or
Format.R8G8B8A8Sint or
Format.A2R10G10B10UnormPack32 or
Format.A2B10G10R10UnormPack32 or
Format.B10G11R11UfloatPack32 => 32,
Format.R32G32Uint or
Format.R32G32Sint or
Format.R32G32Sfloat or
Format.R16G16B16A16Unorm or
Format.R16G16B16A16Uint or
Format.R16G16B16A16Sint or
Format.R16G16B16A16Sfloat => 64,
Format.R32G32B32Sfloat => 96,
Format.R32G32B32A32Uint or
Format.R32G32B32A32Sint or
Format.R32G32B32A32Sfloat => 128,
_ => 0,
};
private void WaitForRenderWork()
{
var gpuWorkInFlight = _pendingGuestSubmissions.Count > 0 ||
Array.Exists(_frameFencePending, static pending => pending);
lock (_gate)
{
if (_closed ||
_pendingGuestWorkCount > 0 ||
(_latestPresentation is { } latest &&
latest.Sequence != _presentedSequence &&
latest.RequiredGuestWorkSequence <= _completedGuestWorkSequence))
{
return;
}
System.Threading.Monitor.Wait(_gate, gpuWorkInFlight ? 1 : 8);
}
}
private bool _overlayHotkeyWasDown;
private void PollPerfOverlayHotkey()
{
// POSIX hosts route F1 through the GLFW window's keyboard events
// (HostWindowInput.Attach). Windows never attaches that path — its
// input comes from user32 polling — so sample the hotkey here for
// both the standalone window and the embedded host surface.
if (!OperatingSystem.IsWindows())
{
return;
}
const int VkF1 = 0x70;
var input = SharpEmu.HLE.Host.HostPlatform.Current.Input;
var down = input.IsKeyDown(VkF1) && input.IsHostWindowFocused();
if (down && !_overlayHotkeyWasDown)
{
PerfOverlay.Toggle();
}
_overlayHotkeyWasDown = down;
}
private void Render(double _)
{
try
{
RenderCore();
}
catch (Exception exception)
{
// Device loss can strike between any two Vulkan calls in the frame;
// keep the window loop pumping instead of tearing the presenter down.
if (!TryMarkDeviceLost(exception))
{
throw;
}
}
}
private void RenderCore()
{
if (Volatile.Read(ref _presenterCloseRequested))
{
Console.Error.WriteLine("[LOADER][WARN] Vulkan VideoOut closing on host shutdown request.");
if (_window is not null)
{
_window.Close();
}
else
{
_embeddedLoopClosed = true;
}
return;
}
PollPerfOverlayHotkey();
if (_hostSurface is not null)
{
_hostSurface.RefreshChildProcessPixelSize();
if (_lastHostResizeGeneration != _hostSurface.ResizeGeneration)
{
_lastHostResizeGeneration = _hostSurface.ResizeGeneration;
RecreateSwapchainResources("embedded host resize", Result.SuboptimalKhr);
_pendingHostSplashReplay = _lastHostSplashPresentation;
}
}
if (!_vulkanReady)
{
return;
}
if (_deviceLost)
{
// Drain queued work so producers aren't back-pressured, then
// return without any Vulkan call (fences never signal post-loss).
while (TryTakeGuestWork(out var lostWork))
{
CompleteGuestWork(lostWork);
}
return;
}
// Reuse of a frame slot waits only on that slot's fence, keeping
// up to MaxFramesInFlight frames pipelined between CPU and GPU.
var frameSlot = _currentFrameSlot;
if (!TryWaitFrameSlot(frameSlot, _frameSlotWaitBudgetNs))
{
// The GPU is still finishing this slot's previous frame (slow
// compute backlog). Don't block the macOS main thread — return
// to the Cocoa event pump so the window keeps handling input
// (F1 overlay, drag, close) and redrawing. The frame is retried
// next Render(); the fence signals once the GPU catches up.
return;
}
_presentationCommandBuffer = _frameCommandBuffers[frameSlot];
_commandBuffer = _presentationCommandBuffer;
if (!_deviceLost)
{
CollectCompletedGuestSubmissions(waitForOldest: false);
}
EvictDirtyCachedTextures();
var completedWork = 0;
var renderWorkDeadline = _renderWorkBudgetTicks > 0
? System.Diagnostics.Stopwatch.GetTimestamp() + _renderWorkBudgetTicks
: long.MaxValue;
while (completedWork < _maxGuestWorkPerRender)
{
// Never block the macOS main thread waiting for in-flight GPU
// work to drain. If submission is at capacity (a slow-compute
// backlog), stop processing and let the event pump run; the
// remaining queued work is picked up on later frames as the GPU
// completions free up capacity (collected non-blockingly here).
CollectCompletedGuestSubmissions(waitForOldest: false);
if (OperatingSystem.IsMacOS() &&
_pendingGuestSubmissions.Count >= MaxInFlightGuestSubmissions)
{
break;
}
if (!TryTakeGuestWork(out var pendingGuestWork))
{
break;
}
if (!string.Equals(
_activeGuestQueue.Name,
pendingGuestWork.Queue.Name,
StringComparison.Ordinal))
{
// A host command buffer must never contain commands from
// two independent guest queues: an ordered action fences
// only its own queue's predecessor submissions.
FlushBatchedGuestCommands();
}
_activeGuestQueue = pendingGuestWork.Queue;
_activeGuestWorkSequence = pendingGuestWork.Sequence;
Volatile.Write(
ref _executingGuestWorkSequence,
pendingGuestWork.Sequence);
using var guestQueueScope = EnterGuestQueue(
pendingGuestWork.Queue.Name,
pendingGuestWork.Queue.SubmissionId);
_enqueueAsImmediateQueueFollowup = true;
_immediateFollowupTail = null;
var work = pendingGuestWork.Work;
var traceWork = ShouldTracePresentedGuestImageContentsForDiagnostics();
var workStart = traceWork ? System.Diagnostics.Stopwatch.GetTimestamp() : 0L;
if (traceWork && work is VulkanComputeGuestDispatch or VulkanOffscreenGuestDraw)
{
Console.Error.WriteLine(
$"[LOADER][TRACE] vk.render_work_enter #{completedWork} " +
$"sequence={pendingGuestWork.Sequence} " +
$"queue={pendingGuestWork.Queue.Name} " +
$"submission={pendingGuestWork.Queue.SubmissionId} " +
$"queued_ms={(System.Diagnostics.Stopwatch.GetTimestamp() - pendingGuestWork.EnqueuedTicks) * 1000.0 / System.Diagnostics.Stopwatch.Frequency:F3} " +
work.GetType().Name);
}
try
{
switch (work)
{
case VulkanOffscreenGuestDraw offscreenDraw:
ExecuteOffscreenDraw(offscreenDraw);
break;
case VulkanComputeGuestDispatch computeDispatch:
ExecuteComputeDispatch(computeDispatch);
break;
case VulkanGuestImageWrite guestImageWrite:
ExecuteGuestImageWrite(guestImageWrite);
break;
case VulkanOrderedGuestAction orderedAction:
ExecuteOrderedGuestAction(orderedAction);
break;
case VulkanOrderedGuestFlip orderedFlip:
ExecuteOrderedGuestFlip(orderedFlip);
break;
case VulkanOrderedGuestFlipWait flipWait:
ExecuteOrderedGuestFlipWait(flipWait);
break;
}
}
finally
{
CompleteGuestWork(pendingGuestWork);
_enqueueAsImmediateQueueFollowup = false;
_immediateFollowupTail = null;
Volatile.Write(ref _executingGuestWorkSequence, 0);
}
if (workStart != 0)
{
var elapsedMs = (System.Diagnostics.Stopwatch.GetTimestamp() - workStart)
* 1000.0 / System.Diagnostics.Stopwatch.Frequency;
if (elapsedMs > 250.0)
{
var desc = work switch
{
VulkanComputeGuestDispatch c => $"compute cs=0x{c.ShaderAddress:X16} groups={c.GroupCountX}x{c.GroupCountY}x{c.GroupCountZ}",
VulkanOffscreenGuestDraw d =>
$"draw mrt={d.Targets.Count} " +
$"rt=0x{d.Targets[0].Address:X16} " +
$"{d.Targets[0].Width}x{d.Targets[0].Height}",
_ => work.GetType().Name,
};
Console.Error.WriteLine(
$"[LOADER][WARN] vk.slow_render_work {elapsedMs:F0}ms " +
$"queue={pendingGuestWork.Queue.Name} " +
$"submission={pendingGuestWork.Queue.SubmissionId} " +
$"sequence={pendingGuestWork.Sequence}: {desc}");
}
}
completedWork++;
// Return to the main-thread event pump + present once the
// per-frame budget is spent; remaining guest work is drained
// on subsequent Render() calls. Without this a compute-heavy
// backlog freezes the window (macOS "Not Responding").
if (System.Diagnostics.Stopwatch.GetTimestamp() >= renderWorkDeadline)
{
break;
}
}
FlushBatchedGuestCommands();
CollectAbandonedGuestImageVersions();
if (!TryTakePresentation(_presentedSequence, out var presentation))
{
if (_pendingHostSplashReplay is { } splash)
{
presentation = splash;
_pendingHostSplashReplay = null;
}
else
{
// A render-loop tick with no newer flip is normal. Warn only when
// an actual queued presentation is waiting on unfinished guest work.
if (ShouldTracePresentedGuestImageContentsForDiagnostics() &&
HasPendingGuestPresentation(_presentedSequence) &&
_presentNotTakenLoggedSequence != _presentedSequence)
{
_presentNotTakenLoggedSequence = _presentedSequence;
Console.Error.WriteLine(
$"[LOADER][WARN] vk.present_not_taken seq={_presentedSequence} " +
"— presentation submitted but its required guest work isn't complete; nothing shown.");
}
return;
}
}
if (_hostSurface is not null)
{
if (presentation.IsSplash && presentation.Pixels is not null)
{
_lastHostSplashPresentation = presentation;
}
else
{
_lastHostSplashPresentation = null;
_pendingHostSplashReplay = null;
}
}
if (ShouldTracePresentedGuestImageContentsForDiagnostics())
{
Console.Error.WriteLine(
$"[LOADER][TRACE] vk.present_taken addr=0x{presentation.GuestImageAddress:X16} " +
$"version={presentation.GuestImageVersion} " +
$"drawKind={presentation.DrawKind} hasPixels={presentation.Pixels is not null} " +
$"hasTranslatedDraw={presentation.TranslatedDraw is not null}");
}
if (presentation.Pixels is null &&
presentation.DrawKind != GuestDrawKind.FullscreenBarycentric &&
presentation.TranslatedDraw is null &&
presentation.GuestImageAddress == 0)
{
return;
}
byte[]? pixels = null;
if (presentation.Pixels is { } sourcePixels)
{
pixels = presentation.Width == _extent.Width && presentation.Height == _extent.Height
? sourcePixels
: _hostSurface is not null
? ScaleBgraCoverBilinear(
sourcePixels,
presentation.Width,
presentation.Height,
_extent.Width,
_extent.Height)
: ScaleBgra(
sourcePixels,
presentation.Width,
presentation.Height,
_extent.Width,
_extent.Height);
if ((ulong)pixels.Length > _stagingSize)
{
return;
}
}
TranslatedDrawResources? translatedResources = null;
GuestImageResource? presentedGuestImage = null;
var ownsPresentedGuestImageVersion = false;
if (presentation.GuestImageVersion != 0)
{
ownsPresentedGuestImageVersion = _guestImageVersions.Remove(
presentation.GuestImageVersion,
out presentedGuestImage);
}
else if (presentation.GuestImageAddress != 0)
{
_guestImages.TryGetValue(
presentation.GuestImageAddress,
out presentedGuestImage);
}
if (presentation.GuestImageAddress != 0 &&
(presentedGuestImage is null || !presentedGuestImage.Initialized))
{
if (ShouldTracePresentedGuestImageContentsForDiagnostics())
{
Console.Error.WriteLine(
$"[LOADER][WARN] vk.present_dropped addr=0x{presentation.GuestImageAddress:X16} " +
$"version={presentation.GuestImageVersion} " +
$"found={(presentedGuestImage is not null)} " +
$"initialized={(presentedGuestImage?.Initialized ?? false)} " +
$"— no swapchain present this frame (black).");
}
if (ownsPresentedGuestImageVersion && presentedGuestImage is not null)
{
DestroyGuestImage(presentedGuestImage);
}
return;
}
if (ownsPresentedGuestImageVersion)
{
System.Diagnostics.Debug.Assert(
_frameGuestImageVersions[frameSlot] is null,
"A reusable frame slot cannot still own a flip version.");
_frameGuestImageVersions[frameSlot] = presentedGuestImage;
}
if (presentedGuestImage is not null)
{
_directPresentationCount++;
var traceAddressedPresentation =
ShouldTraceAddressedPresentedGuestImage(presentedGuestImage);
if (traceAddressedPresentation ||
ShouldTracePresentedGuestImageContentsForDiagnostics() &&
(_directPresentationCount is 1 or 30 or 120 ||
_directPresentationCount % 600 == 0))
{
Console.Error.WriteLine(
$"[LOADER][TRACE] vk.present_sample frame={_directPresentationCount} " +
$"addr=0x{presentedGuestImage.Address:X16}");
}
}
if (presentation.TranslatedDraw is { } translatedDraw)
{
try
{
translatedResources = CreateTranslatedDrawResources(
translatedDraw,
_renderPass,
[_swapchainFormat],
_extent);
if (ShouldTracePresentedGuestImageContentsForDiagnostics() &&
!_firstGuestDrawPresented &&
translatedResources.Textures is
[
{ GuestImage: { } guestImage },
] &&
_tracedGuestImageContents.Add(guestImage.Address))
{
TraceGuestImageContents(guestImage);
}
}
catch (Exception exception)
{
_presentedSequence = presentation.Sequence;
Console.Error.WriteLine(
$"[LOADER][ERROR] Vulkan VideoOut translated draw setup failed: {exception.Message}");
return;
}
}
uint imageIndex;
var acquireResult = _swapchainApi.AcquireNextImage(
_device,
_swapchain,
ulong.MaxValue,
_frameImageAvailable[frameSlot],
default,
&imageIndex);
if (acquireResult == Result.ErrorOutOfDateKhr)
{
RecreateSwapchainResources("vkAcquireNextImageKHR", acquireResult);
if (translatedResources is not null)
{
DestroyTranslatedDrawResources(translatedResources);
}
if (ownsPresentedGuestImageVersion && presentedGuestImage is not null)
{
if (_frameGuestImageVersions.Length > frameSlot &&
ReferenceEquals(
_frameGuestImageVersions[frameSlot],
presentedGuestImage))
{
_frameGuestImageVersions[frameSlot] = null;
_capturedGuestFlipVersions.Remove(
presentedGuestImage.FlipVersion);
DestroyGuestImage(presentedGuestImage);
}
}
return;
}
CheckSwapchainResult(acquireResult, "vkAcquireNextImageKHR");
var recreateAfterPresent = acquireResult == Result.SuboptimalKhr;
if (pixels is not null)
{
// The staging buffer is shared across frame slots; a CPU
// pixel upload (splash / host frames) degrades to serial
// presentation rather than corrupting an in-flight copy.
WaitAllFrameSlots();
void* mapped;
Check(
_vk.MapMemory(_device, _stagingMemory, 0, (ulong)pixels.Length, 0, &mapped),
"vkMapMemory");
fixed (byte* source = pixels)
{
System.Buffer.MemoryCopy(source, mapped, pixels.Length, pixels.Length);
}
_vk.UnmapMemory(_device, _stagingMemory);
}
Check(_vk.ResetCommandBuffer(_commandBuffer, 0), "vkResetCommandBuffer");
var beginInfo = new CommandBufferBeginInfo
{
SType = StructureType.CommandBufferBeginInfo,
Flags = CommandBufferUsageFlags.OneTimeSubmitBit,
};
Check(_vk.BeginCommandBuffer(_commandBuffer, &beginInfo), "vkBeginCommandBuffer");
PipelineStageFlags waitStage;
if (pixels is not null)
{
RecordUpload(imageIndex);
waitStage = PipelineStageFlags.TransferBit;
}
else if (presentation.DrawKind == GuestDrawKind.FullscreenBarycentric)
{
var clearValue = default(ClearValue);
var renderPassInfo = new RenderPassBeginInfo
{
SType = StructureType.RenderPassBeginInfo,
RenderPass = _renderPass,
Framebuffer = _framebuffers[imageIndex],
RenderArea = new Rect2D(new Offset2D(0, 0), _extent),
ClearValueCount = 1,
PClearValues = &clearValue,
};
_vk.CmdBeginRenderPass(
_commandBuffer,
&renderPassInfo,
SubpassContents.Inline);
_vk.CmdBindPipeline(
_commandBuffer,
PipelineBindPoint.Graphics,
_barycentricPipeline);
_vk.CmdDraw(_commandBuffer, 3, 1, 0, 0);
_vk.CmdEndRenderPass(_commandBuffer);
waitStage = PipelineStageFlags.ColorAttachmentOutputBit;
}
else if (presentedGuestImage is not null)
{
RecordGuestImageBlit(imageIndex, presentedGuestImage);
waitStage = PipelineStageFlags.TransferBit;
}
else if (translatedResources is not null)
{
RecordTranslatedDraw(imageIndex, translatedResources);
waitStage = PipelineStageFlags.AllCommandsBit;
}
else
{
throw new InvalidOperationException(
$"Unsupported translated guest draw: {presentation.DrawKind}.");
}
if (PerfOverlay.Enabled)
{
RecordOverlayBlit(imageIndex, frameSlot);
}
Check(_vk.EndCommandBuffer(_commandBuffer), "vkEndCommandBuffer");
var imageAvailable = _frameImageAvailable[frameSlot];
var commandBuffer = _commandBuffer;
var renderFinished = _renderFinishedPerImage[imageIndex];
var submitInfo = new SubmitInfo
{
SType = StructureType.SubmitInfo,
WaitSemaphoreCount = 1,
PWaitSemaphores = &imageAvailable,
PWaitDstStageMask = &waitStage,
CommandBufferCount = 1,
PCommandBuffers = &commandBuffer,
SignalSemaphoreCount = 1,
PSignalSemaphores = &renderFinished,
};
Check(
_vk.QueueSubmit(_queue, 1, &submitInfo, _frameFences[frameSlot]),
"vkQueueSubmit");
_submitTimeline++;
_frameTimelines[frameSlot] = _submitTimeline;
_frameFencePending[frameSlot] = true;
_frameTranslatedResources[frameSlot] = translatedResources;
if (translatedResources is not null)
{
// CPU-side layout bookkeeping only; later command buffers are
// recorded after this submission, so queue order makes the
// flags valid before any dependent GPU work runs.
MarkSampledImagesInitialized(translatedResources);
MarkStorageImagesInitialized(translatedResources);
}
var swapchain = _swapchain;
var presentInfo = new PresentInfoKHR
{
SType = StructureType.PresentInfoKhr,
WaitSemaphoreCount = 1,
PWaitSemaphores = &renderFinished,
SwapchainCount = 1,
PSwapchains = &swapchain,
PImageIndices = &imageIndex,
};
var presentResult = _swapchainApi.QueuePresent(_queue, &presentInfo);
if (presentResult == Result.ErrorOutOfDateKhr)
{
// The submitted frame still executes; RecreateSwapchainResources
// drains it (and every frame slot) before destroying anything.
RecreateSwapchainResources("vkQueuePresentKHR", presentResult);
return;
}
CheckSwapchainResult(presentResult, "vkQueuePresentKHR");
recreateAfterPresent |= presentResult == Result.SuboptimalKhr;
VideoOutExports.ReportPresentedFrame();
PerfOverlay.RecordPresent();
if (_hostSurface is not null && !_firstHostFramePresented)
{
_firstHostFramePresented = true;
NotifyFirstHostFramePresented(_hostSurface);
}
if (_swapchainReadbackPending || !_pendingAliasImageDumps.IsEmpty)
{
// Diagnostics read back GPU memory and need this frame done.
WaitFrameSlot(frameSlot);
if (_swapchainReadbackPending)
{
TraceSwapchainReadback();
}
while (_pendingAliasImageDumps.TryDequeue(out var aliasImage))
{
TraceGuestImageContents(aliasImage);
}
}
CollectCompletedGuestSubmissions(waitForOldest: false);
_imageInitialized[imageIndex] = true;
_currentFrameSlot = (frameSlot + 1) % MaxFramesInFlight;
_presentedSequence = presentation.Sequence;
if (presentation.IsSplash && !_splashPresented)
{
_splashPresented = true;
Console.Error.WriteLine(
$"[LOADER][INFO] Vulkan VideoOut presented splash: " +
$"{presentation.Width}x{presentation.Height}");
}
else if (!presentation.IsSplash && !_firstFramePresented)
{
_firstFramePresented = true;
Console.Error.WriteLine(
$"[LOADER][INFO] Vulkan VideoOut presented first frame: " +
$"{presentation.Width}x{presentation.Height}");
}
if (pixels is null && !_firstGuestDrawPresented)
{
_firstGuestDrawPresented = true;
Console.Error.WriteLine(
$"[LOADER][INFO] Vulkan VideoOut presented guest frame: " +
(presentedGuestImage is not null
? $"image=0x{presentedGuestImage.Address:X16} " +
$"{presentedGuestImage.Width}x{presentedGuestImage.Height}"
: presentation.TranslatedDraw is null
? $"{presentation.DrawKind}"
: $"shader textures={presentation.TranslatedDraw.Textures.Count}"));
}
if (recreateAfterPresent)
{
RecreateSwapchainResources("present suboptimal", Result.SuboptimalKhr);
}
}
private void TraceGuestImageContents(GuestImageResource image)
{
var bytesPerPixel = GetReadbackBytesPerPixel(image.Format);
if (bytesPerPixel == 0)
{
Console.Error.WriteLine(
"[LOADER][TRACE] " +
$"vk.guest_image addr=0x{image.Address:X16} " +
$"format={image.Format} readback=unsupported");
return;
}
var byteCount = checked((ulong)image.Width * image.Height * bytesPerPixel);
var buffer = CreateBuffer(
byteCount,
BufferUsageFlags.TransferDstBit,
MemoryPropertyFlags.HostVisibleBit | MemoryPropertyFlags.HostCoherentBit,
out var memory);
try
{
Check(
_vk.ResetCommandBuffer(_commandBuffer, 0),
"vkResetCommandBuffer(guest readback)");
var beginInfo = new CommandBufferBeginInfo
{
SType = StructureType.CommandBufferBeginInfo,
Flags = CommandBufferUsageFlags.OneTimeSubmitBit,
};
Check(
_vk.BeginCommandBuffer(_commandBuffer, &beginInfo),
"vkBeginCommandBuffer(guest readback)");
var toTransfer = new ImageMemoryBarrier
{
SType = StructureType.ImageMemoryBarrier,
SrcAccessMask = AccessFlags.ShaderReadBit,
DstAccessMask = AccessFlags.TransferReadBit,
OldLayout = ImageLayout.ShaderReadOnlyOptimal,
NewLayout = ImageLayout.TransferSrcOptimal,
SrcQueueFamilyIndex = Vk.QueueFamilyIgnored,
DstQueueFamilyIndex = Vk.QueueFamilyIgnored,
Image = image.Image,
SubresourceRange = ColorSubresourceRange(),
};
_vk.CmdPipelineBarrier(
_commandBuffer,
PipelineStageFlags.FragmentShaderBit |
PipelineStageFlags.ComputeShaderBit,
PipelineStageFlags.TransferBit,
0,
0,
null,
0,
null,
1,
&toTransfer);
var region = new BufferImageCopy
{
ImageSubresource = new ImageSubresourceLayers
{
AspectMask = ImageAspectFlags.ColorBit,
LayerCount = 1,
},
ImageExtent = new Extent3D(image.Width, image.Height, 1),
};
_vk.CmdCopyImageToBuffer(
_commandBuffer,
image.Image,
ImageLayout.TransferSrcOptimal,
buffer,
1,
&region);
var toShaderRead = new ImageMemoryBarrier
{
SType = StructureType.ImageMemoryBarrier,
SrcAccessMask = AccessFlags.TransferReadBit,
DstAccessMask = AccessFlags.ShaderReadBit,
OldLayout = ImageLayout.TransferSrcOptimal,
NewLayout = ImageLayout.ShaderReadOnlyOptimal,
SrcQueueFamilyIndex = Vk.QueueFamilyIgnored,
DstQueueFamilyIndex = Vk.QueueFamilyIgnored,
Image = image.Image,
SubresourceRange = ColorSubresourceRange(),
};
_vk.CmdPipelineBarrier(
_commandBuffer,
PipelineStageFlags.TransferBit,
PipelineStageFlags.FragmentShaderBit |
PipelineStageFlags.ComputeShaderBit,
0,
0,
null,
0,
null,
1,
&toShaderRead);
Check(
_vk.EndCommandBuffer(_commandBuffer),
"vkEndCommandBuffer(guest readback)");
var commandBuffer = _commandBuffer;
var submitInfo = new SubmitInfo
{
SType = StructureType.SubmitInfo,
CommandBufferCount = 1,
PCommandBuffers = &commandBuffer,
};
Check(
_vk.QueueSubmit(_queue, 1, &submitInfo, default),
"vkQueueSubmit(guest readback)");
Check(
_vk.QueueWaitIdle(_queue),
"vkQueueWaitIdle(guest readback)");
void* mapped;
Check(
_vk.MapMemory(_device, memory, 0, byteCount, 0, &mapped),
"vkMapMemory(guest readback)");
try
{
var bytes = new ReadOnlySpan<byte>(mapped, checked((int)byteCount));
if (GuestImageTraceInterval() is not null && bytesPerPixel == 4)
{
long r = 0, g = 0, b = 0, a = 0, samples = 0;
for (var offset = 0; offset + 4 <= bytes.Length; offset += 4 * 251)
{
r += bytes[offset];
g += bytes[offset + 1];
b += bytes[offset + 2];
a += bytes[offset + 3];
samples++;
}
if (samples > 0)
{
Console.Error.WriteLine(
$"[RB] addr=0x{image.Address:X} " +
$"mean={r / samples},{g / samples},{b / samples},A{a / samples} " +
$"sample_unique={CountSampledUniquePixels(bytes, bytesPerPixel)}");
}
if (++_intervalReadbackCount % 25 == 0)
{
DumpGuestImageBytes(image, bytes);
}
return;
}
var nonzeroBytes = 0L;
ulong hash = 14695981039346656037UL;
foreach (var value in bytes)
{
nonzeroBytes += value == 0 ? 0 : 1;
hash = (hash ^ value) * 1099511628211UL;
}
var nonblackPixels = CountNonblackPixels(
bytes,
image.Format,
bytesPerPixel);
var centerOffset = checked(
((int)(image.Height / 2) * (int)image.Width +
(int)(image.Width / 2)) *
(int)bytesPerPixel);
var center = Convert.ToHexString(
bytes.Slice(centerOffset, (int)bytesPerPixel));
Console.Error.WriteLine(
"[LOADER][TRACE] " +
$"vk.guest_image addr=0x{image.Address:X16} " +
$"size={image.Width}x{image.Height} format={image.Format} " +
$"nonzero_bytes={nonzeroBytes}/{byteCount} " +
$"nonblack_pixels={nonblackPixels}/{(ulong)image.Width * image.Height} " +
$"center={center} sample_unique={CountSampledUniquePixels(bytes, bytesPerPixel)} " +
$"hash=0x{hash:X16}");
DumpGuestImageBytes(image, bytes);
}
finally
{
_vk.UnmapMemory(_device, memory);
}
}
finally
{
_vk.DestroyBuffer(_device, buffer, null);
_vk.FreeMemory(_device, memory, null);
}
}
private static int CountSampledUniquePixels(
ReadOnlySpan<byte> bytes,
uint bytesPerPixel)
{
if (bytesPerPixel == 0)
{
return 0;
}
var unique = new HashSet<ulong>();
var stride = checked((int)bytesPerPixel * 251);
for (var offset = 0;
offset + bytesPerPixel <= bytes.Length;
offset += stride)
{
ulong hash = 14695981039346656037UL;
for (var index = 0; index < bytesPerPixel; index++)
{
hash = (hash ^ bytes[offset + index]) * 1099511628211UL;
}
unique.Add(hash);
if (unique.Count > 256)
{
return 257;
}
}
return unique.Count;
}
private static void DumpGuestImageBytes(
GuestImageResource image,
ReadOnlySpan<byte> bytes)
{
var directory =
Environment.GetEnvironmentVariable("SHARPEMU_GUEST_IMAGE_DUMP_DIR");
if (string.IsNullOrWhiteSpace(directory))
{
return;
}
Directory.CreateDirectory(directory);
var sequence = Interlocked.Increment(ref _guestImageDumpSequence);
var path = Path.Combine(
directory,
$"{sequence:D4}-0x{image.Address:X16}-{image.Width}x{image.Height}-{image.Format}.rgba");
File.WriteAllBytes(path, bytes.ToArray());
}
// Metal cannot blend into integer render targets or 32-bit-per-channel
// float targets (unsupported on Apple-family GPUs). Enabling blend on
// one makes vkCreateGraphicsPipelines fail with ErrorInitializationFailed
// (and trips a Metal "not blendable" validation assertion), so the draw
// is silently dropped. Force blend off for those; blending on an integer
// target is meaningless on real hardware anyway.
private static bool IsBlendableFormat(Format format) =>
format switch
{
Format.R8Uint or Format.R8Sint or
Format.R8G8B8A8Uint or Format.R8G8B8A8Sint or
Format.R16G16Uint or Format.R16G16Sint or
Format.R16G16B16A16Uint or Format.R16G16B16A16Sint or
Format.R32Uint or Format.R32Sint or
Format.R32G32Uint or Format.R32G32Sint or
Format.R32G32B32A32Uint or Format.R32G32B32A32Sint or
Format.R32Sfloat or
Format.R32G32Sfloat or
Format.R32G32B32A32Sfloat => false,
_ => true,
};
private static uint GetReadbackBytesPerPixel(Format format) =>
format switch
{
Format.R8Unorm or
Format.R8Uint or
Format.R8Sint => 1,
Format.R8G8Unorm or
Format.R8G8Uint or
Format.R8G8Sint => 2,
Format.R32Uint or
Format.R32Sint or
Format.R32Sfloat or
Format.B10G11R11UfloatPack32 or
Format.R16G16Uint or
Format.R16G16Sint or
Format.R16G16Sfloat or
Format.R8G8B8A8Uint or
Format.R8G8B8A8Sint or
Format.R8G8B8A8Unorm or
Format.A2R10G10B10UnormPack32 or
Format.A2B10G10R10UnormPack32 => 4,
Format.R16G16B16A16Uint or
Format.R16G16B16A16Sint or
Format.R16G16B16A16Sfloat => 8,
Format.R32G32Uint or
Format.R32G32Sint or
Format.R32G32Sfloat => 8,
Format.R32G32B32A32Uint or
Format.R32G32B32A32Sint or
Format.R32G32B32A32Sfloat => 16,
_ => 0,
};
private static long CountNonblackPixels(
ReadOnlySpan<byte> bytes,
Format format,
uint bytesPerPixel)
{
var count = 0L;
for (var offset = 0; offset < bytes.Length; offset += (int)bytesPerPixel)
{
var pixel = bytes.Slice(offset, (int)bytesPerPixel);
var hasColor = format switch
{
Format.A2R10G10B10UnormPack32 or
Format.A2B10G10R10UnormPack32 =>
(BitConverter.ToUInt32(pixel) & 0x3FFFFFFFu) != 0,
Format.R8G8B8A8Uint or
Format.R8G8B8A8Sint or
Format.R8G8B8A8Unorm =>
pixel[0] != 0 || pixel[1] != 0 || pixel[2] != 0,
Format.R16G16B16A16Uint or
Format.R16G16B16A16Sint or
Format.R16G16B16A16Sfloat =>
pixel[..6].IndexOfAnyExcept((byte)0) >= 0,
_ => pixel.IndexOfAnyExcept((byte)0) >= 0,
};
count += hasColor ? 1 : 0;
}
return count;
}
private void RecordTranslatedDraw(uint imageIndex, TranslatedDrawResources resources)
{
BeginDebugLabel(_commandBuffer, "SharpEmu swapchain draw");
RecordGlobalBufferVisibilityBarrier(
_commandBuffer,
resources,
PipelineStageFlags.VertexShaderBit |
PipelineStageFlags.FragmentShaderBit);
RecordTextureUploads(resources, PipelineStageFlags.FragmentShaderBit);
RecordStorageImagesForWrite(resources, PipelineStageFlags.FragmentShaderBit);
RecordTranslatedGraphicsPass(
resources,
_renderPass,
_framebuffers[imageIndex],
_extent);
RecordStorageImagesForRead(resources, PipelineStageFlags.FragmentShaderBit);
EndDebugLabel(_commandBuffer);
}
private void RecordTextureUploads(
TranslatedDrawResources resources,
PipelineStageFlags shaderStage)
{
foreach (var texture in resources.Textures)
{
if (texture.GuestDepth is { } depth)
{
RecordGuestDepthForSampling(depth, shaderStage);
}
if (!texture.NeedsUpload)
{
continue;
}
var toTransfer = new ImageMemoryBarrier
{
SType = StructureType.ImageMemoryBarrier,
DstAccessMask = AccessFlags.TransferWriteBit,
OldLayout = ImageLayout.Undefined,
NewLayout = ImageLayout.TransferDstOptimal,
SrcQueueFamilyIndex = Vk.QueueFamilyIgnored,
DstQueueFamilyIndex = Vk.QueueFamilyIgnored,
Image = texture.Image,
SubresourceRange = ColorSubresourceRange(),
};
_vk.CmdPipelineBarrier(
_commandBuffer,
PipelineStageFlags.TopOfPipeBit,
PipelineStageFlags.TransferBit,
0,
0,
null,
0,
null,
1,
&toTransfer);
var copyRegion = new BufferImageCopy
{
BufferRowLength = texture.RowLength > texture.Width
? texture.RowLength
: 0,
ImageSubresource = new ImageSubresourceLayers
{
AspectMask = ImageAspectFlags.ColorBit,
LayerCount = 1,
},
ImageExtent = new Extent3D(texture.Width, texture.Height, 1),
};
_vk.CmdCopyBufferToImage(
_commandBuffer,
texture.StagingBuffer,
texture.Image,
ImageLayout.TransferDstOptimal,
1,
&copyRegion);
var toShaderRead = new ImageMemoryBarrier
{
SType = StructureType.ImageMemoryBarrier,
SrcAccessMask = AccessFlags.TransferWriteBit,
DstAccessMask = AccessFlags.ShaderReadBit,
OldLayout = ImageLayout.TransferDstOptimal,
NewLayout = ImageLayout.ShaderReadOnlyOptimal,
SrcQueueFamilyIndex = Vk.QueueFamilyIgnored,
DstQueueFamilyIndex = Vk.QueueFamilyIgnored,
Image = texture.Image,
SubresourceRange = ColorSubresourceRange(),
};
_vk.CmdPipelineBarrier(
_commandBuffer,
PipelineStageFlags.TransferBit,
shaderStage,
0,
0,
null,
0,
null,
1,
&toShaderRead);
if (texture.Cached)
{
// The queue executes command buffers in submission order,
// so once this upload is recorded every later draw can
// reuse the image without restaging it.
texture.NeedsUpload = false;
}
}
}
private void RecordGuestDepthForSampling(
GuestDepthResource depth,
PipelineStageFlags shaderStage)
{
if (depth.Layout == ImageLayout.ShaderReadOnlyOptimal)
{
return;
}
if (!depth.Initialized)
{
var depthRange = new ImageSubresourceRange(
ImageAspectFlags.DepthBit,
0,
1,
0,
1);
var toTransfer = new ImageMemoryBarrier
{
SType = StructureType.ImageMemoryBarrier,
DstAccessMask = AccessFlags.TransferWriteBit,
OldLayout = depth.Layout,
NewLayout = ImageLayout.TransferDstOptimal,
SrcQueueFamilyIndex = Vk.QueueFamilyIgnored,
DstQueueFamilyIndex = Vk.QueueFamilyIgnored,
Image = depth.Image,
SubresourceRange = depthRange,
};
_vk.CmdPipelineBarrier(
_commandBuffer,
PipelineStageFlags.TopOfPipeBit,
PipelineStageFlags.TransferBit,
0,
0,
null,
0,
null,
1,
&toTransfer);
var clearValue = new ClearDepthStencilValue(depth.ClearDepth, 0);
_vk.CmdClearDepthStencilImage(
_commandBuffer,
depth.Image,
ImageLayout.TransferDstOptimal,
&clearValue,
1,
&depthRange);
depth.Initialized = true;
if (depth.InitializationSource == "none")
{
depth.InitializationSource = "sample-clear";
}
depth.Layout = ImageLayout.TransferDstOptimal;
}
var barrier = new ImageMemoryBarrier
{
SType = StructureType.ImageMemoryBarrier,
SrcAccessMask = depth.Layout == ImageLayout.TransferDstOptimal
? AccessFlags.TransferWriteBit
: AccessFlags.DepthStencilAttachmentWriteBit,
DstAccessMask = AccessFlags.ShaderReadBit,
OldLayout = depth.Layout,
NewLayout = ImageLayout.ShaderReadOnlyOptimal,
SrcQueueFamilyIndex = Vk.QueueFamilyIgnored,
DstQueueFamilyIndex = Vk.QueueFamilyIgnored,
Image = depth.Image,
SubresourceRange = new ImageSubresourceRange(
ImageAspectFlags.DepthBit,
0,
1,
0,
1),
};
_vk.CmdPipelineBarrier(
_commandBuffer,
depth.Layout == ImageLayout.TransferDstOptimal
? PipelineStageFlags.TransferBit
: PipelineStageFlags.LateFragmentTestsBit,
shaderStage,
0,
0,
null,
0,
null,
1,
&barrier);
depth.Layout = ImageLayout.ShaderReadOnlyOptimal;
}
private void RecordRenderTargetFeedbackSnapshots(
TranslatedDrawResources resources,
PipelineStageFlags shaderStage)
{
foreach (var texture in resources.Textures)
{
if (texture.FeedbackSource is not { } source)
{
continue;
}
// Initialize every destination mip to a deterministic zero.
// Render-target writes currently populate mip 0; leaving the
// remaining sampled mips undefined turns guest LOD selection
// into driver-dependent colored garbage.
var destinationToTransfer = new ImageMemoryBarrier
{
SType = StructureType.ImageMemoryBarrier,
DstAccessMask = AccessFlags.TransferWriteBit,
OldLayout = ImageLayout.Undefined,
NewLayout = ImageLayout.TransferDstOptimal,
SrcQueueFamilyIndex = Vk.QueueFamilyIgnored,
DstQueueFamilyIndex = Vk.QueueFamilyIgnored,
Image = texture.Image,
SubresourceRange = ColorSubresourceRange(0, source.MipLevels),
};
_vk.CmdPipelineBarrier(
_commandBuffer,
PipelineStageFlags.TopOfPipeBit,
PipelineStageFlags.TransferBit,
0,
0,
null,
0,
null,
1,
&destinationToTransfer);
var clearValue = new ClearColorValue(0f, 0f, 0f, 0f);
// Avoid overlapping a clear and copy on mip 0: without an
// intervening dependency two transfer writes to one
// subresource are not ordered merely because they were
// recorded in that order. Initialized sources overwrite mip
// 0 directly and clear only the otherwise undefined tail.
var clearBaseMip = source.Initialized ? 1u : 0u;
if (clearBaseMip < source.MipLevels)
{
var destinationRange = ColorSubresourceRange(
clearBaseMip,
source.MipLevels - clearBaseMip);
_vk.CmdClearColorImage(
_commandBuffer,
texture.Image,
ImageLayout.TransferDstOptimal,
&clearValue,
1,
&destinationRange);
}
if (source.Initialized)
{
var sourceToTransfer = new ImageMemoryBarrier
{
SType = StructureType.ImageMemoryBarrier,
SrcAccessMask = AccessFlags.ShaderReadBit,
DstAccessMask = AccessFlags.TransferReadBit,
OldLayout = ImageLayout.ShaderReadOnlyOptimal,
NewLayout = ImageLayout.TransferSrcOptimal,
SrcQueueFamilyIndex = Vk.QueueFamilyIgnored,
DstQueueFamilyIndex = Vk.QueueFamilyIgnored,
Image = source.Image,
// Only mip 0 has defined render-target contents.
SubresourceRange = ColorSubresourceRange(),
};
_vk.CmdPipelineBarrier(
_commandBuffer,
shaderStage |
PipelineStageFlags.ColorAttachmentOutputBit,
PipelineStageFlags.TransferBit,
0,
0,
null,
0,
null,
1,
&sourceToTransfer);
var copy = new ImageCopy
{
SrcSubresource = new ImageSubresourceLayers(
ImageAspectFlags.ColorBit,
0,
0,
1),
DstSubresource = new ImageSubresourceLayers(
ImageAspectFlags.ColorBit,
0,
0,
1),
Extent = new Extent3D(source.Width, source.Height, 1),
};
_vk.CmdCopyImage(
_commandBuffer,
source.Image,
ImageLayout.TransferSrcOptimal,
texture.Image,
ImageLayout.TransferDstOptimal,
1,
&copy);
var sourceToShaderRead = new ImageMemoryBarrier
{
SType = StructureType.ImageMemoryBarrier,
SrcAccessMask = AccessFlags.TransferReadBit,
DstAccessMask = AccessFlags.ShaderReadBit,
OldLayout = ImageLayout.TransferSrcOptimal,
NewLayout = ImageLayout.ShaderReadOnlyOptimal,
SrcQueueFamilyIndex = Vk.QueueFamilyIgnored,
DstQueueFamilyIndex = Vk.QueueFamilyIgnored,
Image = source.Image,
SubresourceRange = ColorSubresourceRange(),
};
_vk.CmdPipelineBarrier(
_commandBuffer,
PipelineStageFlags.TransferBit,
shaderStage,
0,
0,
null,
0,
null,
1,
&sourceToShaderRead);
}
var destinationToShaderRead = new ImageMemoryBarrier
{
SType = StructureType.ImageMemoryBarrier,
SrcAccessMask = AccessFlags.TransferWriteBit,
DstAccessMask = AccessFlags.ShaderReadBit,
OldLayout = ImageLayout.TransferDstOptimal,
NewLayout = ImageLayout.ShaderReadOnlyOptimal,
SrcQueueFamilyIndex = Vk.QueueFamilyIgnored,
DstQueueFamilyIndex = Vk.QueueFamilyIgnored,
Image = texture.Image,
SubresourceRange = ColorSubresourceRange(0, source.MipLevels),
};
_vk.CmdPipelineBarrier(
_commandBuffer,
PipelineStageFlags.TransferBit,
shaderStage,
0,
0,
null,
0,
null,
1,
&destinationToShaderRead);
TraceVulkanShader(
$"vk.feedback_snapshot_copy addr=0x{source.Address:X16} " +
$"size={source.Width}x{source.Height} initialized={source.Initialized}");
}
}
private void RecordDepthFeedbackSnapshots(
TranslatedDrawResources resources,
PipelineStageFlags shaderStage)
{
foreach (var texture in resources.Textures)
{
if (texture.DepthFeedbackSource is not { } source)
{
continue;
}
var depthRange = new ImageSubresourceRange(
ImageAspectFlags.DepthBit,
0,
1,
0,
1);
var destinationToTransfer = new ImageMemoryBarrier
{
SType = StructureType.ImageMemoryBarrier,
DstAccessMask = AccessFlags.TransferWriteBit,
OldLayout = ImageLayout.Undefined,
NewLayout = ImageLayout.TransferDstOptimal,
SrcQueueFamilyIndex = Vk.QueueFamilyIgnored,
DstQueueFamilyIndex = Vk.QueueFamilyIgnored,
Image = texture.Image,
SubresourceRange = depthRange,
};
_vk.CmdPipelineBarrier(
_commandBuffer,
PipelineStageFlags.TopOfPipeBit,
PipelineStageFlags.TransferBit,
0,
0,
null,
0,
null,
1,
&destinationToTransfer);
if (source.Initialized)
{
var sourceToTransfer = new ImageMemoryBarrier
{
SType = StructureType.ImageMemoryBarrier,
SrcAccessMask = source.Layout == ImageLayout.ShaderReadOnlyOptimal
? AccessFlags.ShaderReadBit
: AccessFlags.DepthStencilAttachmentWriteBit,
DstAccessMask = AccessFlags.TransferReadBit,
OldLayout = source.Layout,
NewLayout = ImageLayout.TransferSrcOptimal,
SrcQueueFamilyIndex = Vk.QueueFamilyIgnored,
DstQueueFamilyIndex = Vk.QueueFamilyIgnored,
Image = source.Image,
SubresourceRange = depthRange,
};
_vk.CmdPipelineBarrier(
_commandBuffer,
shaderStage |
PipelineStageFlags.EarlyFragmentTestsBit |
PipelineStageFlags.LateFragmentTestsBit,
PipelineStageFlags.TransferBit,
0,
0,
null,
0,
null,
1,
&sourceToTransfer);
var copy = new ImageCopy
{
SrcSubresource = new ImageSubresourceLayers(
ImageAspectFlags.DepthBit,
0,
0,
1),
DstSubresource = new ImageSubresourceLayers(
ImageAspectFlags.DepthBit,
0,
0,
1),
Extent = new Extent3D(source.Width, source.Height, 1),
};
_vk.CmdCopyImage(
_commandBuffer,
source.Image,
ImageLayout.TransferSrcOptimal,
texture.Image,
ImageLayout.TransferDstOptimal,
1,
&copy);
var sourceToAttachment = new ImageMemoryBarrier
{
SType = StructureType.ImageMemoryBarrier,
SrcAccessMask = AccessFlags.TransferReadBit,
DstAccessMask =
AccessFlags.DepthStencilAttachmentReadBit |
AccessFlags.DepthStencilAttachmentWriteBit,
OldLayout = ImageLayout.TransferSrcOptimal,
NewLayout = ImageLayout.DepthStencilAttachmentOptimal,
SrcQueueFamilyIndex = Vk.QueueFamilyIgnored,
DstQueueFamilyIndex = Vk.QueueFamilyIgnored,
Image = source.Image,
SubresourceRange = depthRange,
};
_vk.CmdPipelineBarrier(
_commandBuffer,
PipelineStageFlags.TransferBit,
PipelineStageFlags.EarlyFragmentTestsBit |
PipelineStageFlags.LateFragmentTestsBit,
0,
0,
null,
0,
null,
1,
&sourceToAttachment);
source.Layout = ImageLayout.DepthStencilAttachmentOptimal;
}
else
{
var clearValue = new ClearDepthStencilValue(source.ClearDepth, 0);
_vk.CmdClearDepthStencilImage(
_commandBuffer,
texture.Image,
ImageLayout.TransferDstOptimal,
&clearValue,
1,
&depthRange);
}
var destinationToShader = new ImageMemoryBarrier
{
SType = StructureType.ImageMemoryBarrier,
SrcAccessMask = AccessFlags.TransferWriteBit,
DstAccessMask = AccessFlags.ShaderReadBit,
OldLayout = ImageLayout.TransferDstOptimal,
NewLayout = ImageLayout.ShaderReadOnlyOptimal,
SrcQueueFamilyIndex = Vk.QueueFamilyIgnored,
DstQueueFamilyIndex = Vk.QueueFamilyIgnored,
Image = texture.Image,
SubresourceRange = depthRange,
};
_vk.CmdPipelineBarrier(
_commandBuffer,
PipelineStageFlags.TransferBit,
shaderStage,
0,
0,
null,
0,
null,
1,
&destinationToShader);
}
}
private void RecordStorageImagesForWrite(
TranslatedDrawResources resources,
PipelineStageFlags shaderStage)
{
var transitioned = new HashSet<GuestImageResource>();
foreach (var texture in resources.Textures)
{
if (!texture.IsStorage ||
texture.GuestImage is not { } guestImage ||
!transitioned.Add(guestImage))
{
continue;
}
var barrier = new ImageMemoryBarrier
{
SType = StructureType.ImageMemoryBarrier,
SrcAccessMask =
guestImage.Initialized || guestImage.InitialUploadPending
? AccessFlags.ShaderReadBit
: 0,
DstAccessMask =
AccessFlags.ShaderReadBit |
AccessFlags.ShaderWriteBit,
OldLayout =
guestImage.Initialized || guestImage.InitialUploadPending
? ImageLayout.ShaderReadOnlyOptimal
: ImageLayout.Undefined,
NewLayout = ImageLayout.General,
SrcQueueFamilyIndex = Vk.QueueFamilyIgnored,
DstQueueFamilyIndex = Vk.QueueFamilyIgnored,
Image = guestImage.Image,
SubresourceRange = ColorSubresourceRange(0, guestImage.MipLevels),
};
_vk.CmdPipelineBarrier(
_commandBuffer,
guestImage.Initialized || guestImage.InitialUploadPending
? shaderStage
: PipelineStageFlags.TopOfPipeBit,
shaderStage,
0,
0,
null,
0,
null,
1,
&barrier);
}
}
private void RecordStorageImagesForRead(
TranslatedDrawResources resources,
PipelineStageFlags shaderStage)
{
var transitioned = new HashSet<GuestImageResource>();
foreach (var texture in resources.Textures)
{
if (!texture.IsStorage ||
texture.GuestImage is not { } guestImage ||
!transitioned.Add(guestImage))
{
continue;
}
var barrier = new ImageMemoryBarrier
{
SType = StructureType.ImageMemoryBarrier,
SrcAccessMask =
AccessFlags.ShaderReadBit |
AccessFlags.ShaderWriteBit,
DstAccessMask = AccessFlags.ShaderReadBit,
OldLayout = ImageLayout.General,
NewLayout = ImageLayout.ShaderReadOnlyOptimal,
SrcQueueFamilyIndex = Vk.QueueFamilyIgnored,
DstQueueFamilyIndex = Vk.QueueFamilyIgnored,
Image = guestImage.Image,
SubresourceRange = ColorSubresourceRange(0, guestImage.MipLevels),
};
_vk.CmdPipelineBarrier(
_commandBuffer,
shaderStage,
shaderStage,
0,
0,
null,
0,
null,
1,
&barrier);
}
}
private void MarkStorageImagesInitialized(
TranslatedDrawResources resources,
bool traceContents = true)
{
List<GuestImageResource>? traceImages = null;
lock (_gate)
{
foreach (var texture in resources.Textures)
{
if (!texture.IsStorage ||
texture.Address == 0 ||
texture.GuestImage is not { } guestImage)
{
continue;
}
guestImage.Initialized = true;
guestImage.InitialUploadPending = false;
if (guestImage.GuestFormat != 0)
{
_availableGuestImages[texture.Address] = guestImage.GuestFormat;
}
if (traceContents &&
ShouldTraceGuestImageContents(guestImage))
{
traceImages ??= [];
traceImages.Add(guestImage);
}
}
}
if (traceImages is null)
{
return;
}
foreach (var image in traceImages)
{
TraceGuestImageContents(image);
}
}
private static void MarkSampledImagesInitialized(
TranslatedDrawResources resources)
{
lock (_gate)
{
foreach (var texture in resources.Textures)
{
if (!texture.NeedsUpload ||
texture.IsStorage ||
texture.Address == 0 ||
texture.GuestImage is not { } guestImage)
{
continue;
}
guestImage.Initialized = true;
guestImage.InitialUploadPending = false;
if (texture.UpdatesCpuContent)
{
guestImage.CpuContentFingerprint = texture.CpuContentFingerprint;
}
}
}
}
private bool ShouldTraceGuestImageContents(
GuestImageResource image,
ulong shaderAddress = 0)
{
if (image.Address == 0)
{
return false;
}
if (_traceGuestImageShaderFilterEnabled &&
!AddressListContains(
"SHARPEMU_TRACE_GUEST_IMAGE_SHADER_ADDRS",
shaderAddress))
{
return false;
}
if ((_traceGuestImageWidth > 0 && image.Width != _traceGuestImageWidth) ||
(_traceGuestImageHeight > 0 && image.Height != _traceGuestImageHeight))
{
return false;
}
if (!string.IsNullOrWhiteSpace(_traceGuestImageFormat) &&
(!Enum.TryParse<Format>(
_traceGuestImageFormat,
ignoreCase: true,
out var expectedFormat) ||
image.Format != expectedFormat))
{
return false;
}
var addressMatched = ShouldTraceGuestImageAddressForDiagnostics(image.Address);
if (addressMatched && _traceGuestImageOccurrence > 0)
{
var count = _guestImageTraceCounts.TryGetValue(image.Address, out var previous)
? previous + 1
: 1;
_guestImageTraceCounts[image.Address] = count;
return count == _traceGuestImageOccurrence;
}
var broadTrace =
ShouldTraceGuestImageContentsForDiagnostics() &&
image.Width >= 1280 &&
image.Height >= 720;
if (GuestImageTraceInterval() is { } interval)
{
var addressFilter = Environment.GetEnvironmentVariable(
"SHARPEMU_TRACE_GUEST_IMAGE_ADDRS");
if (!string.IsNullOrWhiteSpace(addressFilter) && !addressMatched)
{
return false;
}
if (image.Width < 1280 || image.Height < 720)
{
return false;
}
_globalGuestImageDrawCount++;
if (_globalGuestImageDrawCount < GuestImageTraceStartAfter() ||
_intervalReadbackCount > 3000)
{
return false;
}
var count = _guestImageTraceCounts.TryGetValue(image.Address, out var previous)
? previous + 1
: 1;
_guestImageTraceCounts[image.Address] = count;
return count % interval == 0;
}
return (addressMatched || broadTrace) &&
_tracedGuestImageContents.Add(image.Address);
}
private readonly Dictionary<ulong, long> _guestImageTraceCounts = new();
private long _globalGuestImageDrawCount;
private long _intervalReadbackCount;
private static long? _cachedGuestImageTraceInterval = long.MinValue;
private static long _cachedGuestImageTraceStartAfter;
// SHARPEMU_TRACE_GUEST_IMAGES=every:N[@M] — read back 1280x720+ guest
// images every Nth draw into each, starting after M total such draws.
private static long? GuestImageTraceInterval()
{
if (_cachedGuestImageTraceInterval == long.MinValue)
{
var mode = Environment.GetEnvironmentVariable("SHARPEMU_TRACE_GUEST_IMAGES");
long? interval = null;
if (mode is not null && mode.StartsWith("every:", StringComparison.Ordinal))
{
var spec = mode["every:".Length..];
var at = spec.IndexOf('@');
var intervalText = at < 0 ? spec : spec[..at];
if (long.TryParse(intervalText, out var parsed) && parsed > 0)
{
interval = parsed;
}
if (at >= 0 && long.TryParse(spec[(at + 1)..], out var after) && after > 0)
{
_cachedGuestImageTraceStartAfter = after;
}
}
_cachedGuestImageTraceInterval = interval;
}
return _cachedGuestImageTraceInterval;
}
private static long GuestImageTraceStartAfter()
{
_ = GuestImageTraceInterval();
return _cachedGuestImageTraceStartAfter;
}
// Diagnostics toggles are read once: these run per draw / per cached
// texture hit, and env lookups plus string parsing are far too
// expensive there (and non-trivially so under Rosetta 2).
private static readonly bool _vulkanValidationEnabled =
string.Equals(
Environment.GetEnvironmentVariable("SHARPEMU_VK_VALIDATION"),
"1",
StringComparison.Ordinal);
// Object names and command labels are useful in RenderDoc and validation
// captures, but formatting them and calling the debug-utils driver hooks
// for every draw is measurable overhead in normal gameplay.
private static readonly bool _vulkanDebugUtilsEnabled =
_vulkanValidationEnabled ||
string.Equals(
Environment.GetEnvironmentVariable("SHARPEMU_VK_DEBUG_LABELS"),
"1",
StringComparison.Ordinal);
private static readonly string? _traceGuestImagesMode =
Environment.GetEnvironmentVariable("SHARPEMU_TRACE_GUEST_IMAGES");
private static readonly bool _traceGuestImagesEnabled =
string.Equals(_traceGuestImagesMode, "1", StringComparison.Ordinal);
private static readonly bool _tracePresentedGuestImagesEnabled =
_traceGuestImagesEnabled ||
string.Equals(_traceGuestImagesMode, "present", StringComparison.OrdinalIgnoreCase);
private static readonly bool _traceVulkanResourcesEnabled =
string.Equals(
Environment.GetEnvironmentVariable("SHARPEMU_LOG_VK_RESOURCES"),
"1",
StringComparison.Ordinal);
private static readonly bool _traceVulkanShaderEnabled =
string.Equals(
Environment.GetEnvironmentVariable("SHARPEMU_LOG_AGC"),
"1",
StringComparison.Ordinal) ||
string.Equals(
Environment.GetEnvironmentVariable("SHARPEMU_LOG_AGC_SHADER"),
"1",
StringComparison.Ordinal);
private static readonly bool _traceDepthInitialization =
string.Equals(
Environment.GetEnvironmentVariable("SHARPEMU_TRACE_DEPTH_INIT"),
"1",
StringComparison.Ordinal);
private static readonly long _traceGuestImageOccurrence =
long.TryParse(
Environment.GetEnvironmentVariable("SHARPEMU_TRACE_GUEST_IMAGE_OCCURRENCE"),
out var traceGuestImageOccurrence) &&
traceGuestImageOccurrence > 0
? traceGuestImageOccurrence
: 0;
private static readonly uint _traceGuestImageWidth =
uint.TryParse(
Environment.GetEnvironmentVariable("SHARPEMU_TRACE_GUEST_IMAGE_WIDTH"),
out var traceGuestImageWidth)
? traceGuestImageWidth
: 0;
private static readonly uint _traceGuestImageHeight =
uint.TryParse(
Environment.GetEnvironmentVariable("SHARPEMU_TRACE_GUEST_IMAGE_HEIGHT"),
out var traceGuestImageHeight)
? traceGuestImageHeight
: 0;
private static readonly string? _traceGuestImageFormat =
Environment.GetEnvironmentVariable("SHARPEMU_TRACE_GUEST_IMAGE_FORMAT");
private static readonly long _tracePresentedGuestImageOccurrence =
long.TryParse(
Environment.GetEnvironmentVariable(
"SHARPEMU_TRACE_PRESENTED_GUEST_IMAGE_OCCURRENCE"),
out var tracePresentedGuestImageOccurrence) &&
tracePresentedGuestImageOccurrence > 0
? tracePresentedGuestImageOccurrence
: 0;
private static readonly bool _traceGuestImageShaderFilterEnabled =
!string.IsNullOrWhiteSpace(
Environment.GetEnvironmentVariable(
"SHARPEMU_TRACE_GUEST_IMAGE_SHADER_ADDRS"));
private static readonly bool _traceGuestImageAddressFilterEnabled =
!string.IsNullOrWhiteSpace(
Environment.GetEnvironmentVariable(
"SHARPEMU_TRACE_GUEST_IMAGE_ADDRS"));
private static readonly bool _forceFullscreenPipeline =
Environment.GetEnvironmentVariable("SHARPEMU_FORCE_FULLSCREEN_PIPELINE") == "1";
private static readonly bool _forceFullscreenVertex =
Environment.GetEnvironmentVariable("SHARPEMU_FORCE_FULLSCREEN_VERTEX") == "1";
private static readonly bool _forceTitleFullscreenVertex =
Environment.GetEnvironmentVariable("SHARPEMU_FORCE_TITLE_FULLSCREEN_VERTEX") == "1";
private static readonly bool _forceDefaultRasterState =
Environment.GetEnvironmentVariable("SHARPEMU_FORCE_DEFAULT_RASTER_STATE") == "1";
private static readonly bool _forceTitleDefaultRasterState =
Environment.GetEnvironmentVariable("SHARPEMU_FORCE_TITLE_DEFAULT_RASTER_STATE") == "1";
private static readonly bool _forceTitleSolidFragment =
Environment.GetEnvironmentVariable("SHARPEMU_FORCE_TITLE_SOLID_FRAGMENT") == "1";
private static readonly bool _forceSolidFragment =
Environment.GetEnvironmentVariable("SHARPEMU_FORCE_SOLID_FRAGMENT") == "1";
private static readonly uint? _forceAttributeFragmentLocation =
uint.TryParse(
Environment.GetEnvironmentVariable("SHARPEMU_FORCE_ATTRIBUTE_FRAGMENT"),
out var forceAttributeFragmentLocation)
? forceAttributeFragmentLocation
: null;
private static readonly string? _fixedFragmentDumpPath =
Environment.GetEnvironmentVariable("SHARPEMU_DUMP_FIXED_SOLID_FRAGMENT");
private static readonly bool _forceTitleDefaultBlend =
Environment.GetEnvironmentVariable("SHARPEMU_FORCE_TITLE_DEFAULT_BLEND") == "1";
private static readonly bool _forceTitleDefaultViewportScissor =
Environment.GetEnvironmentVariable("SHARPEMU_FORCE_TITLE_DEFAULT_VIEWPORT_SCISSOR") == "1";
private static readonly bool _forceTitleDisableCull =
Environment.GetEnvironmentVariable("SHARPEMU_FORCE_TITLE_DISABLE_CULL") == "1";
private static readonly bool _forceTitleDisableDepth =
Environment.GetEnvironmentVariable("SHARPEMU_FORCE_TITLE_DISABLE_DEPTH") == "1";
private static readonly bool _traceTitleState =
Environment.GetEnvironmentVariable("SHARPEMU_TRACE_TITLE_STATE") == "1";
private static readonly bool _forceTitleVertexColorWhite =
Environment.GetEnvironmentVariable("SHARPEMU_FORCE_TITLE_VERTEX_COLOR_WHITE") == "1";
private static readonly bool _chunkedDrawsEnabled =
Environment.GetEnvironmentVariable("SHARPEMU_ENABLE_CHUNKED_DRAWS") == "1";
private static readonly string? _traceGuestWritesMode =
Environment.GetEnvironmentVariable("SHARPEMU_TRACE_GUEST_WRITES");
private static readonly long _traceGuestWriteOrdinal =
long.TryParse(
Environment.GetEnvironmentVariable("SHARPEMU_TRACE_GUEST_WRITE_ORDINAL"),
out var traceGuestWriteOrdinal)
? traceGuestWriteOrdinal
: 0;
private static readonly long _traceLargeGuestWriteOrdinal =
ParseTraceLargeGuestWriteOrdinal(_traceGuestWritesMode);
private static readonly int _tracePixelSpirvBytes =
int.TryParse(
Environment.GetEnvironmentVariable("SHARPEMU_TRACE_PIXEL_SPIRV_BYTES"),
out var tracePixelSpirvBytes)
? tracePixelSpirvBytes
: 0;
private static readonly int _tracePixelSpirvOccurrence =
int.TryParse(
Environment.GetEnvironmentVariable("SHARPEMU_TRACE_PIXEL_SPIRV_OCCURRENCE"),
out var tracePixelSpirvOccurrence)
? Math.Max(tracePixelSpirvOccurrence, 1)
: 1;
private static readonly bool _traceTitleDrawEnabled =
Environment.GetEnvironmentVariable("SHARPEMU_TRACE_TITLE_DRAW") == "1";
private static readonly System.Collections.Concurrent.ConcurrentDictionary<
string,
(bool Wildcard, ulong[] Addresses)> _cachedAddressLists = new();
private static long ParseTraceLargeGuestWriteOrdinal(string? mode)
{
return mode is not null &&
mode.StartsWith("large@", StringComparison.Ordinal) &&
long.TryParse(mode.AsSpan("large@".Length), out var ordinal) &&
ordinal > 0
? ordinal
: 0;
}
private static bool ShouldTraceGuestImageContentsForDiagnostics() =>
_traceGuestImagesEnabled;
private static bool ShouldTraceGuestImageAddressForDiagnostics(ulong address)
{
return AddressListContains(
"SHARPEMU_TRACE_GUEST_IMAGE_ADDRS",
address);
}
private static bool ShouldTraceGuestImageWriteForDiagnostics(ulong address)
{
return AddressListContains(
"SHARPEMU_TRACE_GUEST_WRITES",
address);
}
private static bool AddressListContains(
string environmentVariable,
ulong address)
{
var (wildcard, addresses) = _cachedAddressLists.GetOrAdd(
environmentVariable,
static name => ParseAddressList(Environment.GetEnvironmentVariable(name)));
return wildcard || Array.IndexOf(addresses, address) >= 0;
}
private static (bool Wildcard, ulong[] Addresses) ParseAddressList(string? addresses)
{
if (string.IsNullOrWhiteSpace(addresses))
{
return (false, []);
}
var parsedAddresses = new List<ulong>();
foreach (var token in addresses.Split(
[',', ';', ' ', '\t'],
StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries))
{
if (token == "*")
{
return (true, []);
}
var span = token.AsSpan();
if (span.StartsWith("0x", StringComparison.OrdinalIgnoreCase))
{
span = span[2..];
}
if (ulong.TryParse(
span,
System.Globalization.NumberStyles.HexNumber,
System.Globalization.CultureInfo.InvariantCulture,
out var parsed))
{
parsedAddresses.Add(parsed);
}
}
return (false, parsedAddresses.ToArray());
}
private static bool ShouldTracePresentedGuestImageContentsForDiagnostics() =>
_tracePresentedGuestImagesEnabled;
private bool ShouldTraceAddressedPresentedGuestImage(GuestImageResource image)
{
if (!AddressListContains(
"SHARPEMU_TRACE_PRESENTED_GUEST_IMAGE_ADDRS",
image.Address))
{
return false;
}
var count = _presentedGuestImageTraceCounts.TryGetValue(
image.Address,
out var previous)
? previous + 1
: 1;
_presentedGuestImageTraceCounts[image.Address] = count;
return _tracePresentedGuestImageOccurrence == 0
? count == 1
: count == _tracePresentedGuestImageOccurrence;
}
private static bool ShouldTraceVulkanResources() =>
_traceVulkanResourcesEnabled;
private void RecordTranslatedGraphicsPass(
TranslatedDrawResources resources,
RenderPass renderPass,
Framebuffer framebuffer,
Extent2D extent)
{
BeginTranslatedRenderPass(renderPass, framebuffer, extent);
RecordTranslatedDrawInPass(resources, extent);
_vk.CmdEndRenderPass(_commandBuffer);
}
private void BeginTranslatedRenderPass(
RenderPass renderPass,
Framebuffer framebuffer,
Extent2D extent,
int colorAttachmentCount = 1,
bool hasDepthAttachment = false,
float clearDepth = 1f)
{
colorAttachmentCount = Math.Max(colorAttachmentCount, 1);
var clearValueCount = colorAttachmentCount + (hasDepthAttachment ? 1 : 0);
var clearValues = stackalloc ClearValue[clearValueCount];
for (var index = 0; index < colorAttachmentCount; index++)
{
clearValues[index] = default;
}
// Reverse-Z is not assumed; clear depth to 1.0 (far) so a standard
// LessOrEqual/Less test keeps the nearest fragment.
if (hasDepthAttachment)
{
clearValues[colorAttachmentCount] = new ClearValue
{
DepthStencil = new ClearDepthStencilValue(clearDepth, 0),
};
}
var renderPassInfo = new RenderPassBeginInfo
{
SType = StructureType.RenderPassBeginInfo,
RenderPass = renderPass,
Framebuffer = framebuffer,
RenderArea = new Rect2D(new Offset2D(0, 0), extent),
ClearValueCount = (uint)clearValueCount,
PClearValues = clearValues,
};
_vk.CmdBeginRenderPass(
_commandBuffer,
&renderPassInfo,
SubpassContents.Inline);
}
private void RecordTranslatedDrawInPass(
TranslatedDrawResources resources,
Extent2D extent)
{
_vk.CmdBindPipeline(
_commandBuffer,
PipelineBindPoint.Graphics,
resources.Pipeline);
if (resources.DescriptorSet.Handle != 0)
{
var descriptorSet = resources.DescriptorSet;
_vk.CmdBindDescriptorSets(
_commandBuffer,
PipelineBindPoint.Graphics,
resources.PipelineLayout,
0,
1,
&descriptorSet,
0,
null);
}
var drawScissor = ClampScissor(resources.Scissor, extent);
if (drawScissor.Width == 0 || drawScissor.Height == 0)
{
return;
}
var drawViewport = ClampViewport(resources.Viewport, extent);
if (ViewportDebugEpsilon != 0f)
{
drawViewport.X += ViewportDebugEpsilon;
drawViewport.Y += ViewportDebugEpsilon;
}
_vk.CmdSetViewport(_commandBuffer, 0, 1, &drawViewport);
if (resources.VertexBuffers.Length != 0)
{
var buffers = stackalloc VkBuffer[resources.VertexBuffers.Length];
var offsets = stackalloc ulong[resources.VertexBuffers.Length];
for (var index = 0; index < resources.VertexBuffers.Length; index++)
{
buffers[index] = resources.VertexBuffers[index].Buffer;
offsets[index] = GetVertexBindingOffset(resources.VertexBuffers[index]);
}
_vk.CmdBindVertexBuffers(
_commandBuffer,
0,
(uint)resources.VertexBuffers.Length,
buffers,
offsets);
}
// Replaying a full-screen primitive once per 512x512 scissor tile
// multiplies an ordinary 4K composite into 32 complete draws. On
// MoltenVK this starves the render thread and makes the guest fall
// behind its own flip queue. Vulkan clips a normal fullscreen draw
// efficiently; keep tiling only as an explicit driver diagnostic.
var maxPixelsPerDraw = _chunkedDrawsEnabled
? 512u * 512u
: uint.MaxValue;
var rowsPerDraw = Math.Max(
1u,
Math.Min(drawScissor.Height, maxPixelsPerDraw / Math.Max(drawScissor.Width, 1u)));
var drawCount = 0u;
for (var y = 0u; y < drawScissor.Height; y += rowsPerDraw)
{
var scissor = new Rect2D(
new Offset2D(
drawScissor.X,
checked(drawScissor.Y + (int)y)),
new Extent2D(
drawScissor.Width,
Math.Min(rowsPerDraw, drawScissor.Height - y)));
_vk.CmdSetScissor(_commandBuffer, 0, 1, &scissor);
if (resources.IndexBuffer.Handle != 0)
{
_vk.CmdBindIndexBuffer(
_commandBuffer,
resources.IndexBuffer,
0,
resources.Index32Bit ? IndexType.Uint32 : IndexType.Uint16);
_vk.CmdDrawIndexed(
_commandBuffer,
resources.VertexCount,
resources.InstanceCount,
0,
0,
0);
}
else
{
_vk.CmdDraw(
_commandBuffer,
resources.VertexCount,
resources.InstanceCount,
0,
0);
}
drawCount++;
}
if (drawCount > 1)
{
TraceVulkanShader(
$"vk.graphics_chunked target={extent.Width}x{extent.Height} " +
$"draws={drawCount} rows={rowsPerDraw} " +
$"scissor={drawScissor.X},{drawScissor.Y},{drawScissor.Width}x{drawScissor.Height} " +
$"viewport={drawViewport.X:0.###},{drawViewport.Y:0.###}," +
$"{drawViewport.Width:0.###}x{drawViewport.Height:0.###} " +
$"name={resources.DebugName}");
}
}
private void DestroyTranslatedDrawResources(TranslatedDrawResources resources)
{
if (resources.TransientFramebuffer.Handle != 0)
{
_vk.DestroyFramebuffer(_device, resources.TransientFramebuffer, null);
}
if (resources.TransientRenderPass.Handle != 0)
{
_vk.DestroyRenderPass(_device, resources.TransientRenderPass, null);
}
foreach (var texture in resources.Textures)
{
if (texture is null || texture.Cached)
{
continue;
}
if (texture.OwnsStorage && texture.View.Handle != 0)
{
_vk.DestroyImageView(_device, texture.View, null);
}
if (texture.OwnsStorage && texture.Image.Handle != 0)
{
_vk.DestroyImage(_device, texture.Image, null);
}
if (texture.OwnsStorage && texture.ImageMemory.Handle != 0)
{
_vk.FreeMemory(_device, texture.ImageMemory, null);
}
if (texture.StagingBuffer.Handle != 0)
{
_vk.DestroyBuffer(_device, texture.StagingBuffer, null);
}
if (texture.StagingMemory.Handle != 0)
{
_vk.FreeMemory(_device, texture.StagingMemory, null);
}
if (texture.NeedsUpload &&
texture.GuestImage is { Initialized: false } guestImage)
{
guestImage.InitialUploadPending = false;
}
}
foreach (var globalBuffer in resources.GlobalMemoryBuffers)
{
if (globalBuffer is null || globalBuffer.Allocation is not null)
{
continue;
}
RecycleHostBuffer(globalBuffer.Buffer, globalBuffer.Memory);
}
foreach (var vertexBuffer in resources.VertexBuffers)
{
if (vertexBuffer is null || !vertexBuffer.OwnsBuffer)
{
continue;
}
RecycleHostBuffer(vertexBuffer.Buffer, vertexBuffer.Memory);
}
RecycleHostBuffer(resources.IndexBuffer, resources.IndexMemory);
if (!resources.PipelineCached && resources.Pipeline.Handle != 0)
{
_vk.DestroyPipeline(_device, resources.Pipeline, null);
}
if (resources.DescriptorPool.Handle != 0)
{
if (_recycledDescriptorPools.Count < 256)
{
_recycledDescriptorPools.Push(resources.DescriptorPool);
}
else
{
_vk.DestroyDescriptorPool(_device, resources.DescriptorPool, null);
}
}
if (!resources.DescriptorLayoutCached &&
resources.PipelineLayout.Handle != 0)
{
_vk.DestroyPipelineLayout(_device, resources.PipelineLayout, null);
}
if (!resources.DescriptorLayoutCached &&
resources.DescriptorSetLayout.Handle != 0)
{
_vk.DestroyDescriptorSetLayout(_device, resources.DescriptorSetLayout, null);
}
}
private void RecordUpload(uint imageIndex)
{
var oldLayout = _imageInitialized[imageIndex]
? ImageLayout.PresentSrcKhr
: ImageLayout.Undefined;
var toTransfer = new ImageMemoryBarrier
{
SType = StructureType.ImageMemoryBarrier,
SrcAccessMask = _imageInitialized[imageIndex] ? AccessFlags.MemoryReadBit : 0,
DstAccessMask = AccessFlags.TransferWriteBit,
OldLayout = oldLayout,
NewLayout = ImageLayout.TransferDstOptimal,
SrcQueueFamilyIndex = Vk.QueueFamilyIgnored,
DstQueueFamilyIndex = Vk.QueueFamilyIgnored,
Image = _swapchainImages[imageIndex],
SubresourceRange = ColorSubresourceRange(),
};
_vk.CmdPipelineBarrier(
_commandBuffer,
_imageInitialized[imageIndex]
? PipelineStageFlags.BottomOfPipeBit
: PipelineStageFlags.TopOfPipeBit,
PipelineStageFlags.TransferBit,
0,
0,
null,
0,
null,
1,
&toTransfer);
var copyRegion = new BufferImageCopy
{
ImageSubresource = new ImageSubresourceLayers
{
AspectMask = ImageAspectFlags.ColorBit,
LayerCount = 1,
},
ImageExtent = new Extent3D(_extent.Width, _extent.Height, 1),
};
_vk.CmdCopyBufferToImage(
_commandBuffer,
_stagingBuffer,
_swapchainImages[imageIndex],
ImageLayout.TransferDstOptimal,
1,
&copyRegion);
var toPresent = new ImageMemoryBarrier
{
SType = StructureType.ImageMemoryBarrier,
SrcAccessMask = AccessFlags.TransferWriteBit,
DstAccessMask = AccessFlags.MemoryReadBit,
OldLayout = ImageLayout.TransferDstOptimal,
NewLayout = ImageLayout.PresentSrcKhr,
SrcQueueFamilyIndex = Vk.QueueFamilyIgnored,
DstQueueFamilyIndex = Vk.QueueFamilyIgnored,
Image = _swapchainImages[imageIndex],
SubresourceRange = ColorSubresourceRange(),
};
_vk.CmdPipelineBarrier(
_commandBuffer,
PipelineStageFlags.TransferBit,
PipelineStageFlags.BottomOfPipeBit,
0,
0,
null,
0,
null,
1,
&toPresent);
}
private void RecordGuestImageBlit(
uint imageIndex,
GuestImageResource source)
{
var presentedCount = Interlocked.Increment(ref _presentedSwapchainCount);
var periodicDumpInterval = SwapchainDumpInterval();
var traceDestination =
ShouldTracePresentedGuestImageContentsForDiagnostics() &&
(!_tracedPresentedSwapchain ||
periodicDumpInterval > 0 && presentedCount % periodicDumpInterval == 0);
_tracedPresentedSwapchain |= traceDestination;
BeginDebugLabel(
_commandBuffer,
$"SharpEmu present image 0x{source.Address:X16}");
var sourceToTransfer = new ImageMemoryBarrier
{
SType = StructureType.ImageMemoryBarrier,
// An offscreen target is last written as a color attachment,
// then put in ShaderReadOnlyOptimal for later sampling. A
// layout-only handoff to ShaderRead does not make that write
// visible to this transfer when no shader sample occurs in
// between. NVIDIA's Linux driver exposed the resulting stale
// (usually black) image while Windows drivers happened to
// tolerate it. Include all preceding writes before blitting
// the image into the swapchain.
SrcAccessMask = AccessFlags.MemoryWriteBit | AccessFlags.ShaderReadBit,
DstAccessMask = AccessFlags.TransferReadBit,
OldLayout = ImageLayout.ShaderReadOnlyOptimal,
NewLayout = ImageLayout.TransferSrcOptimal,
SrcQueueFamilyIndex = Vk.QueueFamilyIgnored,
DstQueueFamilyIndex = Vk.QueueFamilyIgnored,
Image = source.Image,
SubresourceRange = ColorSubresourceRange(),
};
var destinationToTransfer = new ImageMemoryBarrier
{
SType = StructureType.ImageMemoryBarrier,
SrcAccessMask = _imageInitialized[imageIndex]
? AccessFlags.MemoryReadBit
: 0,
DstAccessMask = AccessFlags.TransferWriteBit,
OldLayout = _imageInitialized[imageIndex]
? ImageLayout.PresentSrcKhr
: ImageLayout.Undefined,
NewLayout = ImageLayout.TransferDstOptimal,
SrcQueueFamilyIndex = Vk.QueueFamilyIgnored,
DstQueueFamilyIndex = Vk.QueueFamilyIgnored,
Image = _swapchainImages[imageIndex],
SubresourceRange = ColorSubresourceRange(),
};
var barriers = stackalloc ImageMemoryBarrier[2];
barriers[0] = sourceToTransfer;
barriers[1] = destinationToTransfer;
_vk.CmdPipelineBarrier(
_commandBuffer,
PipelineStageFlags.AllCommandsBit,
PipelineStageFlags.TransferBit,
0,
0,
null,
0,
null,
2,
barriers);
var sourceX = 0u;
var sourceY = 0u;
var sourceWidth = source.Width;
var sourceHeight = source.Height;
if (_hostSurface is not null)
{
// The embedded GUI fills its game surface like CSS
// object-fit: cover. Preserve the guest aspect ratio and crop
// only the excess instead of distorting every video frame.
var sourceIsWider = (ulong)sourceWidth * _extent.Height >
(ulong)_extent.Width * sourceHeight;
if (sourceIsWider)
{
sourceWidth = Math.Max(1u, (uint)((ulong)sourceHeight * _extent.Width / _extent.Height));
sourceX = (source.Width - sourceWidth) / 2;
}
else
{
sourceHeight = Math.Max(1u, (uint)((ulong)sourceWidth * _extent.Height / _extent.Width));
sourceY = (source.Height - sourceHeight) / 2;
}
}
var sourceOffsets = new ImageBlit.SrcOffsetsBuffer
{
Element0 = new Offset3D(checked((int)sourceX), checked((int)sourceY), 0),
Element1 = new Offset3D(
checked((int)(sourceX + sourceWidth)),
checked((int)(sourceY + sourceHeight)),
1),
};
var destinationOffsets = new ImageBlit.DstOffsetsBuffer
{
Element0 = new Offset3D(0, 0, 0),
Element1 = new Offset3D(
checked((int)_extent.Width),
checked((int)_extent.Height),
1),
};
var region = new ImageBlit
{
SrcSubresource = new ImageSubresourceLayers(
ImageAspectFlags.ColorBit,
0,
0,
1),
SrcOffsets = sourceOffsets,
DstSubresource = new ImageSubresourceLayers(
ImageAspectFlags.ColorBit,
0,
0,
1),
DstOffsets = destinationOffsets,
};
// Nearest keeps integer upscales pixel-crisp, but any fractional
// scale (e.g. a 3840x2160 guest frame into a 2560x1440 swapchain)
// must blend neighbours or it silently drops every Nth source
// row/column, which shreds 1-2px features in the guest frame.
var isIntegerUpscale =
sourceWidth != 0 && sourceHeight != 0 &&
_extent.Width >= sourceWidth && _extent.Height >= sourceHeight &&
_extent.Width % sourceWidth == 0 && _extent.Height % sourceHeight == 0;
_vk.CmdBlitImage(
_commandBuffer,
source.Image,
ImageLayout.TransferSrcOptimal,
_swapchainImages[imageIndex],
ImageLayout.TransferDstOptimal,
1,
&region,
isIntegerUpscale ? Filter.Nearest : Filter.Linear);
if (traceDestination)
{
var destinationToReadback = new ImageMemoryBarrier
{
SType = StructureType.ImageMemoryBarrier,
SrcAccessMask = AccessFlags.TransferWriteBit,
DstAccessMask = AccessFlags.TransferReadBit,
OldLayout = ImageLayout.TransferDstOptimal,
NewLayout = ImageLayout.TransferSrcOptimal,
SrcQueueFamilyIndex = Vk.QueueFamilyIgnored,
DstQueueFamilyIndex = Vk.QueueFamilyIgnored,
Image = _swapchainImages[imageIndex],
SubresourceRange = ColorSubresourceRange(),
};
_vk.CmdPipelineBarrier(
_commandBuffer,
PipelineStageFlags.TransferBit,
PipelineStageFlags.TransferBit,
0,
0,
null,
0,
null,
1,
&destinationToReadback);
var copyRegion = new BufferImageCopy
{
ImageSubresource = new ImageSubresourceLayers
{
AspectMask = ImageAspectFlags.ColorBit,
LayerCount = 1,
},
ImageExtent = new Extent3D(_extent.Width, _extent.Height, 1),
};
_vk.CmdCopyImageToBuffer(
_commandBuffer,
_swapchainImages[imageIndex],
ImageLayout.TransferSrcOptimal,
_stagingBuffer,
1,
&copyRegion);
_swapchainReadbackPending = true;
}
var sourceToShaderRead = new ImageMemoryBarrier
{
SType = StructureType.ImageMemoryBarrier,
SrcAccessMask = AccessFlags.TransferReadBit,
DstAccessMask = AccessFlags.ShaderReadBit,
OldLayout = ImageLayout.TransferSrcOptimal,
NewLayout = ImageLayout.ShaderReadOnlyOptimal,
SrcQueueFamilyIndex = Vk.QueueFamilyIgnored,
DstQueueFamilyIndex = Vk.QueueFamilyIgnored,
Image = source.Image,
SubresourceRange = ColorSubresourceRange(),
};
var destinationToPresent = new ImageMemoryBarrier
{
SType = StructureType.ImageMemoryBarrier,
SrcAccessMask = traceDestination
? AccessFlags.TransferReadBit
: AccessFlags.TransferWriteBit,
DstAccessMask = AccessFlags.MemoryReadBit,
OldLayout = traceDestination
? ImageLayout.TransferSrcOptimal
: ImageLayout.TransferDstOptimal,
NewLayout = ImageLayout.PresentSrcKhr,
SrcQueueFamilyIndex = Vk.QueueFamilyIgnored,
DstQueueFamilyIndex = Vk.QueueFamilyIgnored,
Image = _swapchainImages[imageIndex],
SubresourceRange = ColorSubresourceRange(),
};
barriers[0] = sourceToShaderRead;
barriers[1] = destinationToPresent;
_vk.CmdPipelineBarrier(
_commandBuffer,
PipelineStageFlags.TransferBit,
PipelineStageFlags.AllCommandsBit,
0,
0,
null,
0,
null,
2,
barriers);
EndDebugLabel(_commandBuffer);
}
private void TraceSwapchainReadback()
{
_swapchainReadbackPending = false;
var byteCount = checked((ulong)_extent.Width * _extent.Height * 4);
void* mapped;
Check(
_vk.MapMemory(_device, _stagingMemory, 0, byteCount, 0, &mapped),
"vkMapMemory(swapchain readback)");
try
{
var bytes = new ReadOnlySpan<byte>(mapped, checked((int)byteCount));
var nonzeroBytes = 0L;
var nonblackPixels = 0L;
ulong hash = 14695981039346656037UL;
for (var offset = 0; offset < bytes.Length; offset += 4)
{
var b0 = bytes[offset];
var b1 = bytes[offset + 1];
var b2 = bytes[offset + 2];
var b3 = bytes[offset + 3];
nonzeroBytes += b0 == 0 ? 0 : 1;
nonzeroBytes += b1 == 0 ? 0 : 1;
nonzeroBytes += b2 == 0 ? 0 : 1;
nonzeroBytes += b3 == 0 ? 0 : 1;
nonblackPixels += b0 != 0 || b1 != 0 || b2 != 0 ? 1 : 0;
hash = (hash ^ b0) * 1099511628211UL;
hash = (hash ^ b1) * 1099511628211UL;
hash = (hash ^ b2) * 1099511628211UL;
hash = (hash ^ b3) * 1099511628211UL;
}
Console.Error.WriteLine(
$"[LOADER][TRACE] vk.swapchain_image size={_extent.Width}x{_extent.Height} " +
$"format={_swapchainFormat} nonzero_bytes={nonzeroBytes}/{byteCount} " +
$"nonblack_pixels={nonblackPixels}/{(ulong)_extent.Width * _extent.Height} " +
$"hash=0x{hash:X16}");
var dumpDir = Environment.GetEnvironmentVariable("SHARPEMU_GUEST_IMAGE_DUMP_DIR");
if (!string.IsNullOrWhiteSpace(dumpDir))
{
Directory.CreateDirectory(dumpDir);
var seq = Interlocked.Increment(ref _guestImageDumpSequence);
var path = Path.Combine(
dumpDir,
$"present-{seq:D4}-{_extent.Width}x{_extent.Height}-{_swapchainFormat}.bgra");
File.WriteAllBytes(path, bytes.ToArray());
Console.Error.WriteLine($"[LOADER][TRACE] vk.swapchain_dump path={path}");
// Continuous readback is intentionally opt-in: each 1080p frame
// is several megabytes and synchronously waits for the GPU.
if (string.Equals(
Environment.GetEnvironmentVariable("SHARPEMU_GUEST_IMAGE_DUMP_CONTINUOUS"),
"1",
StringComparison.Ordinal))
{
_tracedPresentedSwapchain = false;
}
}
}
finally
{
_vk.UnmapMemory(_device, _stagingMemory);
}
}
private static readonly long _swapchainDumpInterval = ParseSwapchainDumpInterval();
private static long SwapchainDumpInterval() => _swapchainDumpInterval;
private static long ParseSwapchainDumpInterval()
{
var raw = Environment.GetEnvironmentVariable("SHARPEMU_SWAPCHAIN_DUMP_EVERY");
return long.TryParse(raw, out var interval) && interval > 0 ? interval : 0;
}
private Extent2D ChooseExtent(SurfaceCapabilitiesKHR capabilities)
{
if (capabilities.CurrentExtent.Width != uint.MaxValue)
{
var fallbackWidth = _extent.Width != 0
? _extent.Width
: DefaultWindowWidth;
var fallbackHeight = _extent.Height != 0
? _extent.Height
: DefaultWindowHeight;
return new Extent2D(
ClampSurfaceExtent(
capabilities.CurrentExtent.Width,
fallbackWidth,
capabilities.MinImageExtent.Width,
capabilities.MaxImageExtent.Width),
ClampSurfaceExtent(
capabilities.CurrentExtent.Height,
fallbackHeight,
capabilities.MinImageExtent.Height,
capabilities.MaxImageExtent.Height));
}
var size = GetFramebufferSize();
return new Extent2D(
ClampSurfaceExtent(
(uint)Math.Max(size.X, 1),
DefaultWindowWidth,
capabilities.MinImageExtent.Width,
capabilities.MaxImageExtent.Width),
ClampSurfaceExtent(
(uint)Math.Max(size.Y, 1),
DefaultWindowHeight,
capabilities.MinImageExtent.Height,
capabilities.MaxImageExtent.Height));
}
private Vector2D<int> GetFramebufferSize()
{
if (_window is not null)
{
return _window.FramebufferSize;
}
if (_hostSurface is not null)
{
return new Vector2D<int>(_hostSurface.PixelWidth, _hostSurface.PixelHeight);
}
return new Vector2D<int>((int)DefaultWindowWidth, (int)DefaultWindowHeight);
}
private static uint ClampSurfaceExtent(
uint value,
uint fallback,
uint minimum,
uint maximum)
{
value = value <= 1 && fallback > 1 ? fallback : value;
minimum = Math.Max(minimum, 1u);
maximum = Math.Max(maximum, minimum);
return Math.Clamp(value, minimum, maximum);
}
private static SurfaceFormatKHR ChooseSurfaceFormat(IReadOnlyList<SurfaceFormatKHR> formats)
{
foreach (var format in formats)
{
if (format.Format is Format.B8G8R8A8Srgb or Format.B8G8R8A8Unorm &&
format.ColorSpace == ColorSpaceKHR.SpaceSrgbNonlinearKhr)
{
return format;
}
}
return formats.Count > 0
? formats[0]
: throw new InvalidOperationException("The Vulkan surface exposes no pixel formats.");
}
private static CompositeAlphaFlagsKHR ChooseCompositeAlpha(CompositeAlphaFlagsKHR supported)
{
foreach (var candidate in new[]
{
CompositeAlphaFlagsKHR.OpaqueBitKhr,
CompositeAlphaFlagsKHR.PreMultipliedBitKhr,
CompositeAlphaFlagsKHR.PostMultipliedBitKhr,
CompositeAlphaFlagsKHR.InheritBitKhr,
})
{
if ((supported & candidate) != 0)
{
return candidate;
}
}
throw new InvalidOperationException("The Vulkan surface exposes no composite alpha mode.");
}
private static ImageSubresourceRange ColorSubresourceRange(
uint baseMipLevel = 0,
uint levelCount = 1) =>
new()
{
AspectMask = ImageAspectFlags.ColorBit,
BaseMipLevel = baseMipLevel,
LevelCount = levelCount,
LayerCount = 1,
};
private static byte[] ScaleBgra(byte[] source, uint sourceWidth, uint sourceHeight, uint width, uint height)
{
var destination = new byte[checked((int)(width * height * 4))];
for (uint y = 0; y < height; y++)
{
var sourceY = (uint)(((ulong)y * sourceHeight) / height);
for (uint x = 0; x < width; x++)
{
var sourceX = (uint)(((ulong)x * sourceWidth) / width);
var sourceOffset = checked((int)(((ulong)sourceY * sourceWidth + sourceX) * 4));
var destinationOffset = checked((int)(((ulong)y * width + x) * 4));
source.AsSpan(sourceOffset, 4).CopyTo(destination.AsSpan(destinationOffset, 4));
}
}
return destination;
}
private static byte[] ScaleBgraCoverBilinear(
byte[] source,
uint sourceWidth,
uint sourceHeight,
uint width,
uint height)
{
var destination = new byte[checked((int)(width * height * 4))];
var sourceIsWider = (ulong)sourceWidth * height > (ulong)width * sourceHeight;
var cropWidth = sourceIsWider
? Math.Max(1u, (uint)((ulong)sourceHeight * width / height))
: sourceWidth;
var cropHeight = sourceIsWider
? sourceHeight
: Math.Max(1u, (uint)((ulong)sourceWidth * height / width));
var offsetX = (sourceWidth - cropWidth) / 2;
var offsetY = (sourceHeight - cropHeight) / 2;
var maxSourceX = offsetX + cropWidth - 1;
var maxSourceY = offsetY + cropHeight - 1;
for (uint y = 0; y < height; y++)
{
for (uint x = 0; x < width; x++)
{
var destinationOffset = checked((int)(((ulong)y * width + x) * 4));
float blue = 0;
float green = 0;
float red = 0;
float alpha = 0;
for (var sampleY = 0; sampleY < 2; sampleY++)
{
var scaledY = offsetY + (((y + ((sampleY + 0.5f) / 2)) * cropHeight) / height) - 0.5f;
var sourceY0 = (uint)Math.Clamp((int)MathF.Floor(scaledY), (int)offsetY, (int)maxSourceY);
var sourceY1 = Math.Min(sourceY0 + 1, maxSourceY);
var fractionY = scaledY - MathF.Floor(scaledY);
for (var sampleX = 0; sampleX < 2; sampleX++)
{
var scaledX = offsetX + (((x + ((sampleX + 0.5f) / 2)) * cropWidth) / width) - 0.5f;
var sourceX0 = (uint)Math.Clamp((int)MathF.Floor(scaledX), (int)offsetX, (int)maxSourceX);
var sourceX1 = Math.Min(sourceX0 + 1, maxSourceX);
var fractionX = scaledX - MathF.Floor(scaledX);
var sourceOffset00 = checked((int)(((ulong)sourceY0 * sourceWidth + sourceX0) * 4));
var sourceOffset10 = checked((int)(((ulong)sourceY0 * sourceWidth + sourceX1) * 4));
var sourceOffset01 = checked((int)(((ulong)sourceY1 * sourceWidth + sourceX0) * 4));
var sourceOffset11 = checked((int)(((ulong)sourceY1 * sourceWidth + sourceX1) * 4));
var topBlue = source[sourceOffset00] +
((source[sourceOffset10] - source[sourceOffset00]) * fractionX);
var bottomBlue = source[sourceOffset01] +
((source[sourceOffset11] - source[sourceOffset01]) * fractionX);
blue += topBlue + ((bottomBlue - topBlue) * fractionY);
var topGreen = source[sourceOffset00 + 1] +
((source[sourceOffset10 + 1] - source[sourceOffset00 + 1]) * fractionX);
var bottomGreen = source[sourceOffset01 + 1] +
((source[sourceOffset11 + 1] - source[sourceOffset01 + 1]) * fractionX);
green += topGreen + ((bottomGreen - topGreen) * fractionY);
var topRed = source[sourceOffset00 + 2] +
((source[sourceOffset10 + 2] - source[sourceOffset00 + 2]) * fractionX);
var bottomRed = source[sourceOffset01 + 2] +
((source[sourceOffset11 + 2] - source[sourceOffset01 + 2]) * fractionX);
red += topRed + ((bottomRed - topRed) * fractionY);
var topAlpha = source[sourceOffset00 + 3] +
((source[sourceOffset10 + 3] - source[sourceOffset00 + 3]) * fractionX);
var bottomAlpha = source[sourceOffset01 + 3] +
((source[sourceOffset11 + 3] - source[sourceOffset01 + 3]) * fractionX);
alpha += topAlpha + ((bottomAlpha - topAlpha) * fractionY);
}
}
destination[destinationOffset] = (byte)MathF.Round(blue * 0.25f);
destination[destinationOffset + 1] = (byte)MathF.Round(green * 0.25f);
destination[destinationOffset + 2] = (byte)MathF.Round(red * 0.25f);
destination[destinationOffset + 3] = (byte)MathF.Round(alpha * 0.25f);
}
}
return destination;
}
private void DisposeVulkan()
{
if (!_vulkanReady)
{
return;
}
if (_debugUtils is not null && _debugMessenger.Handle != 0)
{
_debugUtils.DestroyDebugUtilsMessenger(_instance, _debugMessenger, null);
}
_vulkanReady = false;
_vk.DeviceWaitIdle(_device);
SavePipelineCache(force: true);
DrainFrameSlots();
CollectCompletedGuestSubmissions(waitForOldest: false);
ClearCachedTextureIdentities();
foreach (var pipeline in _computePipelines.Values)
{
_vk.DestroyPipeline(_device, pipeline, null);
}
_computePipelines.Clear();
foreach (var pipeline in _graphicsPipelines.Values)
{
_vk.DestroyPipeline(_device, pipeline, null);
}
_graphicsPipelines.Clear();
foreach (var layout in _descriptorLayouts.Values)
{
_vk.DestroyPipelineLayout(_device, layout.PipelineLayout, null);
if (layout.DescriptorSetLayout.Handle != 0)
{
_vk.DestroyDescriptorSetLayout(
_device,
layout.DescriptorSetLayout,
null);
}
}
_descriptorLayouts.Clear();
foreach (var sampler in _samplers.Values)
{
_vk.DestroySampler(_device, sampler, null);
}
_samplers.Clear();
_shaderDigests.Clear();
WriteBackAllDirtyGuestBuffers();
foreach (var allocation in _guestBufferAllocations)
{
DestroyGuestBufferAllocation(allocation);
}
_guestBufferAllocations.Clear();
_hostBufferPool.Dispose();
foreach (var guestImage in _guestImages.Values)
{
DestroyGuestImage(guestImage);
}
_guestImages.Clear();
foreach (var guestImageVariant in _guestImageVariants.Values)
{
DestroyGuestImage(guestImageVariant);
}
_guestImageVariants.Clear();
foreach (var guestImageVersion in _guestImageVersions.Values)
{
DestroyGuestImage(guestImageVersion);
}
_guestImageVersions.Clear();
_capturedGuestFlipVersions.Clear();
while (_deferredGuestImageVersionDestroys.TryDequeue(out var deferredVersion))
{
DestroyGuestImage(deferredVersion.Image);
}
foreach (var guestDepth in _guestDepthImages.Values)
{
DestroyGuestDepth(guestDepth);
}
_guestDepthImages.Clear();
lock (_gate)
{
_availableGuestImages.Clear();
_lastOrderedGuestFlipVersions.Clear();
}
DestroySwapchainResources();
if (_device.Handle != 0)
{
if (_pipelineCache.Handle != 0)
{
_vk.DestroyPipelineCache(_device, _pipelineCache, null);
_pipelineCache = default;
}
_vk.DestroyDevice(_device, null);
_device = default;
}
if (_surface.Handle != 0)
{
_surfaceApi.DestroySurface(_instance, _surface, null);
_surface = default;
}
if (_instance.Handle != 0)
{
_vk.DestroyInstance(_instance, null);
_instance = default;
}
}
private void RecreateSwapchainResources(string operation, Result result)
{
if (_device.Handle == 0)
{
return;
}
Check(
_surfaceApi.GetPhysicalDeviceSurfaceCapabilities(
_physicalDevice,
_surface,
out var capabilities),
"vkGetPhysicalDeviceSurfaceCapabilitiesKHR");
var framebufferSize = GetFramebufferSize();
var hasFixedExtent = capabilities.CurrentExtent.Width != uint.MaxValue;
var surfaceWidth = hasFixedExtent
? capabilities.CurrentExtent.Width
: (uint)Math.Max(framebufferSize.X, 0);
var surfaceHeight = hasFixedExtent
? capabilities.CurrentExtent.Height
: (uint)Math.Max(framebufferSize.Y, 0);
if (surfaceWidth <= 1 || surfaceHeight <= 1)
{
if (!_swapchainRecreateDeferred)
{
_swapchainRecreateDeferred = true;
Console.Error.WriteLine(
$"[LOADER][INFO] Vulkan VideoOut deferred swapchain recreation: " +
$"surface={surfaceWidth}x{surfaceHeight}");
}
return;
}
_swapchainRecreateDeferred = false;
Console.Error.WriteLine(
$"[LOADER][INFO] Vulkan VideoOut recreating swapchain after {operation}: {result}");
_vk.DeviceWaitIdle(_device);
DrainFrameSlots();
CollectCompletedGuestSubmissions(waitForOldest: false);
DestroySwapchainResources();
CreateSwapchain();
CreateCommandResources();
CreateGuestDrawResources();
Console.Error.WriteLine(
$"[LOADER][INFO] Vulkan VideoOut recreated swapchain: " +
$"{_extent.Width}x{_extent.Height}, format={_swapchainFormat}");
}
private void DestroySwapchainResources()
{
if (_stagingBuffer.Handle != 0)
{
_vk.DestroyBuffer(_device, _stagingBuffer, null);
_stagingBuffer = default;
}
if (_stagingMemory.Handle != 0)
{
_vk.FreeMemory(_device, _stagingMemory, null);
_stagingMemory = default;
_stagingSize = 0;
}
foreach (var semaphore in _frameImageAvailable)
{
if (semaphore.Handle != 0)
{
_vk.DestroySemaphore(_device, semaphore, null);
}
}
_frameImageAvailable = [];
foreach (var semaphore in _renderFinishedPerImage)
{
if (semaphore.Handle != 0)
{
_vk.DestroySemaphore(_device, semaphore, null);
}
}
_renderFinishedPerImage = [];
if (_overlayImage.Handle != 0)
{
_vk.DestroyImage(_device, _overlayImage, null);
_overlayImage = default;
}
if (_overlayImageMemory.Handle != 0)
{
_vk.FreeMemory(_device, _overlayImageMemory, null);
_overlayImageMemory = default;
}
for (var slot = 0; slot < _overlayStagingBuffers.Length; slot++)
{
if (_overlayStagingBuffers[slot].Handle != 0)
{
_vk.DestroyBuffer(_device, _overlayStagingBuffers[slot], null);
}
if (_overlayStagingMemory[slot].Handle != 0)
{
_vk.FreeMemory(_device, _overlayStagingMemory[slot], null);
}
}
_overlayStagingBuffers = [];
_overlayStagingMemory = [];
_overlayStagingMapped = [];
_overlayImageInitialized = false;
foreach (var fence in _frameFences)
{
if (fence.Handle != 0)
{
_vk.DestroyFence(_device, fence, null);
}
}
_frameFences = [];
_frameFencePending = [];
_frameTimelines = [];
_frameTranslatedResources = [];
_frameGuestImageVersions = [];
while (_recycledGuestFences.TryPop(out var recycledFence))
{
_vk.DestroyFence(_device, recycledFence, null);
}
if (_barycentricPipeline.Handle != 0)
{
_vk.DestroyPipeline(_device, _barycentricPipeline, null);
_barycentricPipeline = default;
}
if (_pipelineLayout.Handle != 0)
{
_vk.DestroyPipelineLayout(_device, _pipelineLayout, null);
_pipelineLayout = default;
}
foreach (var framebuffer in _framebuffers)
{
if (framebuffer.Handle != 0)
{
_vk.DestroyFramebuffer(_device, framebuffer, null);
}
}
if (_renderPass.Handle != 0)
{
_vk.DestroyRenderPass(_device, _renderPass, null);
_renderPass = default;
}
foreach (var imageView in _swapchainImageViews)
{
if (imageView.Handle != 0)
{
_vk.DestroyImageView(_device, imageView, null);
}
}
if (_commandPool.Handle != 0)
{
// Destroying the pool frees every command buffer allocated
// from it, including recycled and per-frame ones.
_recycledGuestCommandBuffers.Clear();
_frameCommandBuffers = [];
_vk.DestroyCommandPool(_device, _commandPool, null);
_commandPool = default;
_commandBuffer = default;
_presentationCommandBuffer = default;
}
if (_swapchain.Handle != 0)
{
_swapchainApi.DestroySwapchain(_device, _swapchain, null);
_swapchain = default;
}
_swapchainImages = [];
_swapchainImageViews = [];
_framebuffers = [];
_imageInitialized = [];
}
private static void CheckSwapchainResult(Result result, string operation)
{
if (result is Result.Success or Result.SuboptimalKhr)
{
return;
}
if (result == Result.ErrorDeviceLost)
{
throw new VulkanDeviceLostException(operation);
}
throw new InvalidOperationException($"{operation} failed with {result}.");
}
private static void Check(Result result, string operation)
{
if (result == Result.ErrorDeviceLost)
{
throw new VulkanDeviceLostException(operation);
}
if (result != Result.Success)
{
throw new InvalidOperationException($"{operation} failed with {result}.");
}
}
// Typed so the frame-boundary catch can recognize device loss without
// depending on the exact wording of the exception message.
private sealed class VulkanDeviceLostException(string operation)
: InvalidOperationException($"{operation} failed with {Result.ErrorDeviceLost}.");
private bool TryMarkDeviceLost(Exception exception)
{
// Prefer the typed signal; fall back to the message for losses that
// surface through other layers (e.g. Silk.NET bindings).
if (exception is not VulkanDeviceLostException &&
!exception.Message.Contains(nameof(Result.ErrorDeviceLost), StringComparison.Ordinal))
{
return false;
}
_deviceLost = true;
if (!_deviceLostLogged)
{
_deviceLostLogged = true;
Console.Error.WriteLine(
"[LOADER][ERROR] Vulkan device lost; dropping subsequent guest GPU work. " +
exception.Message);
}
return true;
}
private static void TraceVulkanShader(string message)
{
if (!_traceVulkanShaderEnabled)
{
return;
}
Console.Error.WriteLine($"[LOADER][TRACE] {message}");
}
}
}