Sdl backend (#670)

* [audio] added sdl audio backend and in-tree atrac9 decoder

* [input] replaced per-platform pad readers with sdl gamepad input

* [video] added sdl window and host display plumbing

* [gui] added host display options and per-game render settings

* [bink] synced host movie playback to the guest audio clock

* [cpu] hooked windows write faults into guest image tracking

* [perf] added guest and render profiling, reserved host cpu lanes

* [kernel] fixed stale pthread mutex handle alias

* [host] wired the sdl session, save-data paths and project references

* [audio] hoisted ajm trace stackalloc out of its loop

* [video] Add guest image sync setting

* [build] Strip native symbols

* reuse
This commit is contained in:
Berk
2026-07-28 03:33:26 +03:00
committed by GitHub
parent b4cc5f88ca
commit 2b6bd5a532
111 changed files with 9846 additions and 4479 deletions
@@ -0,0 +1,129 @@
// Copyright (C) 2026 SharpEmu Emulator Project
// SPDX-License-Identifier: GPL-2.0-or-later
using SDL;
using static SDL.SDL3;
namespace SharpEmu.Libs.VideoOut;
public sealed record HostDisplayMode(int Width, int Height, int RefreshRate);
public sealed record HostDisplayInfo(
int Index,
string Name,
IReadOnlyList<HostDisplayMode> Modes);
public static unsafe class HostDisplayCatalog
{
private const SDL_InitFlags VideoFlag = SDL_InitFlags.SDL_INIT_VIDEO;
private static int _queryFailureLogged;
public static IReadOnlyList<HostDisplayInfo> Query()
{
var initializedHere = false;
var videoReady = false;
try
{
initializedHere = (SDL_WasInit(VideoFlag) & VideoFlag) == 0;
if (initializedHere && !SDL_InitSubSystem(VideoFlag))
{
LogQueryFailure(SDL_GetError() ?? "unknown SDL error");
return CreateFallback();
}
videoReady = true;
using var displays = SDL_GetDisplays();
if (displays is null || displays.Count == 0)
{
LogQueryFailure("SDL reported no displays");
return CreateFallback();
}
var result = new List<HostDisplayInfo>(displays.Count);
for (var index = 0; index < displays.Count; index++)
{
var display = displays[index];
var name = SDL_GetDisplayName(display);
var modes = ReadModes(display);
result.Add(new HostDisplayInfo(
index,
string.IsNullOrWhiteSpace(name) ? $"Display {index + 1}" : name,
modes));
}
return result;
}
catch (Exception exception)
{
LogQueryFailure(exception.Message);
return CreateFallback();
}
finally
{
if (initializedHere && videoReady)
{
SDL_QuitSubSystem(VideoFlag);
}
}
}
private static IReadOnlyList<HostDisplayMode> ReadModes(SDL_DisplayID display)
{
var modes = new HashSet<HostDisplayMode>();
using (var fullscreenModes = SDL_GetFullscreenDisplayModes(display))
{
if (fullscreenModes is not null)
{
for (var index = 0; index < fullscreenModes.Count; index++)
{
var mode = fullscreenModes[index];
AddMode(modes, &mode);
}
}
}
AddMode(modes, SDL_GetDesktopDisplayMode(display));
if (modes.Count == 0)
{
return CreateFallbackModes();
}
return modes
.OrderByDescending(mode => (long)mode.Width * mode.Height)
.ThenByDescending(mode => mode.Width)
.ThenByDescending(mode => mode.RefreshRate)
.ToArray();
}
private static void AddMode(HashSet<HostDisplayMode> modes, SDL_DisplayMode* mode)
{
if (mode is null || mode->w <= 0 || mode->h <= 0)
{
return;
}
var refreshRate = mode->refresh_rate > 0
? Math.Max(1, (int)Math.Round(mode->refresh_rate, MidpointRounding.AwayFromZero))
: 0;
modes.Add(new HostDisplayMode(mode->w, mode->h, refreshRate));
}
private static IReadOnlyList<HostDisplayInfo> CreateFallback() =>
[new HostDisplayInfo(0, "Display 1", CreateFallbackModes())];
private static IReadOnlyList<HostDisplayMode> CreateFallbackModes() =>
[
new HostDisplayMode(3840, 2160, 60),
new HostDisplayMode(2560, 1440, 60),
new HostDisplayMode(1920, 1080, 60),
new HostDisplayMode(1280, 720, 60),
];
private static void LogQueryFailure(string message)
{
if (Interlocked.Exchange(ref _queryFailureLogged, 1) == 0)
{
Console.Error.WriteLine($"[GUI][WARN] SDL display query failed: {message}");
}
}
}
@@ -0,0 +1,68 @@
// Copyright (C) 2026 SharpEmu Emulator Project
// SPDX-License-Identifier: GPL-2.0-or-later
namespace SharpEmu.Libs.VideoOut;
using SharpEmu.Libs.Gpu.Metal;
public enum HostWindowMode
{
Windowed,
Borderless,
ExclusiveFullscreen,
}
public enum HostScalingMode
{
Fit,
Cover,
Stretch,
Integer,
}
public enum HostHdrMode
{
Auto,
On,
Off,
}
public sealed record HostVideoOptions
{
public static HostVideoOptions Default { get; } = new();
public HostWindowMode WindowMode { get; init; } = HostWindowMode.Windowed;
public HostScalingMode ScalingMode { get; init; } = HostScalingMode.Fit;
public int Width { get; init; } = 1920;
public int Height { get; init; } = 1080;
public int DisplayIndex { get; init; }
public int RefreshRate { get; init; }
public bool VSync { get; init; } = true;
public HostHdrMode HdrMode { get; init; } = HostHdrMode.Auto;
public HostVideoOptions Normalize() => this with
{
Width = Math.Clamp(Width, 640, 16384),
Height = Math.Clamp(Height, 360, 16384),
DisplayIndex = Math.Max(0, DisplayIndex),
RefreshRate = Math.Clamp(RefreshRate, 0, 1000),
HdrMode = Enum.IsDefined(HdrMode) ? HdrMode : HostHdrMode.Auto,
};
}
public static class HostVideoHost
{
public static bool TryConfigureVideo(HostVideoOptions options)
{
var normalized = options.Normalize();
return VulkanVideoPresenter.TryConfigureVideo(normalized) &
MetalVideoPresenter.TryConfigureVideo(normalized);
}
}
@@ -0,0 +1,252 @@
// Copyright (C) 2026 SharpEmu Emulator Project
// SPDX-License-Identifier: GPL-2.0-or-later
using System.Diagnostics;
namespace SharpEmu.Libs.VideoOut;
/// <summary>
/// Self-time accounting for the render thread, enabled with
/// SHARPEMU_PROFILE_RENDER=1. The existing videoout counters report how much
/// work was done (draws, pipelines, SPIR-V) but not where the render thread's
/// second went, which is the number that decides whether a low frame rate is
/// the emulator recording commands, the GPU executing them, or neither.
///
/// Scopes nest: entering a phase suspends the enclosing one and resumes it on
/// dispose, so a <see cref="Phase.QueueSubmit"/> inside
/// <see cref="Phase.Flush"/> is never counted twice.
/// </summary>
internal static class RenderPhaseProfile
{
internal enum Phase
{
/// <summary>Outside any measured phase — loop overhead.</summary>
Unattributed = 0,
/// <summary>Parked because no guest work and no newer flip exist.</summary>
Idle,
/// <summary>Blocked on the frame slot's fence: the GPU is behind.</summary>
FrameSlotWait,
/// <summary>Reaping completed guest submissions (fence polls).</summary>
Collect,
Evict,
/// <summary>Dequeuing the next guest work item.</summary>
TakeWork,
/// <summary>Building the diagnostic label for a work item.</summary>
Describe,
/// <summary>Publishing a work item's completion to its waiters.</summary>
CompleteWork,
/// <summary>Selecting the presentation to show this iteration.</summary>
TakePresentation,
Draw,
Compute,
ColorClear,
ImageWrite,
OrderedAction,
Flip,
/// <summary>Closing and submitting the batched guest command buffer.</summary>
Flush,
/// <summary>vkQueueSubmit itself.</summary>
QueueSubmit,
/// <summary>vkAcquireNextImageKHR.</summary>
Acquire,
/// <summary>Recording + submitting the presentation command buffer.</summary>
Present,
/// <summary>vkQueuePresentKHR.</summary>
QueuePresent,
Count,
}
public static readonly bool Enabled = string.Equals(
Environment.GetEnvironmentVariable("SHARPEMU_PROFILE_RENDER"),
"1",
StringComparison.Ordinal);
/// <summary>
/// Breaks down the CPU-visible actions which are deliberately serialized
/// behind guest GPU work. This stays opt-in because it is diagnostic data,
/// not a normal render-thread cost.
/// </summary>
public static readonly bool OrderedActionDetailsEnabled = string.Equals(
Environment.GetEnvironmentVariable("SHARPEMU_PROFILE_ORDERED_ACTION"),
"1",
StringComparison.Ordinal);
private static readonly double _reportSeconds =
double.TryParse(
Environment.GetEnvironmentVariable("SHARPEMU_PROFILE_RENDER_REPORT_S"),
System.Globalization.CultureInfo.InvariantCulture,
out var seconds) && seconds > 0
? seconds
: 5.0;
private static readonly long[] _ticks = new long[(int)Phase.Count];
private static readonly long[] _entries = new long[(int)Phase.Count];
private static long _frames;
private static long _windowStart = Stopwatch.GetTimestamp();
private static readonly Dictionary<string, OrderedActionStats> _orderedActions =
new(StringComparer.Ordinal);
// The render loop is single-threaded, so plain fields are enough and keep
// the per-scope cost to two timestamp reads.
[ThreadStatic] private static Phase _current;
[ThreadStatic] private static long _lastTimestamp;
internal readonly ref struct Scope
{
private readonly Phase _previous;
private readonly bool _active;
internal Scope(Phase previous)
{
_previous = previous;
_active = true;
}
public void Dispose()
{
if (!_active)
{
return;
}
Charge(_previous);
}
}
public static Scope Measure(Phase phase)
{
if (!Enabled)
{
return default;
}
var previous = Charge(phase);
_entries[(int)phase]++;
return new Scope(previous);
}
public static void RecordOrderedAction(string debugName, bool completed)
{
if (!OrderedActionDetailsEnabled)
{
return;
}
var category = GetOrderedActionCategory(debugName);
ref var stats = ref System.Runtime.InteropServices.CollectionsMarshal.GetValueRefOrAddDefault(
_orderedActions,
category,
out _);
stats.Executed += completed ? 1 : 0;
stats.Deferred += completed ? 0 : 1;
}
/// <summary>
/// Closes out the running phase and switches to <paramref name="next"/>,
/// returning the phase that was running.
/// </summary>
private static Phase Charge(Phase next)
{
var now = Stopwatch.GetTimestamp();
var previous = _current;
if (_lastTimestamp != 0)
{
_ticks[(int)previous] += now - _lastTimestamp;
}
_lastTimestamp = now;
_current = next;
return previous;
}
/// <summary>Called once per presented frame; also drives the report.</summary>
public static void RecordFrame()
{
if (!Enabled)
{
return;
}
_frames++;
var now = Stopwatch.GetTimestamp();
var elapsedTicks = now - _windowStart;
if (elapsedTicks < _reportSeconds * Stopwatch.Frequency)
{
return;
}
_windowStart = now;
var seconds = elapsedTicks / (double)Stopwatch.Frequency;
var frames = _frames;
_frames = 0;
var parts = new List<(Phase Phase, double Percent, long Entries)>((int)Phase.Count);
var accounted = 0L;
for (var index = 0; index < (int)Phase.Count; index++)
{
var phaseTicks = _ticks[index];
_ticks[index] = 0;
var entries = _entries[index];
_entries[index] = 0;
accounted += phaseTicks;
if (phaseTicks <= 0)
{
continue;
}
parts.Add(((Phase)index, phaseTicks * 100.0 / elapsedTicks, entries));
}
parts.Sort(static (left, right) => right.Percent.CompareTo(left.Percent));
Console.Error.WriteLine(
$"[PERF][RENDER] {seconds:F1}s fps={frames / seconds:F1} " +
$"covered={accounted * 100.0 / elapsedTicks:F0}% " +
string.Join(
" ",
parts.Select(part =>
$"{part.Phase}={part.Percent:F1}%" +
(part.Entries > 0 ? $"/n{part.Entries}" : string.Empty))));
if (OrderedActionDetailsEnabled && _orderedActions.Count != 0)
{
var ordered = _orderedActions
.OrderByDescending(static pair => pair.Value.Executed + pair.Value.Deferred)
.Take(12)
.Select(static pair =>
$"{pair.Key}=ok{pair.Value.Executed}/defer{pair.Value.Deferred}");
Console.Error.WriteLine($"[PERF][ORDERED] {string.Join(" ", ordered)}");
_orderedActions.Clear();
}
}
private static string GetOrderedActionCategory(string debugName)
{
if (debugName.EndsWith(" completion", StringComparison.Ordinal))
{
return "completion";
}
var firstSpace = debugName.IndexOf(' ');
if (firstSpace < 0)
{
return debugName;
}
// AGC labels conventionally begin with "agc <packet>". Keeping the
// packet token separates DMA, submit and register traffic without
// retaining guest addresses in the diagnostic key.
if (debugName.StartsWith("agc ", StringComparison.Ordinal))
{
var secondSpace = debugName.IndexOf(' ', firstSpace + 1);
return secondSpace < 0 ? debugName : debugName[..secondSpace];
}
return debugName[..firstSpace];
}
private struct OrderedActionStats
{
public long Executed;
public long Deferred;
}
}
+832
View File
@@ -0,0 +1,832 @@
// Copyright (C) 2026 SharpEmu Emulator Project
// SPDX-License-Identifier: GPL-2.0-or-later
using System.Diagnostics;
using System.Runtime.InteropServices;
using SharpEmu.HLE.Host;
using SharpEmu.Libs.Pad;
using SDL;
using Silk.NET.Vulkan;
using static SDL.SDL3;
namespace SharpEmu.Libs.VideoOut;
internal enum SdlGraphicsApi
{
Vulkan,
Metal,
}
internal readonly record struct SdlHdrState(
bool Enabled,
float SdrWhiteLevel,
float Headroom);
internal sealed unsafe class SdlHostWindow : IDisposable, IHostGamepadOutput
{
private const SDL_InitFlags InitFlags = SDL_InitFlags.SDL_INIT_VIDEO | SDL_InitFlags.SDL_INIT_GAMEPAD;
private static readonly long CursorHideDelayTicks = 2 * Stopwatch.Frequency;
private const uint OutputDurationMs = 5_000;
private readonly HostVideoOptions _options;
private readonly SdlGraphicsApi _graphicsApi;
private readonly Action? _toggleBackendHud;
private readonly object _gamepadGate = new();
private SDL_Window* _window;
private nint _metalView;
private SDL_Gamepad* _gamepad;
private HostGamepadType _gamepadType;
private byte _leftTriggerRumble;
private byte _rightTriggerRumble;
private int _closeRequested;
private bool _closedByUser;
private bool _fullscreen;
private bool _focused = true;
private bool _cursorVisible = true;
private long _cursorHideDeadline;
private bool _surfaceRestorePending;
private bool _hdrStateChangePending;
private bool _disposed;
public SdlHostWindow(
string title,
HostVideoOptions options,
SdlGraphicsApi graphicsApi,
Action? toggleBackendHud = null)
{
_options = options.Normalize();
_graphicsApi = graphicsApi;
_toggleBackendHud = toggleBackendHud;
SdlGamepadStateReader.EnableSonyHidApi();
if (!SDL_InitSubSystem(InitFlags))
{
throw new InvalidOperationException($"SDL video initialization failed: {GetError()}");
}
var graphicsFlag = graphicsApi == SdlGraphicsApi.Vulkan
? SDL_WindowFlags.SDL_WINDOW_VULKAN
: SDL_WindowFlags.SDL_WINDOW_METAL;
var flags = graphicsFlag |
SDL_WindowFlags.SDL_WINDOW_RESIZABLE |
SDL_WindowFlags.SDL_WINDOW_HIGH_PIXEL_DENSITY |
SDL_WindowFlags.SDL_WINDOW_HIDDEN;
_window = CreateWindow(title, _options.Width, _options.Height, flags);
if (_window is null)
{
SDL_QuitSubSystem(InitFlags);
throw new InvalidOperationException($"SDL window creation failed: {GetError()}");
}
MoveToConfiguredDisplay();
ApplyConfiguredMode(_options.WindowMode);
SetIcon();
SDL_ShowWindow(_window);
SDL_RaiseWindow(_window);
if (_fullscreen && !SDL_SyncWindow(_window))
{
Console.Error.WriteLine($"[LOADER][WARN] SDL initial fullscreen sync failed: {GetError()}");
}
HostWindowInput.Connect(this);
OpenFirstGamepad();
Console.Error.WriteLine(
$"[LOADER][INFO] SDL3 window ready: mode={_options.WindowMode} " +
$"size={_options.Width}x{_options.Height} display={_options.DisplayIndex} " +
$"refresh={(_options.RefreshRate == 0 ? "auto" : _options.RefreshRate)}");
var hdr = HdrState;
Console.Error.WriteLine(
$"[LOADER][INFO] SDL3 display HDR: enabled={hdr.Enabled} " +
$"sdr_white={hdr.SdrWhiteLevel:F3} headroom={hdr.Headroom:F3}");
}
public (int Width, int Height) PixelSize
{
get
{
var width = 0;
var height = 0;
if (_window is not null)
{
SDL_GetWindowSizeInPixels(_window, &width, &height);
}
return (Math.Max(width, 1), Math.Max(height, 1));
}
}
public bool IsMinimized =>
_window is not null &&
(SDL_GetWindowFlags(_window) & SDL_WindowFlags.SDL_WINDOW_MINIMIZED) != 0;
public bool ClosedByUser => _closedByUser;
public SdlHdrState HdrState
{
get
{
if (_window is null)
{
return default;
}
var properties = SDL_GetWindowProperties(_window);
fixed (byte* hdrEnabledName = SDL_PROP_WINDOW_HDR_ENABLED_BOOLEAN)
fixed (byte* sdrWhiteName = SDL_PROP_WINDOW_SDR_WHITE_LEVEL_FLOAT)
fixed (byte* headroomName = SDL_PROP_WINDOW_HDR_HEADROOM_FLOAT)
{
return new SdlHdrState(
(bool)SDL_GetBooleanProperty(properties, hdrEnabledName, false),
SDL_GetFloatProperty(properties, sdrWhiteName, 1f),
SDL_GetFloatProperty(properties, headroomName, 1f));
}
}
}
public bool ConsumeSurfaceRestore()
{
var pending = _surfaceRestorePending;
_surfaceRestorePending = false;
return pending;
}
public bool ConsumeHdrStateChange()
{
var pending = _hdrStateChangePending;
_hdrStateChangePending = false;
return pending;
}
public byte** GetRequiredVulkanInstanceExtensions(out uint count)
{
EnsureGraphicsApi(SdlGraphicsApi.Vulkan);
uint extensionCount = 0;
var extensions = SDL_Vulkan_GetInstanceExtensions(&extensionCount);
count = extensionCount;
if (extensions is null || count == 0)
{
throw new InvalidOperationException($"SDL did not provide Vulkan instance extensions: {GetError()}");
}
return extensions;
}
public SurfaceKHR CreateVulkanSurface(Instance instance)
{
EnsureGraphicsApi(SdlGraphicsApi.Vulkan);
VkSurfaceKHR_T* surface = null;
if (!SDL_Vulkan_CreateSurface(
_window,
(VkInstance_T*)instance.Handle,
null,
&surface) || surface is null)
{
throw new InvalidOperationException($"SDL Vulkan surface creation failed: {GetError()}");
}
return new SurfaceKHR(unchecked((ulong)surface));
}
public nint CreateMetalLayer()
{
EnsureGraphicsApi(SdlGraphicsApi.Metal);
if (_metalView == 0)
{
_metalView = SDL_Metal_CreateView(_window);
if (_metalView == 0)
{
throw new InvalidOperationException($"SDL Metal view creation failed: {GetError()}");
}
}
var layer = SDL_Metal_GetLayer(_metalView);
if (layer == 0)
{
throw new InvalidOperationException($"SDL Metal layer lookup failed: {GetError()}");
}
return layer;
}
public void SetTitle(string title)
{
if (_window is null)
{
return;
}
var utf8 = Marshal.StringToCoTaskMemUTF8(title);
try
{
SDL_SetWindowTitle(_window, (byte*)utf8);
}
finally
{
Marshal.FreeCoTaskMem(utf8);
}
}
public void Close() => Volatile.Write(ref _closeRequested, 1);
public void SetRumble(byte largeMotor, byte smallMotor)
{
lock (_gamepadGate)
{
if (IsGamepadConnected())
{
SDL_RumbleGamepad(
_gamepad,
ExpandByte(largeMotor),
ExpandByte(smallMotor),
OutputDurationMs);
}
}
}
public void SetTriggerRumble(byte? leftTrigger, byte? rightTrigger)
{
lock (_gamepadGate)
{
if (IsGamepadConnected())
{
if (leftTrigger is { } left)
{
_leftTriggerRumble = left;
}
if (rightTrigger is { } right)
{
_rightTriggerRumble = right;
}
SDL_RumbleGamepadTriggers(
_gamepad,
ExpandByte(_leftTriggerRumble),
ExpandByte(_rightTriggerRumble),
OutputDurationMs);
}
}
}
public void SetAdaptiveTriggerEffect(
HostAdaptiveTriggerEffect? leftTrigger,
HostAdaptiveTriggerEffect? rightTrigger)
{
lock (_gamepadGate)
{
if (!IsGamepadConnected())
{
return;
}
if (_gamepadType != HostGamepadType.DualSense)
{
if (leftTrigger is { } fallbackLeft)
{
_leftTriggerRumble = fallbackLeft.FallbackStrength;
}
if (rightTrigger is { } fallbackRight)
{
_rightTriggerRumble = fallbackRight.FallbackStrength;
}
SDL_RumbleGamepadTriggers(
_gamepad,
ExpandByte(_leftTriggerRumble),
ExpandByte(_rightTriggerRumble),
OutputDurationMs);
return;
}
Span<byte> state = stackalloc byte[47];
state.Clear();
if (rightTrigger is { } nativeRight)
{
state[0] |= 0x04;
nativeRight.CopyTo(state[10..21]);
}
if (leftTrigger is { } nativeLeft)
{
state[0] |= 0x08;
nativeLeft.CopyTo(state[21..32]);
}
if (state[0] == 0)
{
return;
}
fixed (byte* data = state)
{
SDL_SendGamepadEffect(_gamepad, (nint)data, state.Length);
}
}
}
public void SetLightbar(byte red, byte green, byte blue)
{
lock (_gamepadGate)
{
if (IsGamepadConnected())
{
SDL_SetGamepadLED(_gamepad, red, green, blue);
}
}
}
public void ResetLightbar() => SetLightbar(0, 0, 64);
public void Run(
Action initialize,
Action<double> render,
Action closing,
Action? idle = null)
{
initialize();
var timer = Stopwatch.StartNew();
var last = timer.Elapsed.TotalSeconds;
try
{
while (Volatile.Read(ref _closeRequested) == 0)
{
PumpEvents();
if (Volatile.Read(ref _closeRequested) != 0)
{
break;
}
UpdateCursorAutoHide();
SampleGamepad();
var now = timer.Elapsed.TotalSeconds;
render(now - last);
last = now;
if (IsMinimized)
{
SDL_Delay(10);
}
else
{
idle?.Invoke();
}
}
}
finally
{
closing();
}
}
public void Dispose()
{
if (_disposed)
{
return;
}
_disposed = true;
SDL_ShowCursor();
HostWindowInput.Disconnect();
CloseGamepad();
if (_metalView != 0)
{
SDL_Metal_DestroyView(_metalView);
_metalView = 0;
}
if (_window is not null)
{
SDL_DestroyWindow(_window);
_window = null;
}
SDL_QuitSubSystem(InitFlags);
}
private void PumpEvents()
{
SDL_Event windowEvent;
while (SDL_PollEvent(&windowEvent))
{
switch (windowEvent.Type)
{
case SDL_EventType.SDL_EVENT_QUIT:
case SDL_EventType.SDL_EVENT_WINDOW_CLOSE_REQUESTED:
_closedByUser = true;
Volatile.Write(ref _closeRequested, 1);
break;
case SDL_EventType.SDL_EVENT_WINDOW_FOCUS_GAINED:
_focused = true;
UpdateCursorVisibility();
HostWindowInput.SetFocused(true);
break;
case SDL_EventType.SDL_EVENT_WINDOW_FOCUS_LOST:
_focused = false;
UpdateCursorVisibility();
HostWindowInput.SetFocused(false);
break;
case SDL_EventType.SDL_EVENT_WINDOW_MINIMIZED:
_focused = false;
UpdateCursorVisibility();
HostWindowInput.SetFocused(false);
break;
case SDL_EventType.SDL_EVENT_WINDOW_RESTORED:
case SDL_EventType.SDL_EVENT_WINDOW_RESIZED:
case SDL_EventType.SDL_EVENT_WINDOW_PIXEL_SIZE_CHANGED:
case SDL_EventType.SDL_EVENT_WINDOW_MAXIMIZED:
case SDL_EventType.SDL_EVENT_WINDOW_ENTER_FULLSCREEN:
case SDL_EventType.SDL_EVENT_WINDOW_LEAVE_FULLSCREEN:
case SDL_EventType.SDL_EVENT_WINDOW_EXPOSED:
case SDL_EventType.SDL_EVENT_WINDOW_DISPLAY_CHANGED:
case SDL_EventType.SDL_EVENT_WINDOW_DISPLAY_SCALE_CHANGED:
_surfaceRestorePending = true;
break;
case SDL_EventType.SDL_EVENT_WINDOW_HDR_STATE_CHANGED:
_hdrStateChangePending = true;
break;
case SDL_EventType.SDL_EVENT_KEY_DOWN:
case SDL_EventType.SDL_EVENT_KEY_UP:
HandleKey(windowEvent.key);
break;
case SDL_EventType.SDL_EVENT_MOUSE_MOTION:
ShowCursorTemporarily();
break;
case SDL_EventType.SDL_EVENT_MOUSE_BUTTON_DOWN:
ShowCursorTemporarily();
if (windowEvent.button.button == 1 && windowEvent.button.clicks == 2)
{
ToggleFullscreen();
}
break;
case SDL_EventType.SDL_EVENT_GAMEPAD_ADDED:
if (_gamepad is null)
{
OpenFirstGamepad();
}
break;
case SDL_EventType.SDL_EVENT_GAMEPAD_REMOVED:
if (_gamepad is not null && !SDL_GamepadConnected(_gamepad))
{
CloseGamepad();
OpenFirstGamepad();
}
break;
}
}
}
private void HandleKey(SDL_KeyboardEvent keyEvent)
{
var down = keyEvent.type == SDL_EventType.SDL_EVENT_KEY_DOWN;
if (down && !keyEvent.repeat)
{
if (keyEvent.key == SDL_Keycode.SDLK_F1 &&
(keyEvent.mod & SDL_Keymod.SDL_KMOD_GUI) != 0 &&
_toggleBackendHud is not null)
{
_toggleBackendHud();
}
else if (keyEvent.key == SDL_Keycode.SDLK_F1)
{
PerfOverlay.Toggle();
}
else if (keyEvent.key == SDL_Keycode.SDLK_F11)
{
ToggleFullscreen();
}
}
if (TryMapVirtualKey(keyEvent.key, out var virtualKey))
{
HostWindowInput.SetKey(virtualKey, down);
}
}
private void ToggleFullscreen()
{
if (_fullscreen)
{
SDL_SetWindowFullscreen(_window, false);
SDL_SetWindowFullscreenMode(_window, null);
SDL_SetWindowResizable(_window, true);
SDL_SetWindowSize(_window, _options.Width, _options.Height);
MoveToConfiguredDisplay();
_fullscreen = false;
UpdateCursorVisibility();
return;
}
ApplyConfiguredMode(
_options.WindowMode == HostWindowMode.ExclusiveFullscreen
? HostWindowMode.ExclusiveFullscreen
: HostWindowMode.Borderless);
}
private void ApplyConfiguredMode(HostWindowMode mode)
{
if (mode == HostWindowMode.Windowed)
{
_fullscreen = false;
UpdateCursorVisibility();
return;
}
if (mode == HostWindowMode.ExclusiveFullscreen)
{
var display = GetConfiguredDisplay();
SDL_DisplayMode closest;
if (SDL_GetClosestFullscreenDisplayMode(
display,
_options.Width,
_options.Height,
_options.RefreshRate,
true,
&closest))
{
SDL_SetWindowFullscreenMode(_window, &closest);
}
else
{
Console.Error.WriteLine(
$"[LOADER][WARN] SDL exclusive mode unavailable; using borderless: {GetError()}");
SDL_SetWindowFullscreenMode(_window, null);
}
}
else
{
SDL_SetWindowFullscreenMode(_window, null);
}
SDL_SetWindowFullscreen(_window, true);
_fullscreen = true;
UpdateCursorVisibility();
}
private void UpdateCursorVisibility()
{
_cursorHideDeadline = 0;
SetCursorVisible(!_fullscreen || !_focused);
}
private void ShowCursorTemporarily()
{
if (!_fullscreen || !_focused)
{
return;
}
SetCursorVisible(true);
_cursorHideDeadline = Stopwatch.GetTimestamp() + CursorHideDelayTicks;
}
private void UpdateCursorAutoHide()
{
if (!_fullscreen || !_focused || !_cursorVisible || _cursorHideDeadline == 0 ||
Stopwatch.GetTimestamp() < _cursorHideDeadline)
{
return;
}
_cursorHideDeadline = 0;
SetCursorVisible(false);
}
private void SetCursorVisible(bool visible)
{
if (_cursorVisible == visible)
{
return;
}
if (visible)
{
SDL_ShowCursor();
}
else
{
SDL_HideCursor();
}
_cursorVisible = visible;
}
private void MoveToConfiguredDisplay()
{
var display = GetConfiguredDisplay();
SDL_Rect bounds;
if (SDL_GetDisplayBounds(display, &bounds))
{
var x = bounds.x + Math.Max(0, (bounds.w - _options.Width) / 2);
var y = bounds.y + Math.Max(0, (bounds.h - _options.Height) / 2);
SDL_SetWindowPosition(_window, x, y);
}
}
private SDL_DisplayID GetConfiguredDisplay()
{
using var displays = SDL_GetDisplays();
if (displays is null || displays.Count == 0)
{
return SDL_GetPrimaryDisplay();
}
return displays[Math.Clamp(_options.DisplayIndex, 0, displays.Count - 1)];
}
private void OpenFirstGamepad()
{
lock (_gamepadGate)
{
_gamepad = SdlGamepadStateReader.OpenPreferredGamepad();
if (_gamepad is null)
{
return;
}
_gamepadType = SdlGamepadStateReader.MapGamepadType(SDL_GetRealGamepadType(_gamepad));
EnableSensor(SDL_SensorType.SDL_SENSOR_ACCEL);
EnableSensor(SDL_SensorType.SDL_SENSOR_GYRO);
}
Console.Error.WriteLine(
$"[LOADER][INFO] SDL gamepad connected: {DescribeGamepad()} " +
$"type={_gamepadType} connection={SdlGamepadStateReader.GetConnection(_gamepad)}");
SampleGamepad();
}
private void CloseGamepad()
{
lock (_gamepadGate)
{
if (_gamepad is not null)
{
SDL_CloseGamepad(_gamepad);
_gamepad = null;
}
_gamepadType = HostGamepadType.Generic;
_leftTriggerRumble = 0;
_rightTriggerRumble = 0;
}
HostWindowInput.ClearGamepad();
}
private void SampleGamepad()
{
lock (_gamepadGate)
{
if (!IsGamepadConnected())
{
HostWindowInput.ClearGamepad();
return;
}
SDL_UpdateGamepads();
var state = SdlGamepadStateReader.Read(_gamepad) with
{
Motion = ReadMotion(),
Touch = ReadTouch(),
};
HostWindowInput.SetGamepad(
DescribeGamepad(),
state);
}
}
private void EnableSensor(SDL_SensorType sensor)
{
if (SDL_GamepadHasSensor(_gamepad, sensor))
{
SDL_SetGamepadSensorEnabled(_gamepad, sensor, true);
}
}
private HostMotionState ReadMotion()
{
Span<float> acceleration = stackalloc float[3];
Span<float> angularVelocity = stackalloc float[3];
acceleration.Clear();
angularVelocity.Clear();
var hasAcceleration = false;
var hasAngularVelocity = false;
fixed (float* data = acceleration)
{
hasAcceleration = SDL_GamepadHasSensor(_gamepad, SDL_SensorType.SDL_SENSOR_ACCEL) &&
SDL_GetGamepadSensorData(_gamepad, SDL_SensorType.SDL_SENSOR_ACCEL, data, acceleration.Length);
}
fixed (float* data = angularVelocity)
{
hasAngularVelocity = SDL_GamepadHasSensor(_gamepad, SDL_SensorType.SDL_SENSOR_GYRO) &&
SDL_GetGamepadSensorData(_gamepad, SDL_SensorType.SDL_SENSOR_GYRO, data, angularVelocity.Length);
}
return new HostMotionState(
hasAcceleration || hasAngularVelocity,
acceleration[0],
acceleration[1],
acceleration[2],
angularVelocity[0],
angularVelocity[1],
angularVelocity[2]);
}
private HostTouchState ReadTouch()
{
if (SDL_GetNumGamepadTouchpads(_gamepad) <= 0)
{
return default;
}
return new HostTouchState(ReadTouchPoint(0), ReadTouchPoint(1));
}
private HostTouchPoint ReadTouchPoint(int finger)
{
if (SDL_GetNumGamepadTouchpadFingers(_gamepad, 0) <= finger)
{
return default;
}
SDLBool down = false;
float x = 0;
float y = 0;
float pressure = 0;
if (!SDL_GetGamepadTouchpadFinger(_gamepad, 0, finger, &down, &x, &y, &pressure))
{
return default;
}
return new HostTouchPoint(down, (byte)finger, Math.Clamp(x, 0, 1), Math.Clamp(y, 0, 1));
}
private bool IsGamepadConnected() => _gamepad is not null && SDL_GamepadConnected(_gamepad);
private string DescribeGamepad() =>
Marshal.PtrToStringUTF8((nint)Unsafe_SDL_GetGamepadName(_gamepad)) ?? "SDL gamepad";
private static ushort ExpandByte(byte value) => (ushort)(value * 257);
private static bool TryMapVirtualKey(SDL_Keycode key, out int virtualKey)
{
virtualKey = key switch
{
SDL_Keycode.SDLK_BACKSPACE => 0x08,
SDL_Keycode.SDLK_TAB => 0x09,
SDL_Keycode.SDLK_RETURN => 0x0D,
SDL_Keycode.SDLK_ESCAPE => 0x1B,
SDL_Keycode.SDLK_LEFT => 0x25,
SDL_Keycode.SDLK_UP => 0x26,
SDL_Keycode.SDLK_RIGHT => 0x27,
SDL_Keycode.SDLK_DOWN => 0x28,
>= SDL_Keycode.SDLK_A and <= SDL_Keycode.SDLK_Z => 0x41 + (int)(key - SDL_Keycode.SDLK_A),
_ => 0,
};
return virtualKey != 0;
}
private void SetIcon()
{
if (!PngSplashLoader.TryLoadIcon(out var pixels, out var width, out var height))
{
return;
}
fixed (byte* data = pixels)
{
var surface = SDL_CreateSurfaceFrom(
(int)width,
(int)height,
SDL_PixelFormat.SDL_PIXELFORMAT_ABGR8888,
(nint)data,
checked((int)width * 4));
if (surface is not null)
{
SDL_SetWindowIcon(_window, surface);
SDL_DestroySurface(surface);
}
}
}
private static SDL_Window* CreateWindow(string title, int width, int height, SDL_WindowFlags flags)
{
var utf8 = Marshal.StringToCoTaskMemUTF8(title);
try
{
return SDL_CreateWindow((byte*)utf8, width, height, flags);
}
finally
{
Marshal.FreeCoTaskMem(utf8);
}
}
private static string GetError() =>
Marshal.PtrToStringUTF8((nint)Unsafe_SDL_GetError()) ?? "unknown SDL error";
private void EnsureGraphicsApi(SdlGraphicsApi expected)
{
if (_graphicsApi != expected)
{
throw new InvalidOperationException(
$"SDL host window uses {_graphicsApi}, not {expected}.");
}
}
}
+48 -30
View File
@@ -2,6 +2,7 @@
// SPDX-License-Identifier: GPL-2.0-or-later
using SharpEmu.HLE;
using SharpEmu.Logging;
using SharpEmu.HLE.Host;
using SharpEmu.Libs.Diagnostics;
using SharpEmu.Libs.Gpu;
@@ -69,11 +70,14 @@ public static class VideoOutExports
private static readonly Dictionary<int, VideoOutPortState> _ports = new();
private static int _presentationWindowCloseNotified;
private static int _vblankStopRequested;
private static int _hdrOutputRequested;
private static readonly Dictionary<(int Handle, int BufferIndex, ulong Address), ulong> _lastFrameFingerprints = new();
private static int _nextHandle = 1;
private static int _frameDumpCount;
private static long _nextFrameDumpIndex;
private static string _windowTitle = "SharpEmu VideoOut";
private static string _applicationWindowTitle = "VideoOut";
private static string _selectedGpuName = string.Empty;
private static string _applicationTitleId = "UNKNOWN";
private static readonly bool _logFrameRate = string.Equals(
Environment.GetEnvironmentVariable("SHARPEMU_LOG_VIDEOOUT_FPS"),
"1",
@@ -113,7 +117,18 @@ public static class VideoOutExports
var versionSuffix = string.IsNullOrWhiteSpace(version) ? string.Empty : $" v{version.Trim()}";
lock (_stateGate)
{
_windowTitle = $"SharpEmu - {application}{versionSuffix}";
_applicationTitleId = string.IsNullOrWhiteSpace(titleId)
? "UNKNOWN"
: titleId.Trim();
_applicationWindowTitle = $"{application}{versionSuffix}";
}
}
internal static string GetApplicationTitleId()
{
lock (_stateGate)
{
return _applicationTitleId;
}
}
@@ -121,7 +136,10 @@ public static class VideoOutExports
{
lock (_stateGate)
{
return _windowTitle;
var gpuSuffix = string.IsNullOrWhiteSpace(_selectedGpuName)
? string.Empty
: $" · {_selectedGpuName}";
return $"SharpEmu · {BuildInfo.CommitSha ?? "dev"} - {_applicationWindowTitle}{gpuSuffix}";
}
}
@@ -139,7 +157,7 @@ public static class VideoOutExports
: string.Empty;
lock (_stateGate)
{
_windowTitle = $"{_windowTitle} · {gpuName.Trim()}{backendSuffix}";
_selectedGpuName = $"{gpuName.Trim()}{backendSuffix}";
}
}
@@ -166,30 +184,17 @@ public static class VideoOutExports
private static void RequestHostShutdown(string reason)
{
Console.Error.WriteLine($"[LOADER][INFO] Host shutdown requested: {reason}");
var embedded = VulkanVideoHost.IsEmbedded;
AudioOutExports.ShutdownAllPorts();
Interlocked.Exchange(ref _vblankStopRequested, 1);
HostSessionControl.RequestShutdown(reason);
GuestGpu.Current.RequestClose();
// A hosted game can still be issuing AGC work after it requests its
// own shutdown. Keep the presenter's resources alive until the GUI
// session reaches its guest-safe exit path and disposes the host
// surface.
if (!embedded)
// Give guest and GPU threads a bounded window to leave cooperatively.
ThreadPool.QueueUserWorkItem(static _ =>
{
GuestGpu.Current.RequestClose();
}
// The embedded GUI owns the process lifetime. A guest shutdown should
// end only that session rather than terminating the launcher itself.
if (!embedded)
{
ThreadPool.QueueUserWorkItem(static _ =>
{
Thread.Sleep(2000);
Environment.Exit(0);
});
}
Thread.Sleep(2000);
Environment.Exit(0);
});
}
private sealed class VideoOutPortState
@@ -871,6 +876,8 @@ public static class VideoOutExports
}
}
internal static bool IsHdrOutputRequested => Volatile.Read(ref _hdrOutputRequested) != 0;
[SysAbiExport(
Nid = "MTxxrOCeSig",
ExportName = "sceVideoOutSetWindowModeMargins",
@@ -1175,16 +1182,21 @@ public static class VideoOutExports
var guestImageSubmitted = false;
ulong guestImageAddress = 0;
if (submitGpuImage &&
bufferIndex >= 0 &&
if (bufferIndex >= 0 &&
TryGetDisplayBufferInfo(handle, bufferIndex, out var displayBuffer))
{
Interlocked.Exchange(
ref _hdrOutputRequested,
IsHdrPixelFormat(displayBuffer.PixelFormat) ? 1 : 0);
guestImageAddress = displayBuffer.Address;
guestImageSubmitted = GuestGpu.Current.TrySubmitGuestImage(
displayBuffer.Address,
displayBuffer.Width,
displayBuffer.Height,
displayBuffer.PitchInPixel);
if (submitGpuImage)
{
guestImageSubmitted = GuestGpu.Current.TrySubmitGuestImage(
displayBuffer.Address,
displayBuffer.Width,
displayBuffer.Height,
displayBuffer.PitchInPixel);
}
}
if (_dumpVideoOut)
@@ -1711,6 +1723,12 @@ public static class VideoOutExports
internal static bool IsPacked10BitPixelFormat(ulong pixelFormat) =>
IsPacked10BitPixelFormatNormalized(NormalizePixelFormat(pixelFormat));
internal static bool IsHdrPixelFormat(ulong pixelFormat) =>
NormalizePixelFormat(pixelFormat) is
SceVideoOutPixelFormatA2R10G10B10Bt2020Pq or
SceVideoOutPixelFormat2R10G10B10A2Bt2100Pq or
SceVideoOutPixelFormat2B10G10R10A2Bt2100Pq;
private static bool IsPacked10BitPixelFormatNormalized(ulong pixelFormat) =>
pixelFormat is
SceVideoOutPixelFormatA2R10G10B10 or
@@ -1,270 +0,0 @@
// Copyright (C) 2026 SharpEmu Emulator Project
// SPDX-License-Identifier: GPL-2.0-or-later
namespace SharpEmu.Libs.VideoOut;
/// <summary>
/// A native child surface owned by a host UI. The presenter consumes this
/// directly, avoiding a second top-level GLFW window when the GUI is active.
/// </summary>
public enum VulkanHostSurfaceKind
{
Win32,
Xlib,
Metal,
}
/// <summary>
/// Platform-native handles required to create a Vulkan presentation surface.
/// The GUI owns their lifetime and updates the physical pixel size on resize.
/// </summary>
public sealed class VulkanHostSurface : IDisposable
{
private int _pixelWidth;
private int _pixelHeight;
private int _resizeGeneration;
private readonly bool _ownsDisplay;
private readonly bool _pollNativeSize;
private long _nextNativeSizePoll;
public VulkanHostSurface(
VulkanHostSurfaceKind kind,
nint windowHandle,
nint displayHandle = 0,
nint metalLayerHandle = 0,
bool ownsDisplay = false,
bool pollNativeSize = false)
{
Kind = kind;
WindowHandle = windowHandle;
DisplayHandle = displayHandle;
MetalLayerHandle = metalLayerHandle;
_ownsDisplay = ownsDisplay;
_pollNativeSize = pollNativeSize;
}
public VulkanHostSurfaceKind Kind { get; }
public nint WindowHandle { get; }
/// <summary>X11 Display* when <see cref="Kind"/> is <see cref="VulkanHostSurfaceKind.Xlib"/>.</summary>
public nint DisplayHandle { get; }
/// <summary>CAMetalLayer* when <see cref="Kind"/> is <see cref="VulkanHostSurfaceKind.Metal"/>.</summary>
public nint MetalLayerHandle { get; }
public int PixelWidth => Volatile.Read(ref _pixelWidth);
public int PixelHeight => Volatile.Read(ref _pixelHeight);
internal int ResizeGeneration => Volatile.Read(ref _resizeGeneration);
public void UpdatePixelSize(int width, int height)
{
width = Math.Max(width, 1);
height = Math.Max(height, 1);
if (Volatile.Read(ref _pixelWidth) == width && Volatile.Read(ref _pixelHeight) == height)
{
return;
}
Volatile.Write(ref _pixelWidth, width);
Volatile.Write(ref _pixelHeight, height);
Interlocked.Increment(ref _resizeGeneration);
}
/// <summary>
/// The child emulator cannot receive Avalonia resize notifications. Poll
/// the native host at a bounded rate so embedded child swapchains still
/// follow normal resize and F11 transitions.
/// </summary>
internal void RefreshChildProcessPixelSize()
{
if (!_pollNativeSize)
{
return;
}
var now = System.Diagnostics.Stopwatch.GetTimestamp();
var due = Volatile.Read(ref _nextNativeSizePoll);
if (now < due || Interlocked.CompareExchange(
ref _nextNativeSizePoll,
now + (System.Diagnostics.Stopwatch.Frequency / 8),
due) != due)
{
return;
}
if (Kind == VulkanHostSurfaceKind.Win32 && GetClientRect(WindowHandle, out var rect))
{
UpdatePixelSize(rect.Right - rect.Left, rect.Bottom - rect.Top);
return;
}
if (Kind == VulkanHostSurfaceKind.Xlib && DisplayHandle != 0 &&
XGetGeometry(
DisplayHandle,
WindowHandle,
out _,
out _,
out _,
out var width,
out var height,
out _,
out _) != 0)
{
UpdatePixelSize(unchecked((int)width), unchecked((int)height));
}
}
/// <summary>
/// Serializes a native child handle for a separately hosted emulator
/// process. Metal object pointers are process-local, so macOS falls back
/// to a standalone child window until an IPC Metal host is implemented.
/// </summary>
public bool TryGetChildProcessDescriptor(out string descriptor)
{
descriptor = string.Empty;
if (Kind == VulkanHostSurfaceKind.Metal || WindowHandle == 0)
{
return false;
}
var kind = Kind == VulkanHostSurfaceKind.Win32 ? "win32" : "xlib";
descriptor = string.Create(
System.Globalization.CultureInfo.InvariantCulture,
$"{kind}:{unchecked((ulong)WindowHandle):X}:{Math.Max(PixelWidth, 1)}:{Math.Max(PixelHeight, 1)}:{unchecked((ulong)DisplayHandle):X}");
return true;
}
/// <summary>
/// Reconstructs a surface in the isolated emulator process. X11 clients
/// must open their own Display connection; Display* values cannot cross a
/// process boundary.
/// </summary>
public static bool TryCreateChildProcessSurface(
string descriptor,
out VulkanHostSurface? surface,
out string? error)
{
surface = null;
error = null;
var parts = descriptor.Split(':');
if (parts.Length != 5 ||
!ulong.TryParse(parts[1], System.Globalization.NumberStyles.AllowHexSpecifier, System.Globalization.CultureInfo.InvariantCulture, out var window) ||
!int.TryParse(parts[2], System.Globalization.NumberStyles.Integer, System.Globalization.CultureInfo.InvariantCulture, out var width) ||
!int.TryParse(parts[3], System.Globalization.NumberStyles.Integer, System.Globalization.CultureInfo.InvariantCulture, out var height) ||
!ulong.TryParse(parts[4], System.Globalization.NumberStyles.AllowHexSpecifier, System.Globalization.CultureInfo.InvariantCulture, out var nativeDisplay))
{
error = "invalid host-surface descriptor";
return false;
}
if (window == 0 || width <= 0 || height <= 0)
{
error = "host-surface descriptor has an invalid size or handle";
return false;
}
if (string.Equals(parts[0], "win32", StringComparison.OrdinalIgnoreCase))
{
surface = new VulkanHostSurface(
VulkanHostSurfaceKind.Win32,
unchecked((nint)window),
unchecked((nint)nativeDisplay),
pollNativeSize: true);
}
else if (string.Equals(parts[0], "xlib", StringComparison.OrdinalIgnoreCase))
{
var display = XOpenDisplay(0);
if (display == 0)
{
error = "could not open an X11 display for the host surface";
return false;
}
surface = new VulkanHostSurface(
VulkanHostSurfaceKind.Xlib,
unchecked((nint)window),
display,
ownsDisplay: true,
pollNativeSize: true);
}
else
{
error = $"unsupported host-surface kind '{parts[0]}'";
return false;
}
surface.UpdatePixelSize(width, height);
return true;
}
public void Dispose()
{
if (_ownsDisplay && DisplayHandle != 0 && OperatingSystem.IsLinux())
{
_ = XCloseDisplay(DisplayHandle);
}
}
[System.Runtime.InteropServices.DllImport("libX11.so.6", EntryPoint = "XOpenDisplay")]
private static extern nint XOpenDisplay(nint displayName);
[System.Runtime.InteropServices.DllImport("libX11.so.6", EntryPoint = "XCloseDisplay")]
private static extern int XCloseDisplay(nint display);
[System.Runtime.InteropServices.DllImport("user32.dll", EntryPoint = "GetClientRect", SetLastError = true)]
[return: System.Runtime.InteropServices.MarshalAs(System.Runtime.InteropServices.UnmanagedType.Bool)]
private static extern bool GetClientRect(nint window, out Rect rect);
[System.Runtime.InteropServices.DllImport("libX11.so.6", EntryPoint = "XGetGeometry")]
private static extern int XGetGeometry(
nint display,
nint drawable,
out nint root,
out int x,
out int y,
out uint width,
out uint height,
out uint borderWidth,
out uint depth);
[System.Runtime.InteropServices.StructLayout(System.Runtime.InteropServices.LayoutKind.Sequential)]
private struct Rect
{
public int Left;
public int Top;
public int Right;
public int Bottom;
}
}
/// <summary>
/// Small public bridge between a desktop UI and the internal Vulkan
/// presenter. Launchers can host a surface without depending on renderer
/// submission internals.
/// </summary>
public static class VulkanVideoHost
{
/// <summary>
/// Raised after the first successful Vulkan present to an embedded host
/// surface. UI hosts use this to retire their launch affordance only once
/// a real frame can be seen.
/// </summary>
public static event Action<VulkanHostSurface>? FirstFramePresented
{
add => VulkanVideoPresenter.FirstHostFramePresented += value;
remove => VulkanVideoPresenter.FirstHostFramePresented -= value;
}
public static bool TryAttachSurface(VulkanHostSurface surface) =>
VulkanVideoPresenter.TryAttachHostSurface(surface);
public static void DetachSurface(VulkanHostSurface surface) =>
VulkanVideoPresenter.DetachHostSurface(surface);
public static void RequestClose() => VulkanVideoPresenter.RequestClose();
public static bool IsEmbedded => VulkanVideoPresenter.UsesHostSurface;
}
@@ -0,0 +1,96 @@
// Copyright (C) 2026 SharpEmu Emulator Project
// SPDX-License-Identifier: GPL-2.0-or-later
namespace SharpEmu.Libs.VideoOut;
internal static class VulkanPipelineCacheStorage
{
private const string CacheFileName = "vulkan-pipeline-cache.bin";
internal static string ResolvePath(string? titleId, string? configuredPath)
{
if (!string.IsNullOrWhiteSpace(configuredPath))
{
return Path.GetFullPath(
Environment.ExpandEnvironmentVariables(configuredPath));
}
return Path.GetFullPath(Path.Combine(
AppContext.BaseDirectory,
"user",
"pipeline_cache",
SanitizeTitleId(titleId),
CacheFileName));
}
internal static string GetLegacyPath()
{
var root = OperatingSystem.IsMacOS()
? Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.UserProfile),
"Library",
"Caches")
: Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData);
return Path.Combine(root, "SharpEmu", CacheFileName);
}
internal static bool ImportLegacyCache(string legacyPath, string destinationPath)
{
if (File.Exists(destinationPath) || !File.Exists(legacyPath))
{
return false;
}
var directory = Path.GetDirectoryName(destinationPath);
if (!string.IsNullOrWhiteSpace(directory))
{
Directory.CreateDirectory(directory);
}
try
{
File.Copy(legacyPath, destinationPath, overwrite: false);
try
{
File.Delete(legacyPath);
}
catch (IOException)
{
// The destination is valid; a locked legacy cache can remain
// as an unused artifact without affecting future writes.
}
catch (UnauthorizedAccessException)
{
// See above. Cache migration must not block game startup.
}
return true;
}
catch (IOException) when (File.Exists(destinationPath))
{
return false;
}
}
private static string SanitizeTitleId(string? titleId)
{
if (string.IsNullOrWhiteSpace(titleId))
{
return "UNKNOWN";
}
var source = titleId.Trim();
Span<char> sanitized = source.Length <= 128
? stackalloc char[source.Length]
: new char[source.Length];
for (var index = 0; index < source.Length; index++)
{
var value = source[index];
sanitized[index] = char.IsAsciiLetterOrDigit(value) || value is '-' or '_'
? char.ToUpperInvariant(value)
: '_';
}
return new string(sanitized);
}
}
File diff suppressed because it is too large Load Diff