mirror of
https://git.ryujinx.app/ryubing/ryujinx.git
synced 2026-01-11 20:10:38 +00:00
RenderDoc integration (start/stop capture menu bar items)
This commit is contained in:
parent
4c64300576
commit
938831a901
19 changed files with 730 additions and 14 deletions
14
Ryujinx.sln
14
Ryujinx.sln
|
|
@ -21,6 +21,8 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Ryujinx.Graphics.GAL", "src
|
|||
EndProject
|
||||
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Ryujinx.Graphics.OpenGL", "src\Ryujinx.Graphics.OpenGL\Ryujinx.Graphics.OpenGL.csproj", "{9558FB96-075D-4219-8FFF-401979DC0B69}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Ryujinx.Graphics.RenderDoc", "src\Ryujinx.Graphics.RenderDocApi\Ryujinx.Graphics.RenderDocApi.csproj", "{D58FA894-27D5-4EAA-9042-AD422AD82931}"
|
||||
EndProject
|
||||
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Ryujinx.Graphics.Texture", "src\Ryujinx.Graphics.Texture\Ryujinx.Graphics.Texture.csproj", "{E1B1AD28-289D-47B7-A106-326972240207}"
|
||||
EndProject
|
||||
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Ryujinx.Graphics.Shader", "src\Ryujinx.Graphics.Shader\Ryujinx.Graphics.Shader.csproj", "{03B955CD-AD84-4B93-AAA7-BF17923BBAA5}"
|
||||
|
|
@ -555,6 +557,18 @@ Global
|
|||
{988E6191-82E1-4E13-9DDB-CB9FA2FDAF29}.Release|x64.Build.0 = Release|Any CPU
|
||||
{988E6191-82E1-4E13-9DDB-CB9FA2FDAF29}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{988E6191-82E1-4E13-9DDB-CB9FA2FDAF29}.Release|x86.Build.0 = Release|Any CPU
|
||||
{D58FA894-27D5-4EAA-9042-AD422AD82931}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{D58FA894-27D5-4EAA-9042-AD422AD82931}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{D58FA894-27D5-4EAA-9042-AD422AD82931}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||
{D58FA894-27D5-4EAA-9042-AD422AD82931}.Debug|x64.Build.0 = Debug|Any CPU
|
||||
{D58FA894-27D5-4EAA-9042-AD422AD82931}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||
{D58FA894-27D5-4EAA-9042-AD422AD82931}.Debug|x86.Build.0 = Debug|Any CPU
|
||||
{D58FA894-27D5-4EAA-9042-AD422AD82931}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{D58FA894-27D5-4EAA-9042-AD422AD82931}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{D58FA894-27D5-4EAA-9042-AD422AD82931}.Release|x64.ActiveCfg = Release|Any CPU
|
||||
{D58FA894-27D5-4EAA-9042-AD422AD82931}.Release|x64.Build.0 = Release|Any CPU
|
||||
{D58FA894-27D5-4EAA-9042-AD422AD82931}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{D58FA894-27D5-4EAA-9042-AD422AD82931}.Release|x86.Build.0 = Release|Any CPU
|
||||
EndGlobalSection
|
||||
GlobalSection(SolutionProperties) = preSolution
|
||||
HideSolutionNode = FALSE
|
||||
|
|
|
|||
12
src/Ryujinx.Graphics.RenderDocApi/Capture.cs
Normal file
12
src/Ryujinx.Graphics.RenderDocApi/Capture.cs
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
using System;
|
||||
|
||||
namespace Ryujinx.Graphics.RenderDocApi
|
||||
{
|
||||
public readonly record struct Capture(int Index, string FileName, DateTime Timestamp)
|
||||
{
|
||||
public void SetComments(string comments)
|
||||
{
|
||||
RenderDoc.SetCaptureFileComments(FileName, comments);
|
||||
}
|
||||
}
|
||||
}
|
||||
22
src/Ryujinx.Graphics.RenderDocApi/CaptureOption.cs
Normal file
22
src/Ryujinx.Graphics.RenderDocApi/CaptureOption.cs
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
// ReSharper disable UnusedMember.Global
|
||||
|
||||
namespace Ryujinx.Graphics.RenderDocApi
|
||||
{
|
||||
public enum CaptureOption
|
||||
{
|
||||
AllowVsync = 0,
|
||||
AllowFullscreen = 1,
|
||||
ApiValidation = 2,
|
||||
CaptureCallstacks = 3,
|
||||
CaptureCallstacksOnlyDraws = 4,
|
||||
DelayForDebugger = 5,
|
||||
VerifyBufferAccess = 6,
|
||||
HookIntoChildren = 7,
|
||||
RefAllSources = 8,
|
||||
SaveAllInitials = 9,
|
||||
CaptureAllCmdLists = 10,
|
||||
DebugOutputMute = 11,
|
||||
AllowUnsupportedVendorExtensions = 12,
|
||||
SoftMemoryLimit = 13,
|
||||
}
|
||||
}
|
||||
83
src/Ryujinx.Graphics.RenderDocApi/InputButton.cs
Normal file
83
src/Ryujinx.Graphics.RenderDocApi/InputButton.cs
Normal file
|
|
@ -0,0 +1,83 @@
|
|||
|
||||
// ReSharper disable UnusedMember.Global
|
||||
namespace Ryujinx.Graphics.RenderDocApi
|
||||
{
|
||||
public enum InputButton
|
||||
{
|
||||
// '0' - '9' matches ASCII values
|
||||
Key0 = 0x30,
|
||||
Key1 = 0x31,
|
||||
Key2 = 0x32,
|
||||
Key3 = 0x33,
|
||||
Key4 = 0x34,
|
||||
Key5 = 0x35,
|
||||
Key6 = 0x36,
|
||||
Key7 = 0x37,
|
||||
Key8 = 0x38,
|
||||
Key9 = 0x39,
|
||||
|
||||
// 'A' - 'Z' matches ASCII values
|
||||
A = 0x41,
|
||||
B = 0x42,
|
||||
C = 0x43,
|
||||
D = 0x44,
|
||||
E = 0x45,
|
||||
F = 0x46,
|
||||
G = 0x47,
|
||||
H = 0x48,
|
||||
I = 0x49,
|
||||
J = 0x4A,
|
||||
K = 0x4B,
|
||||
L = 0x4C,
|
||||
M = 0x4D,
|
||||
N = 0x4E,
|
||||
O = 0x4F,
|
||||
P = 0x50,
|
||||
Q = 0x51,
|
||||
R = 0x52,
|
||||
S = 0x53,
|
||||
T = 0x54,
|
||||
U = 0x55,
|
||||
V = 0x56,
|
||||
W = 0x57,
|
||||
X = 0x58,
|
||||
Y = 0x59,
|
||||
Z = 0x5A,
|
||||
|
||||
// leave the rest of the ASCII range free
|
||||
// in case we want to use it later
|
||||
NonPrintable = 0x100,
|
||||
|
||||
Divide,
|
||||
Multiply,
|
||||
Subtract,
|
||||
Plus,
|
||||
|
||||
F1,
|
||||
F2,
|
||||
F3,
|
||||
F4,
|
||||
F5,
|
||||
F6,
|
||||
F7,
|
||||
F8,
|
||||
F9,
|
||||
F10,
|
||||
F11,
|
||||
F12,
|
||||
|
||||
Home,
|
||||
End,
|
||||
Insert,
|
||||
Delete,
|
||||
PageUp,
|
||||
PageDn,
|
||||
|
||||
Backspace,
|
||||
Tab,
|
||||
PrtScrn,
|
||||
Pause,
|
||||
|
||||
Max,
|
||||
}
|
||||
}
|
||||
19
src/Ryujinx.Graphics.RenderDocApi/OverlayBits.cs
Normal file
19
src/Ryujinx.Graphics.RenderDocApi/OverlayBits.cs
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
// ReSharper disable UnusedMember.Global
|
||||
|
||||
using System;
|
||||
|
||||
namespace Ryujinx.Graphics.RenderDocApi
|
||||
{
|
||||
[Flags]
|
||||
public enum OverlayBits
|
||||
{
|
||||
Enabled = 1 << 0,
|
||||
FrameRate = 1 << 1,
|
||||
FrameNumber = 1 << 2,
|
||||
CaptureList = 1 << 3,
|
||||
|
||||
Default = Enabled | FrameRate | FrameNumber | CaptureList,
|
||||
All = ~0,
|
||||
None = 0
|
||||
}
|
||||
}
|
||||
5
src/Ryujinx.Graphics.RenderDocApi/README.md
Normal file
5
src/Ryujinx.Graphics.RenderDocApi/README.md
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
# Ryujinx.Graphics.RenderDoc
|
||||
|
||||
This is a C# binding for RenderDoc's application API.
|
||||
This is a source-inclusion of https://github.com/utkumaden/RenderdocSharp.
|
||||
I didn't use the NuGet package as I had a few minor changes I wanted to make, and I want to learn from it as well via hands-on experience.
|
||||
335
src/Ryujinx.Graphics.RenderDocApi/RenderDoc.cs
Normal file
335
src/Ryujinx.Graphics.RenderDocApi/RenderDoc.cs
Normal file
|
|
@ -0,0 +1,335 @@
|
|||
using System;
|
||||
using System.Diagnostics;
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using System.Reflection;
|
||||
using System.Runtime.CompilerServices;
|
||||
using System.Runtime.InteropServices;
|
||||
using System.Text;
|
||||
using System.Text.RegularExpressions;
|
||||
|
||||
namespace Ryujinx.Graphics.RenderDocApi
|
||||
{
|
||||
public static unsafe class RenderDoc
|
||||
{
|
||||
/// <summary>
|
||||
/// True if the API is available.
|
||||
/// </summary>
|
||||
public static bool IsAvailable => Api != null;
|
||||
|
||||
/// <summary>
|
||||
/// Set the minimum version of the API you require.
|
||||
/// </summary>
|
||||
/// <remarks>Set this before you do anything else with the RenderDoc API, including <see cref="IsAvailable"/>.</remarks>
|
||||
public static Version MinimumRequired { get; set; } = new Version(1, 0, 0);
|
||||
|
||||
/// <summary>
|
||||
/// Set to true to assert versions.
|
||||
/// </summary>
|
||||
public static bool AssertVersionEnabled { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Version of the API available.
|
||||
/// </summary>
|
||||
[MemberNotNullWhen(true, nameof(IsAvailable))]
|
||||
public static Version? Version
|
||||
{
|
||||
get
|
||||
{
|
||||
if (!IsAvailable)
|
||||
return null;
|
||||
|
||||
int major, minor, build;
|
||||
Api->GetApiVersion(&major, &minor, &build);
|
||||
return new Version(major, minor, build);
|
||||
}
|
||||
}
|
||||
|
||||
[RenderDocApiVersion(1, 0)]
|
||||
public static OverlayBits OverlayBits
|
||||
{
|
||||
get => Api->GetOverlayBits();
|
||||
set
|
||||
{
|
||||
Api->MaskOverlayBits(~value, value);
|
||||
}
|
||||
}
|
||||
|
||||
[RenderDocApiVersion(1, 0)]
|
||||
public static string CaptureFilePathTemplate
|
||||
{
|
||||
get
|
||||
{
|
||||
byte* ptr = Api->GetCaptureFilePathTemplate();
|
||||
return Marshal.PtrToStringUTF8((IntPtr)ptr)!;
|
||||
}
|
||||
set
|
||||
{
|
||||
fixed (byte* ptr = Encoding.UTF8.GetBytes(value + '\0'))
|
||||
{
|
||||
Api->SetCaptureFilePathTemplate(ptr);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
[RenderDocApiVersion(1, 0)] public static int NumCaptures => Api->GetNumCaptures();
|
||||
|
||||
[RenderDocApiVersion(1, 0)] public static bool IsTargetControlConnected => Api->IsTargetControlConnected() != 0;
|
||||
|
||||
[RenderDocApiVersion(1, 0)] public static bool IsFrameCapturing => Api->IsFrameCapturing() != 0;
|
||||
|
||||
[RenderDocApiVersion(1, 0)]
|
||||
public static bool SetCaptureOption(CaptureOption option, int integer)
|
||||
{
|
||||
return Api->SetCaptureOptionU32(option, integer) != 0;
|
||||
}
|
||||
|
||||
[RenderDocApiVersion(1, 0)]
|
||||
public static bool SetCaptureOption(CaptureOption option, float single)
|
||||
{
|
||||
return Api->SetCaptureOptionF32(option, single) != 0;
|
||||
}
|
||||
|
||||
[RenderDocApiVersion(1, 0)]
|
||||
public static void GetCaptureOption(CaptureOption option, out int integer)
|
||||
{
|
||||
integer = Api->GetCaptureOptionU32(option);
|
||||
}
|
||||
|
||||
[RenderDocApiVersion(1, 0)]
|
||||
public static void GetCaptureOption(CaptureOption option, out float single)
|
||||
{
|
||||
single = Api->GetCaptureOptionF32(option);
|
||||
}
|
||||
|
||||
[RenderDocApiVersion(1, 0)]
|
||||
public static int GetCaptureOptionU32(CaptureOption option) => Api->GetCaptureOptionU32(option);
|
||||
|
||||
[RenderDocApiVersion(1, 0)]
|
||||
public static float GetCaptureOptionF32(CaptureOption option) => Api->GetCaptureOptionF32(option);
|
||||
|
||||
[RenderDocApiVersion(1, 0)]
|
||||
public static void SetFocusToggleKeys(ReadOnlySpan<InputButton> buttons)
|
||||
{
|
||||
fixed (InputButton* ptr = buttons)
|
||||
{
|
||||
Api->SetFocusToggleKeys(ptr, buttons.Length);
|
||||
}
|
||||
}
|
||||
|
||||
[RenderDocApiVersion(1, 0)]
|
||||
public static void SetCaptureKeys(ReadOnlySpan<InputButton> buttons)
|
||||
{
|
||||
fixed (InputButton* ptr = buttons)
|
||||
{
|
||||
Api->SetCaptureKeys(ptr, buttons.Length);
|
||||
}
|
||||
}
|
||||
|
||||
[RenderDocApiVersion(1, 0)]
|
||||
public static void RemoveHooks()
|
||||
{
|
||||
Api->RemoveHooks();
|
||||
}
|
||||
|
||||
[RenderDocApiVersion(1, 0)]
|
||||
public static void UnloadCrashHandler()
|
||||
{
|
||||
Api->UnloadCrashHandler();
|
||||
}
|
||||
|
||||
[RenderDocApiVersion(1, 0)]
|
||||
public static void TriggerCapture()
|
||||
{
|
||||
Api->TriggerCapture();
|
||||
}
|
||||
|
||||
[RenderDocApiVersion(1, 0)]
|
||||
public static Capture? GetCapture(int index)
|
||||
{
|
||||
int length = 0;
|
||||
if (Api->GetCapture(index, null, &length, null) == 0)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
Span<byte> bytes = stackalloc byte[length + 1];
|
||||
long timestamp;
|
||||
|
||||
fixed (byte* ptr = bytes)
|
||||
Api->GetCapture(index, ptr, &length, ×tamp);
|
||||
|
||||
string fileName = Encoding.UTF8.GetString(bytes.Slice(length));
|
||||
return new Capture(index, fileName, DateTimeOffset.FromUnixTimeSeconds(timestamp).DateTime);
|
||||
}
|
||||
|
||||
[RenderDocApiVersion(1, 0)]
|
||||
public static bool LaunchReplayUI(bool connectTargetControl, string? commandLine)
|
||||
{
|
||||
if (commandLine == null)
|
||||
{
|
||||
return Api->LaunchReplayUI(connectTargetControl ? 1 : 0, null) != 0;
|
||||
}
|
||||
|
||||
fixed (byte* ptr = Encoding.UTF8.GetBytes(commandLine + '\0'))
|
||||
{
|
||||
return Api->LaunchReplayUI(connectTargetControl ? 1 : 0, ptr) != 0;
|
||||
}
|
||||
}
|
||||
|
||||
[RenderDocApiVersion(1, 0)]
|
||||
public static void SetActiveWindow(nint hDevice, nint hWindow)
|
||||
{
|
||||
Api->SetActiveWindow((void*)hDevice, (void*)hWindow);
|
||||
}
|
||||
|
||||
[RenderDocApiVersion(1, 0)]
|
||||
public static void StartFrameCapture(nint hDevice, nint hWindow)
|
||||
{
|
||||
Api->StartFrameCapture((void*)hDevice, (void*)hWindow);
|
||||
}
|
||||
|
||||
[RenderDocApiVersion(1, 0)]
|
||||
public static bool EndFrameCapture(nint hDevice, nint hWindow)
|
||||
{
|
||||
return Api->EndFrameCapture((void*)hDevice, (void*)hWindow) != 0;
|
||||
}
|
||||
|
||||
[RenderDocApiVersion(1, 1)]
|
||||
public static void TriggerMultiFrameCapture(int numFrames)
|
||||
{
|
||||
AssertAtLeast(1, 1);
|
||||
Api->TriggerMultiFrameCapture(numFrames);
|
||||
}
|
||||
|
||||
[RenderDocApiVersion(1, 2)]
|
||||
public static void SetCaptureFileComments(string fileName, string comments)
|
||||
{
|
||||
AssertAtLeast(1, 2);
|
||||
|
||||
byte[] fileBytes = Encoding.UTF8.GetBytes(fileName + '\0');
|
||||
byte[] commentBytes = Encoding.UTF8.GetBytes(comments + '\0');
|
||||
|
||||
fixed (byte* pfile = fileBytes)
|
||||
fixed (byte* pcomment = commentBytes)
|
||||
{
|
||||
Api->SetCaptureFileComments(pfile, pcomment);
|
||||
}
|
||||
}
|
||||
|
||||
[RenderDocApiVersion(1, 3)]
|
||||
public static void DiscardFrameCapture(nint hdevice, nint hWindow)
|
||||
{
|
||||
AssertAtLeast(1, 3);
|
||||
Api->DiscardFrameCapture((void*)hdevice, (void*)hWindow);
|
||||
}
|
||||
|
||||
[RenderDocApiVersion(1, 5)]
|
||||
public static bool ShowReplayUI()
|
||||
{
|
||||
AssertAtLeast(1, 5);
|
||||
return Api->ShowReplayUI() != 0;
|
||||
}
|
||||
|
||||
[RenderDocApiVersion(1, 6)]
|
||||
public static void SetCaptureTitle(string title)
|
||||
{
|
||||
AssertAtLeast(1, 6);
|
||||
fixed (byte* ptr = Encoding.UTF8.GetBytes(title + '\0'))
|
||||
Api->SetCaptureTitle(ptr);
|
||||
}
|
||||
|
||||
public static void ReloadApi(bool ignoreAlreadyLoaded = false)
|
||||
{
|
||||
if (_loaded && !ignoreAlreadyLoaded)
|
||||
return;
|
||||
|
||||
lock (typeof(RenderDoc))
|
||||
{
|
||||
// Prevent double loads.
|
||||
if (_loaded && !ignoreAlreadyLoaded)
|
||||
return;
|
||||
|
||||
_loaded = true;
|
||||
_api = GetApi(SystemVersionToRenderdocVersion(MinimumRequired));
|
||||
|
||||
if (_api != null)
|
||||
AssertAtLeast(MinimumRequired.Major, MinimumRequired.Minor, MinimumRequired.Build);
|
||||
}
|
||||
}
|
||||
|
||||
private static RenderDocApi* _api = null;
|
||||
private static bool _loaded = false;
|
||||
|
||||
private static RenderDocApi* Api
|
||||
{
|
||||
get
|
||||
{
|
||||
ReloadApi();
|
||||
return _api;
|
||||
}
|
||||
}
|
||||
|
||||
private static RenderDocApi* GetApi(RenderDocVersion minimumRequired = RenderDocVersion.Version_1_0_0)
|
||||
{
|
||||
Regex re = new Regex(@"(lib)?renderdoc(\.dll|\.so|\.dylib)(\.\d+)?",
|
||||
RegexOptions.CultureInvariant | RegexOptions.IgnoreCase);
|
||||
|
||||
foreach (ProcessModule module in Process.GetCurrentProcess().Modules)
|
||||
{
|
||||
string moduleName = module.FileName ?? string.Empty;
|
||||
|
||||
if (!re.IsMatch(moduleName))
|
||||
continue;
|
||||
|
||||
if (!NativeLibrary.TryLoad(moduleName, out IntPtr moduleHandle))
|
||||
return null;
|
||||
|
||||
if (!NativeLibrary.TryGetExport(moduleHandle, "RENDERDOC_GetAPI", out IntPtr procAddress))
|
||||
return null;
|
||||
|
||||
var RENDERDOC_GetApi = (delegate* unmanaged[Cdecl]<RenderDocVersion, RenderDocApi**, int>)procAddress;
|
||||
|
||||
RenderDocApi* api;
|
||||
return RENDERDOC_GetApi(minimumRequired, &api) != 0 ? api : null;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static void AssertAtLeast(int major, int minor, int patch = 0, [CallerMemberName] string callee = "")
|
||||
{
|
||||
if (!AssertVersionEnabled)
|
||||
return;
|
||||
|
||||
if (Version!.Major < major)
|
||||
goto fail;
|
||||
|
||||
if (Version.Major > major)
|
||||
goto success;
|
||||
if (Version.Minor < minor)
|
||||
goto fail;
|
||||
if (Version.Minor > minor)
|
||||
goto success;
|
||||
if (Version.Build < patch)
|
||||
goto fail;
|
||||
|
||||
success:
|
||||
return;
|
||||
|
||||
fail:
|
||||
Version minVersion =
|
||||
typeof(RenderDoc).GetMethod(callee)!.GetCustomAttribute<RenderDocApiVersionAttribute>()!.MinVersion;
|
||||
throw new NotSupportedException(
|
||||
$"This API was introduced in RenderdocAPI {minVersion}. Current API version is {Version}.");
|
||||
}
|
||||
|
||||
private static Version RenderdocVersionToSystemVersion(RenderDocVersion version)
|
||||
{
|
||||
int i = (int)version;
|
||||
return new Version(i / 10000, (i % 10000) / 100, i % 100);
|
||||
}
|
||||
|
||||
private static RenderDocVersion SystemVersionToRenderdocVersion(Version version) =>
|
||||
(RenderDocVersion)(version.Major * 10000 + version.Minor * 100 + version.Build);
|
||||
}
|
||||
}
|
||||
51
src/Ryujinx.Graphics.RenderDocApi/RenderDocApi.cs
Normal file
51
src/Ryujinx.Graphics.RenderDocApi/RenderDocApi.cs
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
namespace Ryujinx.Graphics.RenderDocApi
|
||||
{
|
||||
#pragma warning disable CS0649
|
||||
internal unsafe struct RenderDocApi
|
||||
{
|
||||
public delegate* unmanaged[Cdecl]<int*, int*, int*, void> GetApiVersion;
|
||||
|
||||
public delegate* unmanaged[Cdecl]<CaptureOption, int, int> SetCaptureOptionU32;
|
||||
public delegate* unmanaged[Cdecl]<CaptureOption, float, int> SetCaptureOptionF32;
|
||||
public delegate* unmanaged[Cdecl]<CaptureOption, int> GetCaptureOptionU32;
|
||||
public delegate* unmanaged[Cdecl]<CaptureOption, float> GetCaptureOptionF32;
|
||||
|
||||
public delegate* unmanaged[Cdecl]<InputButton*, int, void> SetFocusToggleKeys;
|
||||
public delegate* unmanaged[Cdecl]<InputButton*, int, void> SetCaptureKeys;
|
||||
|
||||
public delegate* unmanaged[Cdecl]<OverlayBits> GetOverlayBits;
|
||||
public delegate* unmanaged[Cdecl]<OverlayBits, OverlayBits, void> MaskOverlayBits;
|
||||
|
||||
public delegate* unmanaged[Cdecl]<void> RemoveHooks;
|
||||
public delegate* unmanaged[Cdecl]<void> UnloadCrashHandler;
|
||||
public delegate* unmanaged[Cdecl]<byte*, void> SetCaptureFilePathTemplate;
|
||||
public delegate* unmanaged[Cdecl]<byte*> GetCaptureFilePathTemplate;
|
||||
|
||||
public delegate* unmanaged[Cdecl]<int> GetNumCaptures;
|
||||
public delegate* unmanaged[Cdecl]<int, byte*, int*, long*, int> GetCapture;
|
||||
public delegate* unmanaged[Cdecl]<void> TriggerCapture;
|
||||
public delegate* unmanaged[Cdecl]<int> IsTargetControlConnected;
|
||||
public delegate* unmanaged[Cdecl]<int, byte*, int> LaunchReplayUI;
|
||||
|
||||
public delegate* unmanaged[Cdecl]<void*, void*, void> SetActiveWindow;
|
||||
public delegate* unmanaged[Cdecl]<void*, void*, void> StartFrameCapture;
|
||||
public delegate* unmanaged[Cdecl]<int> IsFrameCapturing;
|
||||
public delegate* unmanaged[Cdecl]<void*, void*, int> EndFrameCapture;
|
||||
|
||||
// 1.1
|
||||
public delegate* unmanaged[Cdecl]<int, void> TriggerMultiFrameCapture;
|
||||
|
||||
// 1.2
|
||||
public delegate* unmanaged[Cdecl]<byte*, byte*, void> SetCaptureFileComments;
|
||||
|
||||
// 1.3
|
||||
public delegate* unmanaged[Cdecl]<void*, void*, int> DiscardFrameCapture;
|
||||
|
||||
// 1.5
|
||||
public delegate* unmanaged[Cdecl]<int> ShowReplayUI;
|
||||
|
||||
// 1.6
|
||||
public delegate* unmanaged[Cdecl]<byte*, void> SetCaptureTitle;
|
||||
}
|
||||
#pragma warning restore CS0649
|
||||
}
|
||||
|
|
@ -0,0 +1,16 @@
|
|||
|
||||
using System;
|
||||
|
||||
namespace Ryujinx.Graphics.RenderDocApi
|
||||
{
|
||||
[AttributeUsage(AttributeTargets.Method | AttributeTargets.Property)]
|
||||
public sealed class RenderDocApiVersionAttribute : Attribute
|
||||
{
|
||||
public Version MinVersion { get; }
|
||||
|
||||
public RenderDocApiVersionAttribute(int major, int minor, int patch = 0)
|
||||
{
|
||||
MinVersion = new Version(major, minor, patch);
|
||||
}
|
||||
}
|
||||
}
|
||||
19
src/Ryujinx.Graphics.RenderDocApi/RenderDocVersion.cs
Normal file
19
src/Ryujinx.Graphics.RenderDocApi/RenderDocVersion.cs
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
namespace Ryujinx.Graphics.RenderDocApi
|
||||
{
|
||||
internal enum RenderDocVersion : int
|
||||
{
|
||||
Version_1_0_0 = 10000,
|
||||
Version_1_0_1 = 10001,
|
||||
Version_1_0_2 = 10002,
|
||||
Version_1_1_0 = 10100,
|
||||
Version_1_1_1 = 10101,
|
||||
Version_1_1_2 = 10102,
|
||||
Version_1_2_0 = 10200,
|
||||
Version_1_3_0 = 10300,
|
||||
Version_1_4_0 = 10400,
|
||||
Version_1_4_1 = 10401,
|
||||
Version_1_4_2 = 10402,
|
||||
Version_1_5_0 = 10500,
|
||||
Version_1_6_0 = 10600,
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,15 @@
|
|||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>disable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<AllowUnsafeBlocks>true</AllowUnsafeBlocks>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="../Ryujinx.Graphics.Vulkan/Ryujinx.Graphics.Vulkan.csproj" />
|
||||
<ProjectReference Include="../Ryujinx.Graphics.OpenGL/Ryujinx.Graphics.OpenGL.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
32
src/Ryujinx.Graphics.Vulkan/Helpers.cs
Normal file
32
src/Ryujinx.Graphics.Vulkan/Helpers.cs
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
using Silk.NET.Vulkan;
|
||||
using System.Runtime.CompilerServices;
|
||||
|
||||
namespace Ryujinx.Graphics.Vulkan
|
||||
{
|
||||
public static class Helpers
|
||||
{
|
||||
extension(Vk api)
|
||||
{
|
||||
/// <summary>
|
||||
/// C# implementation of the RENDERDOC_DEVICEPOINTER_FROM_VKINSTANCE macro from the RenderDoc API header, since we cannot use macros from C#.
|
||||
/// </summary>
|
||||
/// <returns>The dispatch table pointer, which sits as the first pointer-sized object in the memory pointed to by the <see cref="Vk"/>'s <see cref="Instance"/> pointer.</returns>
|
||||
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
||||
public unsafe void* GetRenderDocDevicePointer() =>
|
||||
api.CurrentInstance is not null
|
||||
? api.CurrentInstance.Value.GetRenderDocDevicePointer()
|
||||
: null;
|
||||
}
|
||||
|
||||
extension(Instance instance)
|
||||
{
|
||||
/// <summary>
|
||||
/// C# implementation of the RENDERDOC_DEVICEPOINTER_FROM_VKINSTANCE macro from the RenderDoc API header, since we cannot use macros from C#.
|
||||
/// </summary>
|
||||
/// <returns>The dispatch table pointer, which sits as the first pointer-sized object in the memory pointed to by the <see cref="Instance"/>'s pointer.</returns>
|
||||
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
||||
public unsafe void* GetRenderDocDevicePointer()
|
||||
=> (*((void**)(instance.Handle)));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -18,6 +18,7 @@ using Ryujinx.Common.GraphicsDriver;
|
|||
using Ryujinx.Common.Logging;
|
||||
using Ryujinx.Common.SystemInterop;
|
||||
using Ryujinx.Common.Utilities;
|
||||
using Ryujinx.Graphics.RenderDocApi;
|
||||
using Ryujinx.Graphics.Vulkan.MoltenVK;
|
||||
using Ryujinx.Headless;
|
||||
using Ryujinx.SDL3.Common;
|
||||
|
|
|
|||
|
|
@ -78,7 +78,7 @@
|
|||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\Ryujinx.Audio.Backends.SDL3\Ryujinx.Audio.Backends.SDL3.csproj" />
|
||||
<ProjectReference Include="..\Ryujinx.Graphics.Vulkan\Ryujinx.Graphics.Vulkan.csproj" />
|
||||
<ProjectReference Include="..\Ryujinx.Graphics.RenderDocApi\Ryujinx.Graphics.RenderDocApi.csproj" />
|
||||
<ProjectReference Include="..\Ryujinx.Input\Ryujinx.Input.csproj" />
|
||||
<ProjectReference Include="..\Ryujinx.Input.SDL3\Ryujinx.Input.SDL3.csproj" />
|
||||
<ProjectReference Include="..\Ryujinx.Audio.Backends.OpenAL\Ryujinx.Audio.Backends.OpenAL.csproj" />
|
||||
|
|
@ -86,7 +86,6 @@
|
|||
<ProjectReference Include="..\Ryujinx.Common\Ryujinx.Common.csproj" />
|
||||
<ProjectReference Include="..\Ryujinx.HLE\Ryujinx.HLE.csproj" />
|
||||
<ProjectReference Include="..\ARMeilleure\ARMeilleure.csproj" />
|
||||
<ProjectReference Include="..\Ryujinx.Graphics.OpenGL\Ryujinx.Graphics.OpenGL.csproj" />
|
||||
<ProjectReference Include="..\Ryujinx.Graphics.Gpu\Ryujinx.Graphics.Gpu.csproj" />
|
||||
<ProjectReference Include="..\Ryujinx.UI.LocaleGenerator\Ryujinx.UI.LocaleGenerator.csproj" OutputItemType="Analyzer" ReferenceOutputAssembly="false" />
|
||||
</ItemGroup>
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ using Avalonia.Platform;
|
|||
using Ryujinx.Ava.Systems.Configuration;
|
||||
using Ryujinx.Common.Configuration;
|
||||
using Ryujinx.Common.Helper;
|
||||
using Ryujinx.Graphics.RenderDocApi;
|
||||
using SPB.Graphics;
|
||||
using SPB.Platform;
|
||||
using SPB.Platform.GLX;
|
||||
|
|
@ -30,6 +31,7 @@ namespace Ryujinx.Ava.UI.Renderer
|
|||
protected nint MetalLayer { get; set; }
|
||||
|
||||
public delegate void UpdateBoundsCallbackDelegate(Rect rect);
|
||||
|
||||
private UpdateBoundsCallbackDelegate _updateBoundsCallback;
|
||||
|
||||
public event EventHandler<nint> WindowCreated;
|
||||
|
|
@ -46,6 +48,23 @@ namespace Ryujinx.Ava.UI.Renderer
|
|||
|
||||
protected virtual void OnWindowDestroyed() { }
|
||||
|
||||
public void StartRenderDocCapture()
|
||||
{
|
||||
if (!RenderDoc.IsAvailable) return;
|
||||
|
||||
if (RenderDoc.IsFrameCapturing) return;
|
||||
|
||||
try
|
||||
{
|
||||
RenderDoc.StartFrameCapture(nint.Zero, WindowHandle);
|
||||
} catch {}
|
||||
}
|
||||
|
||||
public bool EndRenderDocCapture()
|
||||
{
|
||||
return RenderDoc.IsAvailable && RenderDoc.EndFrameCapture(nint.Zero, WindowHandle);
|
||||
}
|
||||
|
||||
protected virtual void OnWindowDestroying()
|
||||
{
|
||||
WindowHandle = nint.Zero;
|
||||
|
|
@ -124,7 +143,9 @@ namespace Ryujinx.Ava.UI.Renderer
|
|||
}
|
||||
else
|
||||
{
|
||||
X11Window = PlatformHelper.CreateOpenGLWindow(new FramebufferFormat(new ColorFormat(8, 8, 8, 0), 16, 0, ColorFormat.Zero, 0, 2, false), 0, 0, 100, 100) as GLXWindow;
|
||||
X11Window = PlatformHelper.CreateOpenGLWindow(
|
||||
new FramebufferFormat(new ColorFormat(8, 8, 8, 0), 16, 0, ColorFormat.Zero, 0, 2, false), 0, 0, 100,
|
||||
100) as GLXWindow;
|
||||
}
|
||||
|
||||
WindowHandle = X11Window.WindowHandle.RawHandle;
|
||||
|
|
@ -138,7 +159,7 @@ namespace Ryujinx.Ava.UI.Renderer
|
|||
{
|
||||
_className = "NativeWindow-" + Guid.NewGuid();
|
||||
|
||||
_wndProcDelegate = delegate (nint hWnd, WindowsMessages msg, nint wParam, nint lParam)
|
||||
_wndProcDelegate = delegate(nint hWnd, WindowsMessages msg, nint wParam, nint lParam)
|
||||
{
|
||||
switch (msg)
|
||||
{
|
||||
|
|
@ -161,7 +182,8 @@ namespace Ryujinx.Ava.UI.Renderer
|
|||
|
||||
RegisterClassEx(ref wndClassEx);
|
||||
|
||||
WindowHandle = CreateWindowEx(0, _className, "NativeWindow", WindowStyles.WsChild, 0, 0, 640, 480, control.Handle, nint.Zero, nint.Zero, nint.Zero);
|
||||
WindowHandle = CreateWindowEx(0, _className, "NativeWindow", WindowStyles.WsChild, 0, 0, 640, 480,
|
||||
control.Handle, nint.Zero, nint.Zero, nint.Zero);
|
||||
|
||||
SetWindowLongPtrW(control.Handle, GWLP_WNDPROC, wndClassEx.lpfnWndProc);
|
||||
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ using Ryujinx.Ava.UI.Windows;
|
|||
using Ryujinx.Ava.Utilities;
|
||||
using Ryujinx.Common;
|
||||
using Ryujinx.Common.Logging;
|
||||
using Ryujinx.Graphics.RenderDocApi;
|
||||
using System;
|
||||
using System.Diagnostics;
|
||||
|
||||
|
|
|
|||
|
|
@ -38,6 +38,7 @@ using Ryujinx.Common.Logging;
|
|||
using Ryujinx.Common.UI;
|
||||
using Ryujinx.Common.Utilities;
|
||||
using Ryujinx.Cpu;
|
||||
using Ryujinx.Graphics.RenderDocApi;
|
||||
using Ryujinx.HLE;
|
||||
using Ryujinx.HLE.FileSystem;
|
||||
using Ryujinx.HLE.HOS;
|
||||
|
|
@ -104,7 +105,7 @@ namespace Ryujinx.Ava.UI.ViewModels
|
|||
[ObservableProperty] public partial Brush ProgressBarForegroundColor { get; set; }
|
||||
|
||||
[ObservableProperty] public partial Brush ProgressBarBackgroundColor { get; set; }
|
||||
|
||||
|
||||
#pragma warning disable MVVMTK0042 // Must stay a normal observable field declaration since this is used as an out parameter target
|
||||
[ObservableProperty] private ReadOnlyObservableCollection<ApplicationData> _appsObservableList;
|
||||
#pragma warning restore MVVMTK0042
|
||||
|
|
@ -129,8 +130,7 @@ namespace Ryujinx.Ava.UI.ViewModels
|
|||
|
||||
[ObservableProperty] public partial string LastScannedAmiiboId { get; set; }
|
||||
|
||||
[ObservableProperty]
|
||||
public partial long LastFullscreenToggle { get; set; } = Environment.TickCount64;
|
||||
[ObservableProperty] public partial long LastFullscreenToggle { get; set; } = Environment.TickCount64;
|
||||
[ObservableProperty] public partial bool ShowContent { get; set; } = true;
|
||||
|
||||
[ObservableProperty] public partial float VolumeBeforeMute { get; set; }
|
||||
|
|
@ -1865,6 +1865,21 @@ namespace Ryujinx.Ava.UI.ViewModels
|
|||
}
|
||||
}
|
||||
|
||||
public void ReloadRenderDocApi()
|
||||
{
|
||||
RenderDoc.ReloadApi(ignoreAlreadyLoaded: true);
|
||||
|
||||
OnPropertiesChanged(nameof(ShowStartCaptureButton), nameof(ShowEndCaptureButton), nameof(RenderDocIsAvailable));
|
||||
|
||||
if (RenderDoc.IsAvailable)
|
||||
RenderDocIsCapturing = RenderDoc.IsFrameCapturing;
|
||||
|
||||
NotificationHelper.ShowInformation(
|
||||
"RenderDoc API reloaded",
|
||||
RenderDoc.IsAvailable ? "RenderDoc is now available." : "RenderDoc is no longer available."
|
||||
);
|
||||
}
|
||||
|
||||
public void ToggleFullscreen()
|
||||
{
|
||||
if (Environment.TickCount64 - LastFullscreenToggle < HotKeyPressDelayMs)
|
||||
|
|
@ -1955,7 +1970,8 @@ namespace Ryujinx.Ava.UI.ViewModels
|
|||
if (ConfigurationState.Instance.Debug.EnableGdbStub)
|
||||
{
|
||||
NotificationHelper.ShowInformation(
|
||||
LocaleManager.Instance.UpdateAndGetDynamicValue(LocaleKeys.NotificationLaunchCheckGdbStubTitle, ConfigurationState.Instance.Debug.GdbStubPort.Value),
|
||||
LocaleManager.Instance.UpdateAndGetDynamicValue(LocaleKeys.NotificationLaunchCheckGdbStubTitle,
|
||||
ConfigurationState.Instance.Debug.GdbStubPort.Value),
|
||||
LocaleManager.Instance[LocaleKeys.NotificationLaunchCheckGdbStubMessage]);
|
||||
}
|
||||
|
||||
|
|
@ -1964,10 +1980,12 @@ namespace Ryujinx.Ava.UI.ViewModels
|
|||
var memoryConfigurationLocaleKey = ConfigurationState.Instance.System.DramSize.Value switch
|
||||
{
|
||||
MemoryConfiguration.MemoryConfiguration4GiB or
|
||||
MemoryConfiguration.MemoryConfiguration4GiBAppletDev or
|
||||
MemoryConfiguration.MemoryConfiguration4GiBSystemDev => LocaleKeys.SettingsTabSystemDramSize4GiB,
|
||||
MemoryConfiguration.MemoryConfiguration4GiBAppletDev or
|
||||
MemoryConfiguration.MemoryConfiguration4GiBSystemDev =>
|
||||
LocaleKeys.SettingsTabSystemDramSize4GiB,
|
||||
MemoryConfiguration.MemoryConfiguration6GiB or
|
||||
MemoryConfiguration.MemoryConfiguration6GiBAppletDev => LocaleKeys.SettingsTabSystemDramSize6GiB,
|
||||
MemoryConfiguration.MemoryConfiguration6GiBAppletDev =>
|
||||
LocaleKeys.SettingsTabSystemDramSize6GiB,
|
||||
MemoryConfiguration.MemoryConfiguration8GiB => LocaleKeys.SettingsTabSystemDramSize8GiB,
|
||||
MemoryConfiguration.MemoryConfiguration12GiB => LocaleKeys.SettingsTabSystemDramSize12GiB,
|
||||
_ => LocaleKeys.SettingsTabSystemDramSize4GiB,
|
||||
|
|
@ -1975,9 +1993,9 @@ namespace Ryujinx.Ava.UI.ViewModels
|
|||
|
||||
NotificationHelper.ShowWarning(
|
||||
LocaleManager.Instance.UpdateAndGetDynamicValue(
|
||||
LocaleKeys.NotificationLaunchCheckDramSizeTitle,
|
||||
LocaleKeys.NotificationLaunchCheckDramSizeTitle,
|
||||
LocaleManager.Instance[memoryConfigurationLocaleKey]
|
||||
),
|
||||
),
|
||||
LocaleManager.Instance[LocaleKeys.NotificationLaunchCheckDramSizeMessage]);
|
||||
}
|
||||
}
|
||||
|
|
@ -2462,6 +2480,41 @@ namespace Ryujinx.Ava.UI.ViewModels
|
|||
png.SaveTo(fileStream);
|
||||
});
|
||||
|
||||
public bool ShowStartCaptureButton => !RenderDocIsCapturing && RenderDoc.IsAvailable;
|
||||
public bool ShowEndCaptureButton => RenderDocIsCapturing && RenderDoc.IsAvailable;
|
||||
public static bool RenderDocIsAvailable => RenderDoc.IsAvailable;
|
||||
|
||||
public bool RenderDocIsCapturing
|
||||
{
|
||||
get;
|
||||
set
|
||||
{
|
||||
field = value;
|
||||
OnPropertyChanged();
|
||||
OnPropertiesChanged(nameof(ShowStartCaptureButton), nameof(ShowEndCaptureButton));
|
||||
}
|
||||
}
|
||||
|
||||
public static RelayCommand<MainWindowViewModel> StartRenderDocCapture { get; } =
|
||||
Commands.CreateConditional<MainWindowViewModel>(_ => RenderDoc.IsAvailable,
|
||||
viewModel =>
|
||||
{
|
||||
if (!RenderDoc.IsFrameCapturing)
|
||||
viewModel.AppHost.RendererHost.EmbeddedWindow.StartRenderDocCapture();
|
||||
|
||||
viewModel.RenderDocIsCapturing = true;
|
||||
});
|
||||
|
||||
public static RelayCommand<MainWindowViewModel> EndRenderDocCapture { get; } =
|
||||
Commands.CreateConditional<MainWindowViewModel>(_ => RenderDoc.IsAvailable,
|
||||
viewModel =>
|
||||
{
|
||||
if (RenderDoc.IsFrameCapturing)
|
||||
viewModel.AppHost.RendererHost.EmbeddedWindow.EndRenderDocCapture();
|
||||
|
||||
viewModel.RenderDocIsCapturing = false;
|
||||
});
|
||||
|
||||
#endregion
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@
|
|||
xmlns:viewModels="clr-namespace:Ryujinx.Ava.UI.ViewModels"
|
||||
xmlns:controls="clr-namespace:Ryujinx.Ava.UI.Controls"
|
||||
xmlns:common="clr-namespace:Ryujinx.Common;assembly=Ryujinx.Common"
|
||||
xmlns:renderDocApi="clr-namespace:Ryujinx.Graphics.RenderDocApi;assembly=Ryujinx.Graphics.RenderDocApi"
|
||||
x:DataType="viewModels:MainWindowViewModel"
|
||||
x:Class="Ryujinx.Ava.UI.Views.Main.MainMenuBarView">
|
||||
<Design.DataContext>
|
||||
|
|
@ -200,6 +201,21 @@
|
|||
Header="{ext:Locale GameListContextMenuManageCheat}"
|
||||
Icon="{ext:Icon fa-solid fa-code}"
|
||||
IsEnabled="{Binding IsGameRunning}" />
|
||||
<Separator IsVisible="{Binding RenderDocIsAvailable}" />
|
||||
<MenuItem
|
||||
IsVisible="{Binding ShowStartCaptureButton}"
|
||||
Command="{Binding StartRenderDocCapture}"
|
||||
CommandParameter="{Binding}"
|
||||
Header="Start RenderDoc Frame Capture"
|
||||
Icon="{ext:Icon fa-solid fa-video}"
|
||||
IsEnabled="{Binding IsGameRunning}" />
|
||||
<MenuItem
|
||||
IsVisible="{Binding ShowEndCaptureButton}"
|
||||
Command="{Binding EndRenderDocCapture}"
|
||||
CommandParameter="{Binding}"
|
||||
Header="End RenderDoc Frame Capture"
|
||||
Icon="{ext:Icon fa-solid fa-video-slash}"
|
||||
IsEnabled="{Binding IsGameRunning}" />
|
||||
</MenuItem>
|
||||
<MenuItem VerticalAlignment="Center" Header="{ext:Locale MenuBarActions}" IsVisible="{Binding EnableNonGameRunningControls}">
|
||||
<MenuItem Header="{ext:Locale MenuBarActionsInstallKeys}" Icon="{ext:Icon fa-solid fa-key}">
|
||||
|
|
|
|||
|
|
@ -41,6 +41,7 @@
|
|||
<KeyBinding Gesture="Escape" Command="{Binding ExitCurrentState}" />
|
||||
<KeyBinding Gesture="Ctrl+A" Command="{Binding OpenAmiiboWindow}" />
|
||||
<KeyBinding Gesture="Ctrl+B" Command="{Binding OpenBinFile}" />
|
||||
<KeyBinding Gesture="Ctrl+Shift+R" Command="{Binding ReloadRenderDocApi}" />
|
||||
</Window.KeyBindings>
|
||||
<Grid HorizontalAlignment="Stretch" VerticalAlignment="Stretch" RowDefinitions="*">
|
||||
<helpers:OffscreenTextBox IsEnabled="False" Opacity="0" Name="HiddenTextBox" IsHitTestVisible="False" IsTabStop="False" />
|
||||
|
|
|
|||
Loading…
Reference in a new issue