// Copyright (C) 2026 SharpEmu Emulator Project // SPDX-License-Identifier: GPL-2.0-or-later using System.Runtime.InteropServices; namespace SharpEmu.Libs.VideoOut; public static unsafe class RenderDocCapture { private const int ApiVersion1_4_2 = 10402; private const int IndexUnloadCrashHandler = 10; private const int IndexSetCaptureKeys = 6; private const int IndexSetCaptureFilePathTemplate = 11; private const int IndexGetNumCaptures = 13; private const int IndexGetCapture = 14; private const int IndexStartFrameCapture = 19; private const int IndexIsFrameCapturing = 20; private const int IndexEndFrameCapture = 21; private const int IndexDiscardFrameCapture = 24; private const int StateIdle = 0; private const int StateRequested = 1; private const int StateCapturing = 2; private static IntPtr* _api; private static int _state = StateIdle; private static bool _initialized; private static long _captureStartedTick; private static long _captureStartGuestFlipVersion; private static readonly long _captureTimeoutMilliseconds = long.TryParse( Environment.GetEnvironmentVariable("SHARPEMU_RENDERDOC_CAPTURE_TIMEOUT_SECONDS"), out var captureTimeoutSeconds) && captureTimeoutSeconds > 0 ? Math.Clamp(captureTimeoutSeconds, 1, 120) * 1_000 : 15_000; public static bool IsAvailable => _api is not null; public static bool ApplyVulkanLoaderEnvironment() { if (!string.Equals( Environment.GetEnvironmentVariable("SHARPEMU_VK_DISABLE_IMPLICITS"), "1", StringComparison.Ordinal)) { return false; } Environment.SetEnvironmentVariable("VK_LOADER_DEBUG", "error,warn,layer"); Environment.SetEnvironmentVariable("VK_LOADER_LAYERS_DISABLE", "~implicit~"); Environment.SetEnvironmentVariable("VK_LOADER_LAYERS_ALLOW", "*RENDERDOC*"); return true; } public static void Initialize() { if (_initialized) { return; } _initialized = true; if (!string.Equals( Environment.GetEnvironmentVariable("SHARPEMU_RENDERDOC"), "1", StringComparison.Ordinal)) { return; } if (!TryLoadLibrary(out var module)) { Console.Error.WriteLine( "[LOADER][WARN] renderdoc: SHARPEMU_RENDERDOC=1 was set but renderdoc.dll could not be loaded."); return; } if (!NativeLibrary.TryGetExport(module, "RENDERDOC_GetAPI", out var getApiAddress)) { Console.Error.WriteLine( "[LOADER][WARN] renderdoc: RENDERDOC_GetAPI is missing; in-app capture disabled."); return; } void* api = null; var getApi = (delegate* unmanaged[Cdecl])getApiAddress; if (getApi(ApiVersion1_4_2, &api) != 1 || api is null) { Console.Error.WriteLine( "[LOADER][WARN] renderdoc: API 1.4.2 unavailable; in-app capture disabled."); return; } _api = (IntPtr*)api; // Host-present captures can bisect an asynchronously drained guest // frame. Disable RenderDoc's own hotkey and let the window route the // capture request through the guest-flip state machine below. ((delegate* unmanaged[Cdecl])_api[IndexSetCaptureKeys])( null, 0); ((delegate* unmanaged[Cdecl])_api[IndexUnloadCrashHandler])(); Console.Error.WriteLine( "[LOADER][INFO] renderdoc: in-app capture ready. Press F12 to capture the next complete guest frame."); } public static void SetCaptureDirectory(string titleId) { if (_api is null) { return; } var safeTitleId = string.IsNullOrWhiteSpace(titleId) ? "UNKNOWN" : titleId.Trim(); foreach (var invalid in Path.GetInvalidFileNameChars()) { safeTitleId = safeTitleId.Replace(invalid, '_'); } try { var directory = Path.Combine( AppContext.BaseDirectory, "user", "logs", "capture_logs", safeTitleId); Directory.CreateDirectory(directory); var template = Path.Combine(directory, safeTitleId); var bytes = System.Text.Encoding.UTF8.GetBytes(template + "\0"); fixed (byte* pointer = bytes) { ((delegate* unmanaged[Cdecl])_api[IndexSetCaptureFilePathTemplate])( pointer); } Console.Error.WriteLine( $"[LOADER][INFO] renderdoc: captures will be written under '{directory}'."); } catch (Exception exception) { Console.Error.WriteLine( $"[LOADER][WARN] renderdoc: could not set the capture directory: {exception.Message}"); } } public static void RequestCapture() { if (_api is null) { return; } if (Interlocked.CompareExchange(ref _state, StateRequested, StateIdle) == StateIdle) { Console.Error.WriteLine( "[LOADER][INFO] renderdoc: capture requested; waiting for the next guest-flip boundary."); } } /// /// Marks a successfully submitted guest-flip boundary. A requested /// capture starts after the first boundary and ends after the next one, /// so asynchronous guest work remains inside one capture even when it is /// drained across several host-presenter ticks. /// public static void OnGuestFlipBoundary(long version) { if (_api is null) { return; } var state = Volatile.Read(ref _state); if (state == StateCapturing) { EndGuestFrame(version); return; } if (state != StateRequested) { return; } if (IsFrameCapturing()) { Volatile.Write(ref _state, StateIdle); return; } StartFrameCapture(); if (!IsFrameCapturing()) { Console.Error.WriteLine( "[LOADER][WARN] renderdoc: StartFrameCapture did not begin a capture."); Volatile.Write(ref _state, StateIdle); return; } Volatile.Write(ref _state, StateCapturing); Volatile.Write(ref _captureStartedTick, Environment.TickCount64); Volatile.Write(ref _captureStartGuestFlipVersion, version); Console.Error.WriteLine( $"[LOADER][INFO] renderdoc: guest-frame capture started after flip v{version}."); } public static void DiscardTimedOutFrame() { if (_api is null || Volatile.Read(ref _state) != StateCapturing) { return; } var elapsed = Environment.TickCount64 - Volatile.Read(ref _captureStartedTick); if (elapsed < _captureTimeoutMilliseconds || Interlocked.CompareExchange(ref _state, StateIdle, StateCapturing) != StateCapturing) { return; } _ = ((delegate* unmanaged[Cdecl]) _api[IndexDiscardFrameCapture])(IntPtr.Zero, IntPtr.Zero); Console.Error.WriteLine( $"[LOADER][WARN] renderdoc: discarded a guest-frame capture after {elapsed} ms without the next guest flip."); } private static void EndGuestFrame(long version) { if (_api is null || Interlocked.CompareExchange(ref _state, StateIdle, StateCapturing) != StateCapturing) { return; } if (EndFrameCapture() != 0) { var startVersion = Volatile.Read(ref _captureStartGuestFlipVersion); Console.Error.WriteLine( $"[LOADER][INFO] renderdoc: guest-frame capture ended at flip v{version} " + $"(v{startVersion}->v{version})."); LogNewestCapture(); } else { Console.Error.WriteLine("[LOADER][WARN] renderdoc: EndFrameCapture failed."); } } public static void DiscardFrame() { if (_api is null || Interlocked.CompareExchange(ref _state, StateIdle, StateCapturing) != StateCapturing) { return; } _ = ((delegate* unmanaged[Cdecl]) _api[IndexDiscardFrameCapture])(IntPtr.Zero, IntPtr.Zero); Console.Error.WriteLine( "[LOADER][WARN] renderdoc: discarded an interrupted frame capture."); } private static void StartFrameCapture() => ((delegate* unmanaged[Cdecl])_api[IndexStartFrameCapture])( IntPtr.Zero, IntPtr.Zero); private static bool IsFrameCapturing() => ((delegate* unmanaged[Cdecl])_api[IndexIsFrameCapturing])() != 0; private static uint EndFrameCapture() => ((delegate* unmanaged[Cdecl])_api[IndexEndFrameCapture])( IntPtr.Zero, IntPtr.Zero); private static void LogNewestCapture() { var count = ((delegate* unmanaged[Cdecl])_api[IndexGetNumCaptures])(); if (count == 0) { return; } var getCapture = (delegate* unmanaged[Cdecl])_api[IndexGetCapture]; uint pathLength = 0; if (getCapture(count - 1, null, &pathLength, null) == 0 || pathLength == 0) { return; } var buffer = new byte[pathLength]; fixed (byte* bufferPointer = buffer) { if (getCapture(count - 1, bufferPointer, &pathLength, null) == 0) { return; } } var path = System.Text.Encoding.UTF8.GetString(buffer).TrimEnd('\0'); Console.Error.WriteLine($"[LOADER][INFO] renderdoc: capture written to '{path}'."); } private static bool TryLoadLibrary(out IntPtr module) { var configured = Environment.GetEnvironmentVariable("SHARPEMU_RENDERDOC_DLL"); if (!string.IsNullOrWhiteSpace(configured) && NativeLibrary.TryLoad(configured, out module)) { return true; } var name = OperatingSystem.IsWindows() ? "renderdoc.dll" : "librenderdoc.so"; if (NativeLibrary.TryLoad(name, out module)) { return true; } foreach (var candidate in KnownLibraryPaths(name)) { if (File.Exists(candidate) && NativeLibrary.TryLoad(candidate, out module)) { return true; } } module = IntPtr.Zero; return false; } private static IEnumerable KnownLibraryPaths(string name) { if (!OperatingSystem.IsWindows()) { yield return "/usr/lib/librenderdoc.so"; yield return "/usr/local/lib/librenderdoc.so"; yield break; } foreach (var variable in (string[])["ProgramFiles", "ProgramW6432", "ProgramFiles(x86)"]) { var root = Environment.GetEnvironmentVariable(variable); if (!string.IsNullOrWhiteSpace(root)) { yield return Path.Combine(root, "RenderDoc", name); } } var localAppData = Environment.GetEnvironmentVariable("LOCALAPPDATA"); if (!string.IsNullOrWhiteSpace(localAppData)) { yield return Path.Combine(localAppData, "RenderDoc", name); } } }