mirror of
https://github.com/par274/sharpemu.git
synced 2026-08-07 18:31:17 +08:00
2b6bd5a532
* [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
833 lines
25 KiB
C#
833 lines
25 KiB
C#
// 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}.");
|
|
}
|
|
}
|
|
}
|