diff --git a/Directory.Packages.props b/Directory.Packages.props
index 2831916..3df7e6d 100644
--- a/Directory.Packages.props
+++ b/Directory.Packages.props
@@ -9,5 +9,8 @@ SPDX-License-Identifier: GPL-2.0-or-later
+
+
+
diff --git a/src/SharpEmu.CLI/packages.lock.json b/src/SharpEmu.CLI/packages.lock.json
index d230926..24f5e4c 100644
--- a/src/SharpEmu.CLI/packages.lock.json
+++ b/src/SharpEmu.CLI/packages.lock.json
@@ -8,6 +8,62 @@
"resolved": "10.0.3",
"contentHash": "0B6nZyCHWXnvmlB559oduOspVdNOnpNXPjhpWVMovLPAsDVG7A4jJR9rzECf67JUzxP8/ee/wA8clwIzJcWNFA=="
},
+ "Microsoft.DotNet.PlatformAbstractions": {
+ "type": "Transitive",
+ "resolved": "3.1.6",
+ "contentHash": "jek4XYaQ/PGUwDKKhwR8K47Uh1189PFzMeLqO83mXrXQVIpARZCcfuDedH50YDTepBkfijCZN5U/vZi++erxtg=="
+ },
+ "Microsoft.Extensions.DependencyModel": {
+ "type": "Transitive",
+ "resolved": "9.0.9",
+ "contentHash": "fNGvKct2De8ghm0Bpfq0iWthtzIWabgOTi+gJhNOPhNJIowXNEUE2eZNW/zNCzrHVA3PXg2yZ+3cWZndC2IqYA=="
+ },
+ "Silk.NET.Core": {
+ "type": "Transitive",
+ "resolved": "2.23.0",
+ "contentHash": "D7AT/nnwlB+4RZ84XY8QNGBZMJI5z9l4CSSETIJ1wCfRJzRt/341y3MRZ4HbnFz4r/IGaWOEZr86iE+0/65yyQ==",
+ "dependencies": {
+ "Microsoft.DotNet.PlatformAbstractions": "3.1.6",
+ "Microsoft.Extensions.DependencyModel": "9.0.9"
+ }
+ },
+ "Silk.NET.GLFW": {
+ "type": "Transitive",
+ "resolved": "2.23.0",
+ "contentHash": "UIs4sH57xlPUNHQ/1bt9rymPWlGy8IMDCNv86h0iM4TOA1CkIx0XM/n/tA4AReh1zQkNrvkxPEdZ3Blvy1dyXg==",
+ "dependencies": {
+ "Silk.NET.Core": "2.23.0",
+ "Ultz.Native.GLFW": "3.4.0"
+ }
+ },
+ "Silk.NET.Maths": {
+ "type": "Transitive",
+ "resolved": "2.23.0",
+ "contentHash": "r8PdIVzME8EH0qAgbmRPO87I4GfgR2j8TofT7EMuRJDf1QluoQwnVypDoFJjQ2ZBSRsGYk5unYxxogI05Ogsmw=="
+ },
+ "Silk.NET.Windowing.Common": {
+ "type": "Transitive",
+ "resolved": "2.23.0",
+ "contentHash": "ThStSinmY9KQI8DGiF5XEhkLJVnBcgRTBTzL9ijg1wMZAYuckz7ykrNw04fjRm2Gryh6tCNGbvz2XaY0efeFzg==",
+ "dependencies": {
+ "Silk.NET.Core": "2.23.0",
+ "Silk.NET.Maths": "2.23.0"
+ }
+ },
+ "Silk.NET.Windowing.Glfw": {
+ "type": "Transitive",
+ "resolved": "2.23.0",
+ "contentHash": "aYBudKmENmvLRn9p15HbdvlQTnnXskcDfTfbYwSb/4fr263rGLwYuDw/txUEc2jihHJiWCp5+75Y7z5wTJWl7g==",
+ "dependencies": {
+ "Silk.NET.GLFW": "2.23.0",
+ "Silk.NET.Windowing.Common": "2.23.0"
+ }
+ },
+ "Ultz.Native.GLFW": {
+ "type": "Transitive",
+ "resolved": "3.4.0",
+ "contentHash": "Iy22JopynbOJ32vA0lBhFEzGi65GQJBuJHYBYRBpydrDpNoTiHnjIXfA65Gu+8qsOr/ZEoIF8r9aHCgAXuO6DA=="
+ },
"sharpemu.core": {
"type": "Project",
"dependencies": {
@@ -23,7 +79,10 @@
"sharpemu.libs": {
"type": "Project",
"dependencies": {
- "SharpEmu.HLE": "[1.0.0, )"
+ "SharpEmu.HLE": "[1.0.0, )",
+ "Silk.NET.Vulkan": "[2.23.0, )",
+ "Silk.NET.Vulkan.Extensions.KHR": "[2.23.0, )",
+ "Silk.NET.Windowing": "[2.23.0, )"
}
},
"sharpemu.logging": {
@@ -34,10 +93,57 @@
"requested": "[1.21.0, )",
"resolved": "1.21.0",
"contentHash": "dv5+81Q1TBQvVMSOOOmRcjJmvWcX3BZPZsIq31+RLc5cNft0IHAyNlkdb7ZarOWG913PyBoFDsDXoCIlKmLclg=="
+ },
+ "Silk.NET.Vulkan": {
+ "type": "CentralTransitive",
+ "requested": "[2.23.0, )",
+ "resolved": "2.23.0",
+ "contentHash": "3/irtlSWXZ3eTi8N6nelI6L34NTB8ZJHpqVMNzZx2aX7Ek9YEQ34NoQW8/Tljrtmkg8KRhHW8hKTEzZaKV8PgA==",
+ "dependencies": {
+ "Silk.NET.Core": "2.23.0"
+ }
+ },
+ "Silk.NET.Vulkan.Extensions.KHR": {
+ "type": "CentralTransitive",
+ "requested": "[2.23.0, )",
+ "resolved": "2.23.0",
+ "contentHash": "uRaf4j+SmH3DumjSSSUbFg33BnsGZUyXGj93O9NgGKZSJN3OTmNmQDxRew+/KiVLcgH6qzbto8aNGZ++j9GFWg==",
+ "dependencies": {
+ "Silk.NET.Core": "2.23.0",
+ "Silk.NET.Vulkan": "2.23.0"
+ }
+ },
+ "Silk.NET.Windowing": {
+ "type": "CentralTransitive",
+ "requested": "[2.23.0, )",
+ "resolved": "2.23.0",
+ "contentHash": "OPNPmt/lRyUKVYrFLQXVxyATqD3MKLc1iY1oKx1/2GppgmZxVZPwN12tekrQ4C7408kgB1L5JD1Wnirqqeb2kg==",
+ "dependencies": {
+ "Silk.NET.Windowing.Common": "2.23.0",
+ "Silk.NET.Windowing.Glfw": "2.23.0"
+ }
}
},
- "net10.0/linux-x64": {},
- "net10.0/osx-arm64": {},
- "net10.0/win-x64": {}
+ "net10.0/linux-x64": {
+ "Ultz.Native.GLFW": {
+ "type": "Transitive",
+ "resolved": "3.4.0",
+ "contentHash": "Iy22JopynbOJ32vA0lBhFEzGi65GQJBuJHYBYRBpydrDpNoTiHnjIXfA65Gu+8qsOr/ZEoIF8r9aHCgAXuO6DA=="
+ }
+ },
+ "net10.0/osx-arm64": {
+ "Ultz.Native.GLFW": {
+ "type": "Transitive",
+ "resolved": "3.4.0",
+ "contentHash": "Iy22JopynbOJ32vA0lBhFEzGi65GQJBuJHYBYRBpydrDpNoTiHnjIXfA65Gu+8qsOr/ZEoIF8r9aHCgAXuO6DA=="
+ }
+ },
+ "net10.0/win-x64": {
+ "Ultz.Native.GLFW": {
+ "type": "Transitive",
+ "resolved": "3.4.0",
+ "contentHash": "Iy22JopynbOJ32vA0lBhFEzGi65GQJBuJHYBYRBpydrDpNoTiHnjIXfA65Gu+8qsOr/ZEoIF8r9aHCgAXuO6DA=="
+ }
+ }
}
}
\ No newline at end of file
diff --git a/src/SharpEmu.Core/packages.lock.json b/src/SharpEmu.Core/packages.lock.json
index fa389c2..d70a0d5 100644
--- a/src/SharpEmu.Core/packages.lock.json
+++ b/src/SharpEmu.Core/packages.lock.json
@@ -8,17 +8,105 @@
"resolved": "1.21.0",
"contentHash": "dv5+81Q1TBQvVMSOOOmRcjJmvWcX3BZPZsIq31+RLc5cNft0IHAyNlkdb7ZarOWG913PyBoFDsDXoCIlKmLclg=="
},
+ "Microsoft.DotNet.PlatformAbstractions": {
+ "type": "Transitive",
+ "resolved": "3.1.6",
+ "contentHash": "jek4XYaQ/PGUwDKKhwR8K47Uh1189PFzMeLqO83mXrXQVIpARZCcfuDedH50YDTepBkfijCZN5U/vZi++erxtg=="
+ },
+ "Microsoft.Extensions.DependencyModel": {
+ "type": "Transitive",
+ "resolved": "9.0.9",
+ "contentHash": "fNGvKct2De8ghm0Bpfq0iWthtzIWabgOTi+gJhNOPhNJIowXNEUE2eZNW/zNCzrHVA3PXg2yZ+3cWZndC2IqYA=="
+ },
+ "Silk.NET.Core": {
+ "type": "Transitive",
+ "resolved": "2.23.0",
+ "contentHash": "D7AT/nnwlB+4RZ84XY8QNGBZMJI5z9l4CSSETIJ1wCfRJzRt/341y3MRZ4HbnFz4r/IGaWOEZr86iE+0/65yyQ==",
+ "dependencies": {
+ "Microsoft.DotNet.PlatformAbstractions": "3.1.6",
+ "Microsoft.Extensions.DependencyModel": "9.0.9"
+ }
+ },
+ "Silk.NET.GLFW": {
+ "type": "Transitive",
+ "resolved": "2.23.0",
+ "contentHash": "UIs4sH57xlPUNHQ/1bt9rymPWlGy8IMDCNv86h0iM4TOA1CkIx0XM/n/tA4AReh1zQkNrvkxPEdZ3Blvy1dyXg==",
+ "dependencies": {
+ "Silk.NET.Core": "2.23.0",
+ "Ultz.Native.GLFW": "3.4.0"
+ }
+ },
+ "Silk.NET.Maths": {
+ "type": "Transitive",
+ "resolved": "2.23.0",
+ "contentHash": "r8PdIVzME8EH0qAgbmRPO87I4GfgR2j8TofT7EMuRJDf1QluoQwnVypDoFJjQ2ZBSRsGYk5unYxxogI05Ogsmw=="
+ },
+ "Silk.NET.Windowing.Common": {
+ "type": "Transitive",
+ "resolved": "2.23.0",
+ "contentHash": "ThStSinmY9KQI8DGiF5XEhkLJVnBcgRTBTzL9ijg1wMZAYuckz7ykrNw04fjRm2Gryh6tCNGbvz2XaY0efeFzg==",
+ "dependencies": {
+ "Silk.NET.Core": "2.23.0",
+ "Silk.NET.Maths": "2.23.0"
+ }
+ },
+ "Silk.NET.Windowing.Glfw": {
+ "type": "Transitive",
+ "resolved": "2.23.0",
+ "contentHash": "aYBudKmENmvLRn9p15HbdvlQTnnXskcDfTfbYwSb/4fr263rGLwYuDw/txUEc2jihHJiWCp5+75Y7z5wTJWl7g==",
+ "dependencies": {
+ "Silk.NET.GLFW": "2.23.0",
+ "Silk.NET.Windowing.Common": "2.23.0"
+ }
+ },
+ "Ultz.Native.GLFW": {
+ "type": "Transitive",
+ "resolved": "3.4.0",
+ "contentHash": "Iy22JopynbOJ32vA0lBhFEzGi65GQJBuJHYBYRBpydrDpNoTiHnjIXfA65Gu+8qsOr/ZEoIF8r9aHCgAXuO6DA=="
+ },
"sharpemu.hle": {
"type": "Project"
},
"sharpemu.libs": {
"type": "Project",
"dependencies": {
- "SharpEmu.HLE": "[1.0.0, )"
+ "SharpEmu.HLE": "[1.0.0, )",
+ "Silk.NET.Vulkan": "[2.23.0, )",
+ "Silk.NET.Vulkan.Extensions.KHR": "[2.23.0, )",
+ "Silk.NET.Windowing": "[2.23.0, )"
}
},
"sharpemu.logging": {
"type": "Project"
+ },
+ "Silk.NET.Vulkan": {
+ "type": "CentralTransitive",
+ "requested": "[2.23.0, )",
+ "resolved": "2.23.0",
+ "contentHash": "3/irtlSWXZ3eTi8N6nelI6L34NTB8ZJHpqVMNzZx2aX7Ek9YEQ34NoQW8/Tljrtmkg8KRhHW8hKTEzZaKV8PgA==",
+ "dependencies": {
+ "Silk.NET.Core": "2.23.0"
+ }
+ },
+ "Silk.NET.Vulkan.Extensions.KHR": {
+ "type": "CentralTransitive",
+ "requested": "[2.23.0, )",
+ "resolved": "2.23.0",
+ "contentHash": "uRaf4j+SmH3DumjSSSUbFg33BnsGZUyXGj93O9NgGKZSJN3OTmNmQDxRew+/KiVLcgH6qzbto8aNGZ++j9GFWg==",
+ "dependencies": {
+ "Silk.NET.Core": "2.23.0",
+ "Silk.NET.Vulkan": "2.23.0"
+ }
+ },
+ "Silk.NET.Windowing": {
+ "type": "CentralTransitive",
+ "requested": "[2.23.0, )",
+ "resolved": "2.23.0",
+ "contentHash": "OPNPmt/lRyUKVYrFLQXVxyATqD3MKLc1iY1oKx1/2GppgmZxVZPwN12tekrQ4C7408kgB1L5JD1Wnirqqeb2kg==",
+ "dependencies": {
+ "Silk.NET.Windowing.Common": "2.23.0",
+ "Silk.NET.Windowing.Glfw": "2.23.0"
+ }
}
}
}
diff --git a/src/SharpEmu.Libs/SharpEmu.Libs.csproj b/src/SharpEmu.Libs/SharpEmu.Libs.csproj
index 9ee1fdb..13893e6 100644
--- a/src/SharpEmu.Libs/SharpEmu.Libs.csproj
+++ b/src/SharpEmu.Libs/SharpEmu.Libs.csproj
@@ -8,6 +8,12 @@ SPDX-License-Identifier: GPL-2.0-or-later
+
+
+
+
+
+
true
diff --git a/src/SharpEmu.Libs/VideoOut/VideoOutExports.cs b/src/SharpEmu.Libs/VideoOut/VideoOutExports.cs
index d39ccbb..ebedf77 100644
--- a/src/SharpEmu.Libs/VideoOut/VideoOutExports.cs
+++ b/src/SharpEmu.Libs/VideoOut/VideoOutExports.cs
@@ -292,6 +292,25 @@ public static class VideoOutExports
public static int SubmitFlipFromAgc(CpuContext ctx, int handle, int bufferIndex, int flipMode, long flipArg) =>
SubmitFlip(ctx, handle, bufferIndex, flipMode, flipArg);
+ internal static void SubmitHostRgbaFrame(ReadOnlySpan rgbaFrame, uint width, uint height)
+ {
+ if (rgbaFrame.Length != checked((int)(width * height * 4)))
+ {
+ return;
+ }
+
+ var bgraFrame = new byte[rgbaFrame.Length];
+ for (var offset = 0; offset < rgbaFrame.Length; offset += 4)
+ {
+ bgraFrame[offset + 0] = rgbaFrame[offset + 2];
+ bgraFrame[offset + 1] = rgbaFrame[offset + 1];
+ bgraFrame[offset + 2] = rgbaFrame[offset + 0];
+ bgraFrame[offset + 3] = rgbaFrame[offset + 3];
+ }
+
+ VulkanVideoPresenter.Submit(bgraFrame, width, height);
+ }
+
internal static bool TryGetDisplayBufferInfo(int handle, int bufferIndex, out DisplayBufferInfo info)
{
info = default;
@@ -608,7 +627,13 @@ public static class VideoOutExports
flipEvents = new List(port.FlipEvents);
}
- _ = TryDumpFrame(ctx, port, bufferIndex, flipMode, flipArg);
+ if (string.Equals(
+ Environment.GetEnvironmentVariable("SHARPEMU_DUMP_VIDEOOUT"),
+ "1",
+ StringComparison.Ordinal))
+ {
+ _ = TryDumpFrame(ctx, port, bufferIndex, flipMode, flipArg);
+ }
foreach (var flipEvent in flipEvents)
{
diff --git a/src/SharpEmu.Libs/VideoOut/VulkanVideoPresenter.cs b/src/SharpEmu.Libs/VideoOut/VulkanVideoPresenter.cs
new file mode 100644
index 0000000..f8b59f3
--- /dev/null
+++ b/src/SharpEmu.Libs/VideoOut/VulkanVideoPresenter.cs
@@ -0,0 +1,702 @@
+// 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 Silk.NET.Vulkan;
+using Silk.NET.Vulkan.Extensions.KHR;
+using Silk.NET.Windowing;
+using VkBuffer = Silk.NET.Vulkan.Buffer;
+using VkSemaphore = Silk.NET.Vulkan.Semaphore;
+
+namespace SharpEmu.Libs.VideoOut;
+
+internal static unsafe class VulkanVideoPresenter
+{
+ private static readonly object _gate = new();
+ private static Thread? _thread;
+ private static byte[]? _latestFrame;
+ private static uint _latestWidth;
+ private static uint _latestHeight;
+ private static long _latestSequence;
+ private static bool _closed;
+
+ public static void Submit(byte[] bgraFrame, uint width, uint height)
+ {
+ if (bgraFrame.Length != checked((int)(width * height * 4)))
+ {
+ return;
+ }
+
+ lock (_gate)
+ {
+ if (_closed)
+ {
+ return;
+ }
+
+ _latestFrame = bgraFrame;
+ _latestWidth = width;
+ _latestHeight = height;
+ _latestSequence++;
+ if (_thread is not null)
+ {
+ return;
+ }
+
+ _thread = new Thread(Run)
+ {
+ IsBackground = true,
+ Name = "SharpEmu Vulkan VideoOut",
+ };
+ _thread.Start();
+ }
+ }
+
+ private static void Run()
+ {
+ uint width;
+ uint height;
+ lock (_gate)
+ {
+ width = _latestWidth;
+ height = _latestHeight;
+ }
+
+ try
+ {
+ using var presenter = new Presenter(width, height);
+ presenter.Run();
+ }
+ catch (Exception exception)
+ {
+ Console.Error.WriteLine($"[LOADER][ERROR] Vulkan VideoOut presenter failed: {exception.Message}");
+ }
+ finally
+ {
+ lock (_gate)
+ {
+ _closed = true;
+ _thread = null;
+ }
+ }
+ }
+
+ private static bool TryTakeFrame(long presentedSequence, out Frame frame)
+ {
+ lock (_gate)
+ {
+ if (_latestFrame is null || _latestSequence == presentedSequence)
+ {
+ frame = default;
+ return false;
+ }
+
+ frame = new Frame(_latestFrame, _latestWidth, _latestHeight, _latestSequence);
+ return true;
+ }
+ }
+
+ private readonly record struct Frame(byte[] Pixels, uint Width, uint Height, long Sequence);
+
+ private sealed class Presenter : IDisposable
+ {
+ private readonly IWindow _window;
+ private Vk _vk = null!;
+ private KhrSurface _surfaceApi = null!;
+ private KhrSwapchain _swapchainApi = null!;
+ private Instance _instance;
+ private SurfaceKHR _surface;
+ private PhysicalDevice _physicalDevice;
+ private Device _device;
+ private Queue _queue;
+ private uint _queueFamilyIndex;
+ private SwapchainKHR _swapchain;
+ private Image[] _swapchainImages = [];
+ private bool[] _imageInitialized = [];
+ private Format _swapchainFormat;
+ private Extent2D _extent;
+ private CommandPool _commandPool;
+ private CommandBuffer _commandBuffer;
+ private VkSemaphore _imageAvailable;
+ private VkSemaphore _renderFinished;
+ private VkBuffer _stagingBuffer;
+ private DeviceMemory _stagingMemory;
+ private ulong _stagingSize;
+ private long _presentedSequence;
+ private bool _vulkanReady;
+ private bool _firstFramePresented;
+
+ public Presenter(uint width, uint height)
+ {
+ var options = WindowOptions.DefaultVulkan;
+ options.Size = new Vector2D((int)width, (int)height);
+ options.Title = "SharpEmu VideoOut";
+ options.WindowBorder = WindowBorder.Fixed;
+ options.VSync = true;
+ _window = Window.Create(options);
+ _window.Load += Initialize;
+ _window.Render += Render;
+ _window.Closing += DisposeVulkan;
+ }
+
+ public void Run() => _window.Run();
+
+ public void Dispose()
+ {
+ DisposeVulkan();
+ _window.Dispose();
+ }
+
+ private void Initialize()
+ {
+ _vk = Vk.GetApi();
+ CreateInstance();
+ CreateSurface();
+ SelectPhysicalDevice();
+ CreateDevice();
+ CreateSwapchain();
+ CreateCommandResources();
+ _vulkanReady = true;
+ Console.Error.WriteLine(
+ $"[LOADER][INFO] Vulkan VideoOut ready: {_extent.Width}x{_extent.Height}, format={_swapchainFormat}");
+ }
+
+ private void CreateInstance()
+ {
+ var applicationName = (byte*)SilkMarshal.StringToPtr("SharpEmu");
+ 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 extensions = _window.VkSurface!.GetRequiredExtensions(out var extensionCount);
+ var createInfo = new InstanceCreateInfo
+ {
+ SType = StructureType.InstanceCreateInfo,
+ PApplicationInfo = &applicationInfo,
+ EnabledExtensionCount = extensionCount,
+ PpEnabledExtensionNames = extensions,
+ };
+
+ Check(_vk.CreateInstance(&createInfo, null, out _instance), "vkCreateInstance");
+ if (!_vk.TryGetInstanceExtension(_instance, out _surfaceApi))
+ {
+ throw new InvalidOperationException("VK_KHR_surface is unavailable.");
+ }
+ }
+ finally
+ {
+ SilkMarshal.Free((nint)applicationName);
+ }
+ }
+
+ private void CreateSurface()
+ {
+ var instanceHandle = new VkHandle(_instance.Handle);
+ var surfaceHandle = _window.VkSurface!.Create(instanceHandle, null);
+ _surface = new SurfaceKHR(surfaceHandle.Handle);
+ }
+
+ 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");
+ }
+
+ 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;
+ }
+
+ _physicalDevice = device;
+ _queueFamilyIndex = index;
+ return;
+ }
+ }
+
+ throw new InvalidOperationException("No Vulkan graphics/present queue was found.");
+ }
+
+ private void CreateDevice()
+ {
+ var priority = 1.0f;
+ var queueInfo = new DeviceQueueCreateInfo
+ {
+ SType = StructureType.DeviceQueueCreateInfo,
+ QueueFamilyIndex = _queueFamilyIndex,
+ QueueCount = 1,
+ PQueuePriorities = &priority,
+ };
+
+ var swapchainExtension = (byte*)SilkMarshal.StringToPtr("VK_KHR_swapchain");
+ try
+ {
+ var createInfo = new DeviceCreateInfo
+ {
+ SType = StructureType.DeviceCreateInfo,
+ QueueCreateInfoCount = 1,
+ PQueueCreateInfos = &queueInfo,
+ EnabledExtensionCount = 1,
+ PpEnabledExtensionNames = &swapchainExtension,
+ };
+
+ Check(_vk.CreateDevice(_physicalDevice, &createInfo, null, out _device), "vkCreateDevice");
+ }
+ finally
+ {
+ SilkMarshal.Free((nint)swapchainExtension);
+ }
+
+ _vk.GetDeviceQueue(_device, _queueFamilyIndex, 0, out _queue);
+ if (!_vk.TryGetDeviceExtension(_instance, _device, out _swapchainApi))
+ {
+ throw new InvalidOperationException("VK_KHR_swapchain is unavailable.");
+ }
+ }
+
+ 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 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,
+ ImageSharingMode = SharingMode.Exclusive,
+ PreTransform = capabilities.CurrentTransform,
+ CompositeAlpha = compositeAlpha,
+ PresentMode = PresentModeKHR.FifoKhr,
+ 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 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 = 1,
+ };
+ Check(_vk.AllocateCommandBuffers(_device, &allocateInfo, out _commandBuffer), "vkAllocateCommandBuffers");
+
+ var semaphoreInfo = new SemaphoreCreateInfo
+ {
+ SType = StructureType.SemaphoreCreateInfo,
+ };
+ Check(_vk.CreateSemaphore(_device, &semaphoreInfo, null, out _imageAvailable), "vkCreateSemaphore");
+ Check(_vk.CreateSemaphore(_device, &semaphoreInfo, null, out _renderFinished), "vkCreateSemaphore");
+
+ CreateStagingBuffer((ulong)_extent.Width * _extent.Height * 4);
+ }
+
+ private void CreateStagingBuffer(ulong size)
+ {
+ var bufferInfo = new BufferCreateInfo
+ {
+ SType = StructureType.BufferCreateInfo,
+ Size = size,
+ Usage = BufferUsageFlags.TransferSrcBit,
+ SharingMode = SharingMode.Exclusive,
+ };
+ Check(_vk.CreateBuffer(_device, &bufferInfo, null, out _stagingBuffer), "vkCreateBuffer");
+
+ _vk.GetBufferMemoryRequirements(_device, _stagingBuffer, out var requirements);
+ var memoryInfo = new MemoryAllocateInfo
+ {
+ SType = StructureType.MemoryAllocateInfo,
+ AllocationSize = requirements.Size,
+ MemoryTypeIndex = FindMemoryType(
+ requirements.MemoryTypeBits,
+ MemoryPropertyFlags.HostVisibleBit | MemoryPropertyFlags.HostCoherentBit),
+ };
+ Check(_vk.AllocateMemory(_device, &memoryInfo, null, out _stagingMemory), "vkAllocateMemory");
+ Check(_vk.BindBufferMemory(_device, _stagingBuffer, _stagingMemory, 0), "vkBindBufferMemory");
+ _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 void Render(double _)
+ {
+ if (!_vulkanReady || !TryTakeFrame(_presentedSequence, out var frame))
+ {
+ return;
+ }
+
+ var pixels = frame.Width == _extent.Width && frame.Height == _extent.Height
+ ? frame.Pixels
+ : ScaleBgra(frame.Pixels, frame.Width, frame.Height, _extent.Width, _extent.Height);
+ if ((ulong)pixels.Length > _stagingSize)
+ {
+ return;
+ }
+
+ uint imageIndex;
+ Check(
+ _swapchainApi.AcquireNextImage(
+ _device,
+ _swapchain,
+ ulong.MaxValue,
+ _imageAvailable,
+ default,
+ &imageIndex),
+ "vkAcquireNextImageKHR");
+
+ 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");
+
+ 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,
+ ©Region);
+
+ 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);
+ Check(_vk.EndCommandBuffer(_commandBuffer), "vkEndCommandBuffer");
+
+ var waitStage = PipelineStageFlags.TransferBit;
+ var imageAvailable = _imageAvailable;
+ var commandBuffer = _commandBuffer;
+ var renderFinished = _renderFinished;
+ 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, default), "vkQueueSubmit");
+
+ var swapchain = _swapchain;
+ var presentInfo = new PresentInfoKHR
+ {
+ SType = StructureType.PresentInfoKhr,
+ WaitSemaphoreCount = 1,
+ PWaitSemaphores = &renderFinished,
+ SwapchainCount = 1,
+ PSwapchains = &swapchain,
+ PImageIndices = &imageIndex,
+ };
+ Check(_swapchainApi.QueuePresent(_queue, &presentInfo), "vkQueuePresentKHR");
+ Check(_vk.QueueWaitIdle(_queue), "vkQueueWaitIdle");
+ _imageInitialized[imageIndex] = true;
+ _presentedSequence = frame.Sequence;
+ if (!_firstFramePresented)
+ {
+ _firstFramePresented = true;
+ Console.Error.WriteLine(
+ $"[LOADER][INFO] Vulkan VideoOut presented first guest frame: {frame.Width}x{frame.Height}");
+ }
+ }
+
+ private Extent2D ChooseExtent(SurfaceCapabilitiesKHR capabilities)
+ {
+ if (capabilities.CurrentExtent.Width != uint.MaxValue)
+ {
+ return capabilities.CurrentExtent;
+ }
+
+ var size = _window.FramebufferSize;
+ return new Extent2D(
+ Math.Clamp((uint)Math.Max(size.X, 1), capabilities.MinImageExtent.Width, capabilities.MaxImageExtent.Width),
+ Math.Clamp((uint)Math.Max(size.Y, 1), capabilities.MinImageExtent.Height, capabilities.MaxImageExtent.Height));
+ }
+
+ private static SurfaceFormatKHR ChooseSurfaceFormat(IReadOnlyList 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() =>
+ new()
+ {
+ AspectMask = ImageAspectFlags.ColorBit,
+ LevelCount = 1,
+ 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 void DisposeVulkan()
+ {
+ if (!_vulkanReady)
+ {
+ return;
+ }
+
+ _vulkanReady = false;
+ _vk.DeviceWaitIdle(_device);
+ if (_stagingBuffer.Handle != 0)
+ {
+ _vk.DestroyBuffer(_device, _stagingBuffer, null);
+ }
+ if (_stagingMemory.Handle != 0)
+ {
+ _vk.FreeMemory(_device, _stagingMemory, null);
+ }
+ if (_imageAvailable.Handle != 0)
+ {
+ _vk.DestroySemaphore(_device, _imageAvailable, null);
+ }
+ if (_renderFinished.Handle != 0)
+ {
+ _vk.DestroySemaphore(_device, _renderFinished, null);
+ }
+ if (_commandPool.Handle != 0)
+ {
+ _vk.DestroyCommandPool(_device, _commandPool, null);
+ }
+ if (_swapchain.Handle != 0)
+ {
+ _swapchainApi.DestroySwapchain(_device, _swapchain, null);
+ }
+ if (_device.Handle != 0)
+ {
+ _vk.DestroyDevice(_device, null);
+ }
+ if (_surface.Handle != 0)
+ {
+ _surfaceApi.DestroySurface(_instance, _surface, null);
+ }
+ if (_instance.Handle != 0)
+ {
+ _vk.DestroyInstance(_instance, null);
+ }
+ }
+
+ private static void Check(Result result, string operation)
+ {
+ if (result != Result.Success)
+ {
+ throw new InvalidOperationException($"{operation} failed with {result}.");
+ }
+ }
+ }
+}
diff --git a/src/SharpEmu.Libs/packages.lock.json b/src/SharpEmu.Libs/packages.lock.json
index ccec8e5..9a2e14b 100644
--- a/src/SharpEmu.Libs/packages.lock.json
+++ b/src/SharpEmu.Libs/packages.lock.json
@@ -2,6 +2,91 @@
"version": 2,
"dependencies": {
"net10.0": {
+ "Silk.NET.Vulkan": {
+ "type": "Direct",
+ "requested": "[2.23.0, )",
+ "resolved": "2.23.0",
+ "contentHash": "3/irtlSWXZ3eTi8N6nelI6L34NTB8ZJHpqVMNzZx2aX7Ek9YEQ34NoQW8/Tljrtmkg8KRhHW8hKTEzZaKV8PgA==",
+ "dependencies": {
+ "Silk.NET.Core": "2.23.0"
+ }
+ },
+ "Silk.NET.Vulkan.Extensions.KHR": {
+ "type": "Direct",
+ "requested": "[2.23.0, )",
+ "resolved": "2.23.0",
+ "contentHash": "uRaf4j+SmH3DumjSSSUbFg33BnsGZUyXGj93O9NgGKZSJN3OTmNmQDxRew+/KiVLcgH6qzbto8aNGZ++j9GFWg==",
+ "dependencies": {
+ "Silk.NET.Core": "2.23.0",
+ "Silk.NET.Vulkan": "2.23.0"
+ }
+ },
+ "Silk.NET.Windowing": {
+ "type": "Direct",
+ "requested": "[2.23.0, )",
+ "resolved": "2.23.0",
+ "contentHash": "OPNPmt/lRyUKVYrFLQXVxyATqD3MKLc1iY1oKx1/2GppgmZxVZPwN12tekrQ4C7408kgB1L5JD1Wnirqqeb2kg==",
+ "dependencies": {
+ "Silk.NET.Windowing.Common": "2.23.0",
+ "Silk.NET.Windowing.Glfw": "2.23.0"
+ }
+ },
+ "Microsoft.DotNet.PlatformAbstractions": {
+ "type": "Transitive",
+ "resolved": "3.1.6",
+ "contentHash": "jek4XYaQ/PGUwDKKhwR8K47Uh1189PFzMeLqO83mXrXQVIpARZCcfuDedH50YDTepBkfijCZN5U/vZi++erxtg=="
+ },
+ "Microsoft.Extensions.DependencyModel": {
+ "type": "Transitive",
+ "resolved": "9.0.9",
+ "contentHash": "fNGvKct2De8ghm0Bpfq0iWthtzIWabgOTi+gJhNOPhNJIowXNEUE2eZNW/zNCzrHVA3PXg2yZ+3cWZndC2IqYA=="
+ },
+ "Silk.NET.Core": {
+ "type": "Transitive",
+ "resolved": "2.23.0",
+ "contentHash": "D7AT/nnwlB+4RZ84XY8QNGBZMJI5z9l4CSSETIJ1wCfRJzRt/341y3MRZ4HbnFz4r/IGaWOEZr86iE+0/65yyQ==",
+ "dependencies": {
+ "Microsoft.DotNet.PlatformAbstractions": "3.1.6",
+ "Microsoft.Extensions.DependencyModel": "9.0.9"
+ }
+ },
+ "Silk.NET.GLFW": {
+ "type": "Transitive",
+ "resolved": "2.23.0",
+ "contentHash": "UIs4sH57xlPUNHQ/1bt9rymPWlGy8IMDCNv86h0iM4TOA1CkIx0XM/n/tA4AReh1zQkNrvkxPEdZ3Blvy1dyXg==",
+ "dependencies": {
+ "Silk.NET.Core": "2.23.0",
+ "Ultz.Native.GLFW": "3.4.0"
+ }
+ },
+ "Silk.NET.Maths": {
+ "type": "Transitive",
+ "resolved": "2.23.0",
+ "contentHash": "r8PdIVzME8EH0qAgbmRPO87I4GfgR2j8TofT7EMuRJDf1QluoQwnVypDoFJjQ2ZBSRsGYk5unYxxogI05Ogsmw=="
+ },
+ "Silk.NET.Windowing.Common": {
+ "type": "Transitive",
+ "resolved": "2.23.0",
+ "contentHash": "ThStSinmY9KQI8DGiF5XEhkLJVnBcgRTBTzL9ijg1wMZAYuckz7ykrNw04fjRm2Gryh6tCNGbvz2XaY0efeFzg==",
+ "dependencies": {
+ "Silk.NET.Core": "2.23.0",
+ "Silk.NET.Maths": "2.23.0"
+ }
+ },
+ "Silk.NET.Windowing.Glfw": {
+ "type": "Transitive",
+ "resolved": "2.23.0",
+ "contentHash": "aYBudKmENmvLRn9p15HbdvlQTnnXskcDfTfbYwSb/4fr263rGLwYuDw/txUEc2jihHJiWCp5+75Y7z5wTJWl7g==",
+ "dependencies": {
+ "Silk.NET.GLFW": "2.23.0",
+ "Silk.NET.Windowing.Common": "2.23.0"
+ }
+ },
+ "Ultz.Native.GLFW": {
+ "type": "Transitive",
+ "resolved": "3.4.0",
+ "contentHash": "Iy22JopynbOJ32vA0lBhFEzGi65GQJBuJHYBYRBpydrDpNoTiHnjIXfA65Gu+8qsOr/ZEoIF8r9aHCgAXuO6DA=="
+ },
"sharpemu.hle": {
"type": "Project"
}