mirror of
https://github.com/par274/sharpemu.git
synced 2026-08-08 02:39:10 +08:00
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:
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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}.");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
Reference in New Issue
Block a user