From 5181df82c83b35b0e17d453f99b033fbcf20733f Mon Sep 17 00:00:00 2001 From: ParantezTech <12572227+par274@users.noreply.github.com> Date: Tue, 28 Jul 2026 01:05:42 +0300 Subject: [PATCH] [input] replaced per-platform pad readers with sdl gamepad input --- src/SharpEmu.HLE/Host/HostGamepadState.cs | 91 +++- src/SharpEmu.HLE/Host/IHostInput.cs | 5 + .../Host/IHostWindowInputSource.cs | 42 ++ src/SharpEmu.HLE/Host/Posix/PosixHostInput.cs | 186 -------- .../Host/Posix/PosixHostPlatform.cs | 6 +- src/SharpEmu.HLE/Host/WindowHostInput.cs | 43 ++ .../Host/Windows/WindowsDualSenseReader.cs | 439 ------------------ .../Host/Windows/WindowsHidNative.cs | 141 ------ .../Host/Windows/WindowsHostInput.cs | 101 ---- .../Host/Windows/WindowsHostPlatform.cs | 6 +- .../Host/Windows/WindowsXInputReader.cs | 277 ----------- src/SharpEmu.Libs/Gpu/Metal/MetalHostInput.cs | 182 -------- src/SharpEmu.Libs/Pad/HostWindowInput.cs | 366 ++++++--------- src/SharpEmu.Libs/Pad/PadExports.cs | 234 +++++++++- src/SharpEmu.Libs/Pad/PadState.cs | 9 +- .../Pad/SdlGamepadStateReader.cs | 121 +++++ src/SharpEmu.Libs/Pad/SdlLauncherGamepad.cs | 85 ++++ .../Pad/HostWindowInputTests.cs | 159 ++++++- 18 files changed, 929 insertions(+), 1564 deletions(-) create mode 100644 src/SharpEmu.HLE/Host/IHostWindowInputSource.cs delete mode 100644 src/SharpEmu.HLE/Host/Posix/PosixHostInput.cs create mode 100644 src/SharpEmu.HLE/Host/WindowHostInput.cs delete mode 100644 src/SharpEmu.HLE/Host/Windows/WindowsDualSenseReader.cs delete mode 100644 src/SharpEmu.HLE/Host/Windows/WindowsHidNative.cs delete mode 100644 src/SharpEmu.HLE/Host/Windows/WindowsHostInput.cs delete mode 100644 src/SharpEmu.HLE/Host/Windows/WindowsXInputReader.cs delete mode 100644 src/SharpEmu.Libs/Gpu/Metal/MetalHostInput.cs create mode 100644 src/SharpEmu.Libs/Pad/SdlGamepadStateReader.cs create mode 100644 src/SharpEmu.Libs/Pad/SdlLauncherGamepad.cs diff --git a/src/SharpEmu.HLE/Host/HostGamepadState.cs b/src/SharpEmu.HLE/Host/HostGamepadState.cs index 79760dca..36863376 100644 --- a/src/SharpEmu.HLE/Host/HostGamepadState.cs +++ b/src/SharpEmu.HLE/Host/HostGamepadState.cs @@ -28,8 +28,44 @@ public enum HostGamepadButtons : uint R3 = 1 << 13, Options = 1 << 14, TouchPad = 1 << 15, + Create = 1 << 16, + Ps = 1 << 17, + Mic = 1 << 18, } +public enum HostGamepadType : byte +{ + Generic, + DualShock4, + DualSense, +} + +public enum HostGamepadConnection : byte +{ + Unknown, + Wired, + Wireless, +} + +public readonly record struct HostMotionState( + bool Available, + float AccelerationX, + float AccelerationY, + float AccelerationZ, + float AngularVelocityX, + float AngularVelocityY, + float AngularVelocityZ); + +public readonly record struct HostTouchPoint( + bool Active, + byte Id, + float X, + float Y); + +public readonly record struct HostTouchState( + HostTouchPoint First, + HostTouchPoint Second); + /// /// Snapshot of one host gamepad: sticks are 0..255 with 128 centered and Y growing /// downward; triggers 0..255. Unmanaged on purpose so per-frame polls can stackalloc @@ -43,4 +79,57 @@ public readonly record struct HostGamepadState( byte RightX, byte RightY, byte LeftTrigger, - byte RightTrigger); + byte RightTrigger, + HostGamepadType Type = HostGamepadType.Generic, + HostGamepadConnection Connection = HostGamepadConnection.Unknown, + HostMotionState Motion = default, + HostTouchState Touch = default, + byte BatteryPercent = 0); + +/// A complete 11-byte DualSense adaptive-trigger command. +public readonly record struct HostAdaptiveTriggerEffect( + byte B0, + byte B1, + byte B2, + byte B3, + byte B4, + byte B5, + byte B6, + byte B7, + byte B8, + byte B9, + byte B10, + byte FallbackStrength = 0) +{ + public static HostAdaptiveTriggerEffect FromBytes(ReadOnlySpan source, byte fallbackStrength = 0) + { + if (source.Length < 11) + { + throw new ArgumentException("Adaptive-trigger source is too small.", nameof(source)); + } + + return new HostAdaptiveTriggerEffect( + source[0], source[1], source[2], source[3], source[4], source[5], + source[6], source[7], source[8], source[9], source[10], fallbackStrength); + } + + public void CopyTo(Span destination) + { + if (destination.Length < 11) + { + throw new ArgumentException("Adaptive-trigger destination is too small.", nameof(destination)); + } + + destination[0] = B0; + destination[1] = B1; + destination[2] = B2; + destination[3] = B3; + destination[4] = B4; + destination[5] = B5; + destination[6] = B6; + destination[7] = B7; + destination[8] = B8; + destination[9] = B9; + destination[10] = B10; + } +} diff --git a/src/SharpEmu.HLE/Host/IHostInput.cs b/src/SharpEmu.HLE/Host/IHostInput.cs index 52779bcf..853f9abb 100644 --- a/src/SharpEmu.HLE/Host/IHostInput.cs +++ b/src/SharpEmu.HLE/Host/IHostInput.cs @@ -32,6 +32,11 @@ public interface IHostInput /// void SetTriggerRumble(byte? leftTrigger, byte? rightTrigger); + /// Applies native DualSense trigger effects when supported. + void SetAdaptiveTriggerEffect( + HostAdaptiveTriggerEffect? leftTrigger, + HostAdaptiveTriggerEffect? rightTrigger); + void SetLightbar(byte red, byte green, byte blue); void ResetLightbar(); diff --git a/src/SharpEmu.HLE/Host/IHostWindowInputSource.cs b/src/SharpEmu.HLE/Host/IHostWindowInputSource.cs new file mode 100644 index 00000000..d33329fa --- /dev/null +++ b/src/SharpEmu.HLE/Host/IHostWindowInputSource.cs @@ -0,0 +1,42 @@ +// Copyright (C) 2026 SharpEmu Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +namespace SharpEmu.HLE.Host; + +/// Input snapshots produced by the active host window. +public interface IHostWindowInputSource +{ + bool HasKeyboardFocus { get; } + + bool IsKeyDown(int virtualKey); + + int GetGamepadStates(Span destination); + + string? DescribeConnectedGamepad(); + + void SetRumble(byte largeMotor, byte smallMotor); + + void SetTriggerRumble(byte? leftTrigger, byte? rightTrigger); + + void SetAdaptiveTriggerEffect( + HostAdaptiveTriggerEffect? leftTrigger, + HostAdaptiveTriggerEffect? rightTrigger); + + void SetLightbar(byte red, byte green, byte blue); + + void ResetLightbar(); +} + +/// Process-wide bridge between the window layer and host input. +public static class HostWindowInputSource +{ + private static IHostWindowInputSource? _current; + + public static IHostWindowInputSource? Current => Volatile.Read(ref _current); + + public static void Set(IHostWindowInputSource source) => + Volatile.Write(ref _current, source); + + public static void Clear(IHostWindowInputSource source) => + Interlocked.CompareExchange(ref _current, null, source); +} diff --git a/src/SharpEmu.HLE/Host/Posix/PosixHostInput.cs b/src/SharpEmu.HLE/Host/Posix/PosixHostInput.cs deleted file mode 100644 index 93cd5c13..00000000 --- a/src/SharpEmu.HLE/Host/Posix/PosixHostInput.cs +++ /dev/null @@ -1,186 +0,0 @@ -// Copyright (C) 2026 SharpEmu Emulator Project -// SPDX-License-Identifier: GPL-2.0-or-later - -namespace SharpEmu.HLE.Host.Posix; - -/// -/// Bridges a window-provided input source into the host input seam. POSIX -/// hosts have no user32/XInput/raw-HID readers; keyboard and gamepad state -/// come from the presenter's GLFW window instead, which registers itself via -/// once the window exists. Until then (and with no -/// window at all, e.g. headless runs) every query reports neutral input. -/// Rumble and lightbar are unsupported by the GLFW input layer and no-op. -/// -public interface IPosixWindowInputSource -{ - /// True while the window's keyboard is delivering events. - bool HasKeyboardFocus { get; } - - /// Windows virtual-key semantics; the source translates. - bool IsKeyDown(int virtualKey); - - /// Same contract as . - int GetGamepadStates(Span destination); - - string? DescribeConnectedGamepad(); -} - -// Public so the presenter's window layer (SharpEmu.Libs) can register its -// input source; the platform still constructs the singleton itself. -public sealed class PosixHostInput : IHostInput -{ - private static volatile IPosixWindowInputSource? _source; - - /// Called by the presenter's window layer when input is ready. - public static void SetSource(IPosixWindowInputSource source) - { - _source = source; - } - - public void EnsureStarted() - { - // Device readers are event-driven off the window thread; nothing to start. - } - - public int GetGamepadStates(Span destination) - { - return _source?.GetGamepadStates(destination) ?? 0; - } - - public string? DescribeConnectedGamepad() => _source?.DescribeConnectedGamepad(); - - public void SetRumble(byte largeMotor, byte smallMotor) - { - } - - public void SetTriggerRumble(byte? leftTrigger, byte? rightTrigger) - { - } - - public void SetLightbar(byte red, byte green, byte blue) - { - } - - public void ResetLightbar() - { - } - - public bool IsHostWindowFocused() - { - // GLFW only delivers key events to the focused window, so a - // delivering keyboard implies focus. - return _source?.HasKeyboardFocus ?? IsEmbeddedX11WindowFocused(); - } - - public bool IsKeyDown(int virtualKey) - { - var source = _source; - if (source is not null) - { - return source.IsKeyDown(virtualKey); - } - - return IsEmbeddedX11WindowFocused() && IsEmbeddedX11KeyDown(virtualKey); - } - - private static bool IsEmbeddedX11WindowFocused() - { - if (!OperatingSystem.IsLinux()) - { - return false; - } - - var display = HostSessionControl.EmbeddedHostDisplay; - var window = HostSessionControl.EmbeddedHostWindow; - if (display == 0 || window == 0 || XGetInputFocus(display, out var focusedWindow, out _) == 0 || focusedWindow == 0) - { - return false; - } - - return GetTopLevelWindow(display, focusedWindow) == GetTopLevelWindow(display, window); - } - - private static bool IsEmbeddedX11KeyDown(int virtualKey) - { - var display = HostSessionControl.EmbeddedHostDisplay; - var keysym = ToX11Keysym(virtualKey); - if (display == 0 || keysym == 0) - { - return false; - } - - var keycode = XKeysymToKeycode(display, keysym); - if (keycode == 0) - { - return false; - } - - var keymap = new byte[32]; - XQueryKeymap(display, keymap); - return (keymap[keycode >> 3] & (1 << (keycode & 7))) != 0; - } - - private static nint GetTopLevelWindow(nint display, nint window) - { - var current = window; - for (var depth = 0; depth < 16; depth++) - { - if (XQueryTree(display, current, out var root, out var parent, out var children, out _) == 0) - { - return 0; - } - - if (children != 0) - { - XFree(children); - } - - if (parent == 0 || parent == root) - { - return current; - } - - current = parent; - } - - return 0; - } - - private static nuint ToX11Keysym(int virtualKey) - { - return virtualKey switch - { - 0x08 => 0xFF08, // Backspace - 0x09 => 0xFF09, // Tab - 0x0D => 0xFF0D, // Return - 0x1B => 0xFF1B, // Escape - 0x25 => 0xFF51, // Left - 0x26 => 0xFF52, // Up - 0x27 => 0xFF53, // Right - 0x28 => 0xFF54, // Down - >= 0x41 and <= 0x5A => (nuint)virtualKey, - _ => 0, - }; - } - - [System.Runtime.InteropServices.DllImport("libX11.so.6")] - private static extern int XGetInputFocus(nint display, out nint focus, out int revertTo); - - [System.Runtime.InteropServices.DllImport("libX11.so.6")] - private static extern int XQueryKeymap(nint display, [System.Runtime.InteropServices.Out] byte[] keysReturn); - - [System.Runtime.InteropServices.DllImport("libX11.so.6")] - private static extern byte XKeysymToKeycode(nint display, nuint keysym); - - [System.Runtime.InteropServices.DllImport("libX11.so.6")] - private static extern int XQueryTree( - nint display, - nint window, - out nint root, - out nint parent, - out nint children, - out uint childCount); - - [System.Runtime.InteropServices.DllImport("libX11.so.6")] - private static extern int XFree(nint data); -} diff --git a/src/SharpEmu.HLE/Host/Posix/PosixHostPlatform.cs b/src/SharpEmu.HLE/Host/Posix/PosixHostPlatform.cs index fc452d31..495aee41 100644 --- a/src/SharpEmu.HLE/Host/Posix/PosixHostPlatform.cs +++ b/src/SharpEmu.HLE/Host/Posix/PosixHostPlatform.cs @@ -1,6 +1,8 @@ // Copyright (C) 2026 SharpEmu Emulator Project // SPDX-License-Identifier: GPL-2.0-or-later +using SharpEmu.HLE.Host.Sdl; + namespace SharpEmu.HLE.Host.Posix; internal sealed class PosixHostPlatform : IHostPlatform @@ -11,7 +13,7 @@ internal sealed class PosixHostPlatform : IHostPlatform public IHostSymbolResolver Symbols { get; } = new PosixHostSymbolResolver(); - public IHostAudioOutput Audio { get; } = new PosixHostAudio(); + public IHostAudioOutput Audio { get; } = new SdlHostAudio(); - public IHostInput Input { get; } = new PosixHostInput(); + public IHostInput Input { get; } = new WindowHostInput(); } diff --git a/src/SharpEmu.HLE/Host/WindowHostInput.cs b/src/SharpEmu.HLE/Host/WindowHostInput.cs new file mode 100644 index 00000000..d3d5a8f2 --- /dev/null +++ b/src/SharpEmu.HLE/Host/WindowHostInput.cs @@ -0,0 +1,43 @@ +// Copyright (C) 2026 SharpEmu Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +namespace SharpEmu.HLE.Host; + +/// +/// Routes emulated input through the active cross-platform host window. +/// +internal sealed class WindowHostInput : IHostInput +{ + public void EnsureStarted() + { + // SDL owns device discovery and pumps it on the window thread. + } + + public int GetGamepadStates(Span destination) => + HostWindowInputSource.Current?.GetGamepadStates(destination) ?? 0; + + public string? DescribeConnectedGamepad() => + HostWindowInputSource.Current?.DescribeConnectedGamepad(); + + public void SetRumble(byte largeMotor, byte smallMotor) => + HostWindowInputSource.Current?.SetRumble(largeMotor, smallMotor); + + public void SetTriggerRumble(byte? leftTrigger, byte? rightTrigger) => + HostWindowInputSource.Current?.SetTriggerRumble(leftTrigger, rightTrigger); + + public void SetAdaptiveTriggerEffect( + HostAdaptiveTriggerEffect? leftTrigger, + HostAdaptiveTriggerEffect? rightTrigger) => + HostWindowInputSource.Current?.SetAdaptiveTriggerEffect(leftTrigger, rightTrigger); + + public void SetLightbar(byte red, byte green, byte blue) => + HostWindowInputSource.Current?.SetLightbar(red, green, blue); + + public void ResetLightbar() => HostWindowInputSource.Current?.ResetLightbar(); + + public bool IsHostWindowFocused() => + HostWindowInputSource.Current?.HasKeyboardFocus ?? false; + + public bool IsKeyDown(int virtualKey) => + HostWindowInputSource.Current?.IsKeyDown(virtualKey) ?? false; +} diff --git a/src/SharpEmu.HLE/Host/Windows/WindowsDualSenseReader.cs b/src/SharpEmu.HLE/Host/Windows/WindowsDualSenseReader.cs deleted file mode 100644 index c1b6cbe8..00000000 --- a/src/SharpEmu.HLE/Host/Windows/WindowsDualSenseReader.cs +++ /dev/null @@ -1,439 +0,0 @@ -// Copyright (C) 2026 SharpEmu Emulator Project -// SPDX-License-Identifier: GPL-2.0-or-later - -using Microsoft.Win32.SafeHandles; - -namespace SharpEmu.HLE.Host.Windows; - -/// -/// Reads a DualSense controller over raw HID on a background thread. -/// Supports USB (input report 0x01) and Bluetooth (extended report 0x31, -/// activated by requesting feature report 0x05), with hot-plug retry. -/// -public static class WindowsDualSenseReader -{ - private const ushort SonyVendorId = 0x054C; - private const ushort DualSenseProductId = 0x0CE6; - private const ushort DualSenseEdgeProductId = 0x0DF2; - - private static readonly object Gate = new(); - private static HostGamepadState _state; - private static bool _started; - - // Output (rumble/lightbar) state, all guarded by Gate. - private static string? _devicePath; - private static bool _bluetooth; - private static bool _outputReady; - private static bool _lightbarSetupPending; - private static byte _outputSequence; - private static FileStream? _outputStream; - private static byte _motorLeft; - private static byte _motorRight; - private static byte _lightbarRed; - private static byte _lightbarGreen; - private static byte _lightbarBlue = 64; // PS-style blue default - private static byte _playerLeds = 0x04; // center LED = player 1 - - /// Starts the background reader once; safe to call repeatedly. - public static void EnsureStarted() - { - // The GUI source-links this reader and calls it directly, without the - // host-platform resolution that otherwise guarantees Windows. - if (!OperatingSystem.IsWindows()) - { - return; - } - - lock (Gate) - { - if (_started) - { - return; - } - - _started = true; - var thread = new Thread(ReadLoop) - { - IsBackground = true, - Name = "DualSenseReader", - }; - thread.Start(); - } - } - - public static bool TryGetState(out HostGamepadState state) - { - lock (Gate) - { - state = _state; - } - - return state.Connected; - } - - private static void SetState(in HostGamepadState state) - { - lock (Gate) - { - _state = state; - } - } - - /// Sets rumble; large = left/strong motor, small = right/weak. - internal static void SetRumble(byte largeMotor, byte smallMotor) - { - lock (Gate) - { - if (_motorLeft == largeMotor && _motorRight == smallMotor) - { - return; - } - - _motorLeft = largeMotor; - _motorRight = smallMotor; - SendOutputLocked(); - } - } - - internal static void SetLightbar(byte red, byte green, byte blue) - { - lock (Gate) - { - if (_lightbarRed == red && _lightbarGreen == green && _lightbarBlue == blue) - { - return; - } - - _lightbarRed = red; - _lightbarGreen = green; - _lightbarBlue = blue; - SendOutputLocked(); - } - } - - internal static void ResetLightbar() => SetLightbar(0, 0, 64); - - private static void OnDeviceIdentified(string path, bool bluetooth) - { - lock (Gate) - { - _devicePath = path; - _bluetooth = bluetooth; - _outputReady = true; - _lightbarSetupPending = true; - // Announce ourselves on the hardware: default lightbar + player 1 LED. - SendOutputLocked(); - } - } - - private static void OnDeviceLost() - { - lock (Gate) - { - _devicePath = null; - _outputReady = false; - _motorLeft = 0; - _motorRight = 0; - _outputStream?.Dispose(); - _outputStream = null; - } - } - - private static void SendOutputLocked() - { - if (!_outputReady || _devicePath is null) - { - return; // flushed by OnDeviceIdentified once connected - } - - try - { - if (_outputStream is null) - { - var handle = WindowsHidNative.CreateFile( - _devicePath, - WindowsHidNative.GenericRead | WindowsHidNative.GenericWrite, - WindowsHidNative.FileShareRead | WindowsHidNative.FileShareWrite, - 0, WindowsHidNative.OpenExisting, 0, 0); - if (handle.IsInvalid) - { - handle.Dispose(); - return; // read-only device access: outputs unavailable - } - - _outputStream = new FileStream(handle, FileAccess.Write, bufferSize: 1); - } - - var report = BuildOutputReportLocked(); - _outputStream.Write(report, 0, report.Length); - _outputStream.Flush(); - } - catch (Exception) - { - _outputStream?.Dispose(); - _outputStream = null; - } - } - - private static byte[] BuildOutputReportLocked() - { - // Common 47-byte output payload (offsets per the DualSense output - // report layout, same as Linux hid-playstation). - Span common = stackalloc byte[47]; - common[0] = 0x03; // valid_flag0: compatible vibration + haptics select - common[1] = 0x04 | 0x10; // valid_flag1: lightbar + player indicator - common[2] = _motorRight; // right (weak) motor - common[3] = _motorLeft; // left (strong) motor - if (_lightbarSetupPending) - { - common[38] |= 0x02; // valid_flag2: lightbar setup control enable - common[41] = 0x01; // lightbar_setup: light on - _lightbarSetupPending = false; - } - - common[43] = _playerLeds; - common[44] = _lightbarRed; - common[45] = _lightbarGreen; - common[46] = _lightbarBlue; - - if (!_bluetooth) - { - var usbReport = new byte[48]; - usbReport[0] = 0x02; - common.CopyTo(usbReport.AsSpan(1)); - return usbReport; - } - - // Bluetooth: 0x31 wrapper with sequence tag and CRC32 over a 0xA2 - // seed byte plus the first 74 report bytes. - var btReport = new byte[78]; - btReport[0] = 0x31; - btReport[1] = (byte)((_outputSequence & 0x0F) << 4); - _outputSequence = (byte)((_outputSequence + 1) & 0x0F); - btReport[2] = 0x10; - common.CopyTo(btReport.AsSpan(3)); - var crc = Crc32(0xA2, btReport.AsSpan(0, 74)); - btReport[74] = (byte)crc; - btReport[75] = (byte)(crc >> 8); - btReport[76] = (byte)(crc >> 16); - btReport[77] = (byte)(crc >> 24); - return btReport; - } - - private static uint Crc32(byte seed, ReadOnlySpan data) - { - var crc = Crc32Update(0xFFFFFFFFu, seed); - foreach (var value in data) - { - crc = Crc32Update(crc, value); - } - - return ~crc; - } - - private static uint Crc32Update(uint crc, byte value) - { - crc ^= value; - for (var bit = 0; bit < 8; bit++) - { - crc = (crc >> 1) ^ (0xEDB88320u & (uint)-(int)(crc & 1)); - } - - return crc; - } - - private static void ReadLoop() - { - var announcedConnect = false; - while (true) - { - SafeFileHandle? handle = null; - try - { - handle = OpenDualSense(out var devicePath); - if (handle is null || devicePath is null) - { - SetState(default); - announcedConnect = false; - Thread.Sleep(1000); - continue; - } - - // Bluetooth quirk: the DualSense sends a simplified report - // until feature report 0x05 is requested, which switches it - // to the full 0x31 input report. Harmless over USB. - var feature = new byte[41]; - feature[0] = 0x05; - _ = WindowsHidNative.HidD_GetFeature(handle, feature, feature.Length); - - if (!announcedConnect) - { - Console.Error.WriteLine("[LOADER][INFO] DualSense controller connected."); - announcedConnect = true; - } - - using var stream = new FileStream(handle, FileAccess.Read, bufferSize: 1); - handle = null; // stream owns it now - var buffer = new byte[256]; - var transportKnown = false; - while (true) - { - var read = stream.Read(buffer, 0, buffer.Length); - if (read <= 0) - { - break; - } - - if (TryParseReport(buffer.AsSpan(0, read), out var state)) - { - if (!transportKnown) - { - // The first parsed report tells us the transport, - // which the output (rumble/lightbar) path needs. - transportKnown = true; - OnDeviceIdentified(devicePath, bluetooth: buffer[0] == 0x31); - } - - SetState(state); - } - } - } - catch (Exception) - { - // Unplugged or read error: fall through and retry. - } - finally - { - handle?.Dispose(); - } - - if (announcedConnect) - { - Console.Error.WriteLine("[LOADER][INFO] DualSense controller disconnected."); - announcedConnect = false; - } - - OnDeviceLost(); - SetState(default); - Thread.Sleep(1000); - } - } - - private static SafeFileHandle? OpenDualSense(out string? devicePath) - { - devicePath = null; - foreach (var path in WindowsHidNative.EnumerateHidDevicePaths()) - { - // Open without access rights just to query VID/PID. - using var probe = WindowsHidNative.CreateFile( - path, 0, WindowsHidNative.FileShareRead | WindowsHidNative.FileShareWrite, 0, WindowsHidNative.OpenExisting, 0, 0); - if (probe.IsInvalid) - { - continue; - } - - var attributes = new WindowsHidNative.HiddAttributes { Size = 12 }; - if (!WindowsHidNative.HidD_GetAttributes(probe, ref attributes) || - attributes.VendorId != SonyVendorId || - (attributes.ProductId != DualSenseProductId && attributes.ProductId != DualSenseEdgeProductId)) - { - continue; - } - - // Read+write so feature reports work; fall back to read-only. - var handle = WindowsHidNative.CreateFile( - path, - WindowsHidNative.GenericRead | WindowsHidNative.GenericWrite, - WindowsHidNative.FileShareRead | WindowsHidNative.FileShareWrite, - 0, WindowsHidNative.OpenExisting, 0, 0); - if (handle.IsInvalid) - { - handle.Dispose(); - handle = WindowsHidNative.CreateFile( - path, - WindowsHidNative.GenericRead, - WindowsHidNative.FileShareRead | WindowsHidNative.FileShareWrite, - 0, WindowsHidNative.OpenExisting, 0, 0); - } - - if (!handle.IsInvalid) - { - devicePath = path; - return handle; - } - - handle.Dispose(); - } - - return null; - } - - private static bool TryParseReport(ReadOnlySpan report, out HostGamepadState state) - { - // USB: report id 0x01, payload starts at [1]. - // Bluetooth extended: report id 0x31, sequence byte at [1], payload at [2]. - int offset; - if (report.Length >= 11 && report[0] == 0x01) - { - offset = 1; - } - else if (report.Length >= 12 && report[0] == 0x31) - { - offset = 2; - } - else - { - state = default; - return false; - } - - var leftX = report[offset + 0]; - var leftY = report[offset + 1]; - var rightX = report[offset + 2]; - var rightY = report[offset + 3]; - var l2 = report[offset + 4]; - var r2 = report[offset + 5]; - var buttons0 = report[offset + 7]; - var buttons1 = report[offset + 8]; - var buttons2 = report[offset + 9]; - - var buttons = HostGamepadButtons.None; - buttons |= (buttons0 & 0x10) != 0 ? HostGamepadButtons.Square : 0; - buttons |= (buttons0 & 0x20) != 0 ? HostGamepadButtons.Cross : 0; - buttons |= (buttons0 & 0x40) != 0 ? HostGamepadButtons.Circle : 0; - buttons |= (buttons0 & 0x80) != 0 ? HostGamepadButtons.Triangle : 0; - buttons |= HatToButtons(buttons0 & 0x0F); - buttons |= (buttons1 & 0x01) != 0 ? HostGamepadButtons.L1 : 0; - buttons |= (buttons1 & 0x02) != 0 ? HostGamepadButtons.R1 : 0; - buttons |= (buttons1 & 0x04) != 0 ? HostGamepadButtons.L2 : 0; - buttons |= (buttons1 & 0x08) != 0 ? HostGamepadButtons.R2 : 0; - buttons |= (buttons1 & 0x20) != 0 ? HostGamepadButtons.Options : 0; - buttons |= (buttons1 & 0x40) != 0 ? HostGamepadButtons.L3 : 0; - buttons |= (buttons1 & 0x80) != 0 ? HostGamepadButtons.R3 : 0; - buttons |= (buttons2 & 0x02) != 0 ? HostGamepadButtons.TouchPad : 0; - - state = new HostGamepadState( - Connected: true, - Buttons: buttons, - LeftX: leftX, - LeftY: leftY, - RightX: rightX, - RightY: rightY, - LeftTrigger: l2, - RightTrigger: r2); - return true; - } - - private static HostGamepadButtons HatToButtons(int hat) => hat switch - { - 0 => HostGamepadButtons.Up, - 1 => HostGamepadButtons.Up | HostGamepadButtons.Right, - 2 => HostGamepadButtons.Right, - 3 => HostGamepadButtons.Right | HostGamepadButtons.Down, - 4 => HostGamepadButtons.Down, - 5 => HostGamepadButtons.Down | HostGamepadButtons.Left, - 6 => HostGamepadButtons.Left, - 7 => HostGamepadButtons.Left | HostGamepadButtons.Up, - _ => 0, - }; -} diff --git a/src/SharpEmu.HLE/Host/Windows/WindowsHidNative.cs b/src/SharpEmu.HLE/Host/Windows/WindowsHidNative.cs deleted file mode 100644 index 22e8afbb..00000000 --- a/src/SharpEmu.HLE/Host/Windows/WindowsHidNative.cs +++ /dev/null @@ -1,141 +0,0 @@ -// Copyright (C) 2026 SharpEmu Emulator Project -// SPDX-License-Identifier: GPL-2.0-or-later - -using System.Runtime.InteropServices; -using Microsoft.Win32.SafeHandles; - -namespace SharpEmu.HLE.Host.Windows; - -/// -/// Minimal Win32 HID interop used to talk to a DualSense controller -/// directly, without any external input library. -/// -internal static partial class WindowsHidNative -{ - internal const int DigcfPresent = 0x02; - internal const int DigcfDeviceInterface = 0x10; - internal const uint GenericRead = 0x80000000; - internal const uint GenericWrite = 0x40000000; - internal const uint FileShareRead = 0x1; - internal const uint FileShareWrite = 0x2; - internal const uint OpenExisting = 3; - - [StructLayout(LayoutKind.Sequential)] - internal struct SpDeviceInterfaceData - { - public int CbSize; - public Guid InterfaceClassGuid; - public int Flags; - public nint Reserved; - } - - [StructLayout(LayoutKind.Sequential)] - internal struct HiddAttributes - { - public int Size; - public ushort VendorId; - public ushort ProductId; - public ushort VersionNumber; - } - - [LibraryImport("hid.dll")] - internal static partial void HidD_GetHidGuid(out Guid hidGuid); - - [LibraryImport("hid.dll")] - [return: MarshalAs(UnmanagedType.Bool)] - internal static partial bool HidD_GetAttributes(SafeFileHandle hidDeviceObject, ref HiddAttributes attributes); - - [LibraryImport("hid.dll")] - [return: MarshalAs(UnmanagedType.Bool)] - internal static partial bool HidD_GetFeature(SafeFileHandle hidDeviceObject, [In, Out] byte[] reportBuffer, int reportBufferLength); - - [LibraryImport("setupapi.dll", EntryPoint = "SetupDiGetClassDevsW")] - internal static partial nint SetupDiGetClassDevs(ref Guid classGuid, nint enumerator, nint hwndParent, int flags); - - [LibraryImport("setupapi.dll")] - [return: MarshalAs(UnmanagedType.Bool)] - internal static partial bool SetupDiEnumDeviceInterfaces( - nint deviceInfoSet, - nint deviceInfoData, - ref Guid interfaceClassGuid, - int memberIndex, - ref SpDeviceInterfaceData deviceInterfaceData); - - [LibraryImport("setupapi.dll", EntryPoint = "SetupDiGetDeviceInterfaceDetailW")] - [return: MarshalAs(UnmanagedType.Bool)] - internal static partial bool SetupDiGetDeviceInterfaceDetail( - nint deviceInfoSet, - ref SpDeviceInterfaceData deviceInterfaceData, - nint deviceInterfaceDetailData, - int deviceInterfaceDetailDataSize, - out int requiredSize, - nint deviceInfoData); - - [LibraryImport("setupapi.dll")] - [return: MarshalAs(UnmanagedType.Bool)] - internal static partial bool SetupDiDestroyDeviceInfoList(nint deviceInfoSet); - - [LibraryImport("kernel32.dll", EntryPoint = "CreateFileW", SetLastError = true, StringMarshalling = StringMarshalling.Utf16)] - internal static partial SafeFileHandle CreateFile( - string fileName, - uint desiredAccess, - uint shareMode, - nint securityAttributes, - uint creationDisposition, - uint flagsAndAttributes, - nint templateFile); - - /// - /// Enumerates the device paths of all present HID interfaces. - /// - internal static List EnumerateHidDevicePaths() - { - var paths = new List(); - HidD_GetHidGuid(out var hidGuid); - var deviceInfoSet = SetupDiGetClassDevs(ref hidGuid, 0, 0, DigcfPresent | DigcfDeviceInterface); - if (deviceInfoSet == -1 || deviceInfoSet == 0) - { - return paths; - } - - try - { - var interfaceData = new SpDeviceInterfaceData - { - CbSize = Marshal.SizeOf(), - }; - - for (var index = 0; SetupDiEnumDeviceInterfaces(deviceInfoSet, 0, ref hidGuid, index, ref interfaceData); index++) - { - SetupDiGetDeviceInterfaceDetail(deviceInfoSet, ref interfaceData, 0, 0, out var requiredSize, 0); - if (requiredSize <= 0) - { - continue; - } - - var detailBuffer = Marshal.AllocHGlobal(requiredSize); - try - { - // SP_DEVICE_INTERFACE_DETAIL_DATA_W.cbSize is 8 on x64 - // (DWORD + aligned WCHAR[1]); the path string follows it. - Marshal.WriteInt32(detailBuffer, 8); - if (SetupDiGetDeviceInterfaceDetail(deviceInfoSet, ref interfaceData, detailBuffer, requiredSize, out _, 0) && - Marshal.PtrToStringUni(detailBuffer + 4) is { Length: > 0 } path) - { - paths.Add(path); - } - } - finally - { - Marshal.FreeHGlobal(detailBuffer); - } - } - } - finally - { - SetupDiDestroyDeviceInfoList(deviceInfoSet); - } - - return paths; - } -} diff --git a/src/SharpEmu.HLE/Host/Windows/WindowsHostInput.cs b/src/SharpEmu.HLE/Host/Windows/WindowsHostInput.cs deleted file mode 100644 index 32673a54..00000000 --- a/src/SharpEmu.HLE/Host/Windows/WindowsHostInput.cs +++ /dev/null @@ -1,101 +0,0 @@ -// Copyright (C) 2026 SharpEmu Emulator Project -// SPDX-License-Identifier: GPL-2.0-or-later - -using System.Runtime.InteropServices; - -namespace SharpEmu.HLE.Host.Windows; - -/// -/// Windows input backend: DualSense over raw HID plus XInput controllers for gamepads, -/// user32 for the keyboard-fallback queries. Rumble fans out to every reader; lightbar -/// only exists on the DualSense. -/// -internal sealed partial class WindowsHostInput : IHostInput -{ - public void EnsureStarted() - { - WindowsDualSenseReader.EnsureStarted(); - WindowsXInputReader.EnsureStarted(); - } - - public int GetGamepadStates(Span destination) - { - var count = 0; - if (count < destination.Length && WindowsDualSenseReader.TryGetState(out var dualSense)) - { - destination[count++] = dualSense; - } - - if (count < destination.Length && WindowsXInputReader.TryGetState(out var xinput)) - { - destination[count++] = xinput; - } - - return count; - } - - public string? DescribeConnectedGamepad() - { - if (WindowsDualSenseReader.TryGetState(out _)) - { - return "DualSense"; - } - - return WindowsXInputReader.TryGetState(out _) ? "Xbox controller" : null; - } - - public void SetRumble(byte largeMotor, byte smallMotor) - { - WindowsDualSenseReader.SetRumble(largeMotor, smallMotor); - WindowsXInputReader.SetRumble(largeMotor, smallMotor); - } - - public void SetTriggerRumble(byte? leftTrigger, byte? rightTrigger) => - WindowsXInputReader.SetTriggerRumble(leftTrigger, rightTrigger); - - public void SetLightbar(byte red, byte green, byte blue) => - WindowsDualSenseReader.SetLightbar(red, green, blue); - - public void ResetLightbar() => WindowsDualSenseReader.ResetLightbar(); - - public bool IsHostWindowFocused() - { - var foregroundWindow = GetForegroundWindow(); - if (foregroundWindow == 0) - { - return false; - } - - GetWindowThreadProcessId(foregroundWindow, out var processId); - if (processId == (uint)Environment.ProcessId) - { - return true; - } - - // The GUI runs the emulator in an isolated child process. Its native - // Vulkan surface is a child of the GUI window, so the foreground - // window belongs to the launcher process rather than this one. - var embeddedHostWindow = HostSessionControl.EmbeddedHostWindow; - var hostTopLevelWindow = embeddedHostWindow == 0 - ? 0 - : GetAncestor(embeddedHostWindow, GetAncestorRoot); - return hostTopLevelWindow != 0 && foregroundWindow == hostTopLevelWindow; - } - - public bool IsKeyDown(int virtualKey) => - (GetAsyncKeyState(virtualKey) & 0x8000) != 0; - - [LibraryImport("user32.dll")] - private static partial short GetAsyncKeyState(int vKey); - - [LibraryImport("user32.dll")] - private static partial nint GetForegroundWindow(); - - [LibraryImport("user32.dll")] - private static partial uint GetWindowThreadProcessId(nint hWnd, out uint processId); - - [LibraryImport("user32.dll")] - private static partial nint GetAncestor(nint hWnd, uint gaFlags); - - private const uint GetAncestorRoot = 2; -} diff --git a/src/SharpEmu.HLE/Host/Windows/WindowsHostPlatform.cs b/src/SharpEmu.HLE/Host/Windows/WindowsHostPlatform.cs index 0e5ab7e2..74374f98 100644 --- a/src/SharpEmu.HLE/Host/Windows/WindowsHostPlatform.cs +++ b/src/SharpEmu.HLE/Host/Windows/WindowsHostPlatform.cs @@ -1,6 +1,8 @@ // Copyright (C) 2026 SharpEmu Emulator Project // SPDX-License-Identifier: GPL-2.0-or-later +using SharpEmu.HLE.Host.Sdl; + namespace SharpEmu.HLE.Host.Windows; internal sealed class WindowsHostPlatform : IHostPlatform @@ -11,7 +13,7 @@ internal sealed class WindowsHostPlatform : IHostPlatform public IHostSymbolResolver Symbols { get; } = new WindowsHostSymbolResolver(); - public IHostAudioOutput Audio { get; } = new WindowsWaveOutAudio(); + public IHostAudioOutput Audio { get; } = new SdlHostAudio(); - public IHostInput Input { get; } = new WindowsHostInput(); + public IHostInput Input { get; } = new WindowHostInput(); } diff --git a/src/SharpEmu.HLE/Host/Windows/WindowsXInputReader.cs b/src/SharpEmu.HLE/Host/Windows/WindowsXInputReader.cs deleted file mode 100644 index f01c0269..00000000 --- a/src/SharpEmu.HLE/Host/Windows/WindowsXInputReader.cs +++ /dev/null @@ -1,277 +0,0 @@ -// Copyright (C) 2026 SharpEmu Emulator Project -// SPDX-License-Identifier: GPL-2.0-or-later - -using System.Runtime.InteropServices; - -namespace SharpEmu.HLE.Host.Windows; - -/// -/// Reads Xbox 360 / Xbox One (and other XInput-compatible) controllers via -/// the Windows XInput API on a background thread, translated to -/// conventions. Supports rumble and hot-plug -/// retry; the first connected slot (of four) is used. -/// -public static partial class WindowsXInputReader -{ - private const uint ErrorSuccess = 0; - private const int SlotCount = 4; - private const byte TriggerThreshold = 30; // XINPUT_GAMEPAD_TRIGGER_THRESHOLD - - // XINPUT_GAMEPAD wButtons bit values. - private const ushort XinputDpadUp = 0x0001; - private const ushort XinputDpadDown = 0x0002; - private const ushort XinputDpadLeft = 0x0004; - private const ushort XinputDpadRight = 0x0008; - private const ushort XinputStart = 0x0010; - private const ushort XinputBack = 0x0020; - private const ushort XinputLeftThumb = 0x0040; - private const ushort XinputRightThumb = 0x0080; - private const ushort XinputLeftShoulder = 0x0100; - private const ushort XinputRightShoulder = 0x0200; - private const ushort XinputA = 0x1000; - private const ushort XinputB = 0x2000; - private const ushort XinputX = 0x4000; - private const ushort XinputY = 0x8000; - - private static readonly object Gate = new(); - private static HostGamepadState _state; - private static bool _started; - private static int _slot = -1; // connected XInput user index, -1 when none - private static byte _motorLeft; - private static byte _motorRight; - private static byte _triggerLeft; - private static byte _triggerRight; - - /// Starts the background reader once; safe to call repeatedly. - public static void EnsureStarted() - { - // The GUI source-links this reader and calls it directly, without the - // host-platform resolution that otherwise guarantees Windows. - if (!OperatingSystem.IsWindows()) - { - return; - } - - lock (Gate) - { - if (_started) - { - return; - } - - _started = true; - var thread = new Thread(ReadLoop) - { - IsBackground = true, - Name = "XInputReader", - }; - thread.Start(); - } - } - - public static bool TryGetState(out HostGamepadState state) - { - lock (Gate) - { - state = _state; - } - - return state.Connected; - } - - private static void SetState(in HostGamepadState state) - { - lock (Gate) - { - _state = state; - } - } - - /// Sets rumble; large = left/strong motor, small = right/weak. - internal static void SetRumble(byte largeMotor, byte smallMotor) - { - lock (Gate) - { - if (_motorLeft == largeMotor && _motorRight == smallMotor) - { - return; - } - - _motorLeft = largeMotor; - _motorRight = smallMotor; - SendRumbleLocked(); - } - } - - /// Approximates per-trigger vibration on the two XInput body motors. - internal static void SetTriggerRumble(byte? leftTrigger, byte? rightTrigger) - { - lock (Gate) - { - var changed = false; - if (leftTrigger is { } left) - { - changed |= _triggerLeft != left; - _triggerLeft = left; - } - - if (rightTrigger is { } right) - { - changed |= _triggerRight != right; - _triggerRight = right; - } - - if (changed) - { - SendRumbleLocked(); - } - } - } - - private static void SendRumbleLocked() - { - if (_slot < 0) - { - return; // resent on connect - } - - var vibration = new XInputVibration - { - LeftMotorSpeed = (ushort)(Math.Max(_motorLeft, _triggerLeft) * 257), - RightMotorSpeed = (ushort)(Math.Max(_motorRight, _triggerRight) * 257), - }; - _ = XInputSetState((uint)_slot, ref vibration); - } - - private static void ReadLoop() - { - try - { - while (true) - { - var slot = FindConnectedSlot(); - if (slot < 0) - { - SetState(default); - Thread.Sleep(1000); - continue; - } - - lock (Gate) - { - _slot = slot; - SendRumbleLocked(); - } - - Console.Error.WriteLine("[LOADER][INFO] XInput (Xbox) controller connected."); - while (XInputGetState((uint)slot, out var state) == ErrorSuccess) - { - SetState(Translate(state.Gamepad)); - Thread.Sleep(8); - } - - Console.Error.WriteLine("[LOADER][INFO] XInput (Xbox) controller disconnected."); - lock (Gate) - { - _slot = -1; - _motorLeft = 0; - _motorRight = 0; - _triggerLeft = 0; - _triggerRight = 0; - _state = default; - } - - Thread.Sleep(1000); - } - } - catch (DllNotFoundException) - { - // XInput unavailable on this system; leave the reader disconnected. - } - catch (EntryPointNotFoundException) - { - } - } - - private static int FindConnectedSlot() - { - for (var index = 0; index < SlotCount; index++) - { - if (XInputGetState((uint)index, out _) == ErrorSuccess) - { - return index; - } - } - - return -1; - } - - private static HostGamepadState Translate(in XInputGamepad pad) - { - var buttons = HostGamepadButtons.None; - buttons |= (pad.Buttons & XinputDpadUp) != 0 ? HostGamepadButtons.Up : 0; - buttons |= (pad.Buttons & XinputDpadDown) != 0 ? HostGamepadButtons.Down : 0; - buttons |= (pad.Buttons & XinputDpadLeft) != 0 ? HostGamepadButtons.Left : 0; - buttons |= (pad.Buttons & XinputDpadRight) != 0 ? HostGamepadButtons.Right : 0; - buttons |= (pad.Buttons & XinputStart) != 0 ? HostGamepadButtons.Options : 0; - buttons |= (pad.Buttons & XinputBack) != 0 ? HostGamepadButtons.TouchPad : 0; - buttons |= (pad.Buttons & XinputLeftThumb) != 0 ? HostGamepadButtons.L3 : 0; - buttons |= (pad.Buttons & XinputRightThumb) != 0 ? HostGamepadButtons.R3 : 0; - buttons |= (pad.Buttons & XinputLeftShoulder) != 0 ? HostGamepadButtons.L1 : 0; - buttons |= (pad.Buttons & XinputRightShoulder) != 0 ? HostGamepadButtons.R1 : 0; - buttons |= (pad.Buttons & XinputA) != 0 ? HostGamepadButtons.Cross : 0; - buttons |= (pad.Buttons & XinputB) != 0 ? HostGamepadButtons.Circle : 0; - buttons |= (pad.Buttons & XinputX) != 0 ? HostGamepadButtons.Square : 0; - buttons |= (pad.Buttons & XinputY) != 0 ? HostGamepadButtons.Triangle : 0; - buttons |= pad.LeftTrigger > TriggerThreshold ? HostGamepadButtons.L2 : 0; - buttons |= pad.RightTrigger > TriggerThreshold ? HostGamepadButtons.R2 : 0; - - return new HostGamepadState( - Connected: true, - Buttons: buttons, - LeftX: AxisToByte(pad.ThumbLX), - LeftY: AxisToByteInverted(pad.ThumbLY), - RightX: AxisToByte(pad.ThumbRX), - RightY: AxisToByteInverted(pad.ThumbRY), - LeftTrigger: pad.LeftTrigger, - RightTrigger: pad.RightTrigger); - } - - private static byte AxisToByte(short value) => (byte)((value + 32768) >> 8); - - // XInput Y grows upward, host pad conventions report Y growing downward. - private static byte AxisToByteInverted(short value) => (byte)(255 - ((value + 32768) >> 8)); - - [StructLayout(LayoutKind.Sequential)] - private struct XInputGamepad - { - public ushort Buttons; - public byte LeftTrigger; - public byte RightTrigger; - public short ThumbLX; - public short ThumbLY; - public short ThumbRX; - public short ThumbRY; - } - - [StructLayout(LayoutKind.Sequential)] - private struct XInputState - { - public uint PacketNumber; - public XInputGamepad Gamepad; - } - - [StructLayout(LayoutKind.Sequential)] - private struct XInputVibration - { - public ushort LeftMotorSpeed; - public ushort RightMotorSpeed; - } - - // xinput1_4.dll ships with Windows 8 and later. - [LibraryImport("xinput1_4.dll")] - private static partial uint XInputGetState(uint userIndex, out XInputState state); - - [LibraryImport("xinput1_4.dll")] - private static partial uint XInputSetState(uint userIndex, ref XInputVibration vibration); -} diff --git a/src/SharpEmu.Libs/Gpu/Metal/MetalHostInput.cs b/src/SharpEmu.Libs/Gpu/Metal/MetalHostInput.cs deleted file mode 100644 index 83dbfa29..00000000 --- a/src/SharpEmu.Libs/Gpu/Metal/MetalHostInput.cs +++ /dev/null @@ -1,182 +0,0 @@ -// Copyright (C) 2026 SharpEmu Emulator Project -// SPDX-License-Identifier: GPL-2.0-or-later - -using SharpEmu.HLE.Host; -using SharpEmu.HLE.Host.Posix; - -namespace SharpEmu.Libs.Gpu.Metal; - -/// -/// Keyboard state sampled from the Metal presenter's window, feeding the POSIX -/// host input seam so pad emulation works like the Vulkan presenter's -/// HostWindowInput. Key events arrive on the AppKit main thread as macOS -/// virtual key codes; pad reads happen on guest threads, so state is guarded. -/// Window gamepads are not surfaced by AppKit — controller support would go -/// through GameController.framework and is out of scope here. -/// -internal static class MetalHostInput -{ - private static readonly object Gate = new(); - private static readonly HashSet Pressed = new(); - private static volatile bool _connected; - - /// Registers this window's keyboard as the host input source. - public static void Attach() - { - _connected = true; - PosixHostInput.SetSource(new MetalWindowInputSource()); - Console.Error.WriteLine("[LOADER][INFO] Window keyboard input attached for pad emulation."); - } - - // Debug automation: SHARPEMU_METAL_AUTOKEY="12:0x24,15:0x24" presses the - // macOS key code at each elapsed-seconds mark for a few frames, letting - // headless test runs navigate menus without a human at the keyboard. - private static readonly List<(double At, ushort Key, bool[] State)> _autoKeys = ParseAutoKeys(); - private static readonly System.Diagnostics.Stopwatch _autoKeyClock = - System.Diagnostics.Stopwatch.StartNew(); - - private static List<(double, ushort, bool[])> ParseAutoKeys() - { - var keys = new List<(double, ushort, bool[])>(); - var spec = Environment.GetEnvironmentVariable("SHARPEMU_METAL_AUTOKEY"); - if (string.IsNullOrWhiteSpace(spec)) - { - return keys; - } - - foreach (var entry in spec.Split(',', StringSplitOptions.RemoveEmptyEntries)) - { - var parts = entry.Split(':'); - if (parts.Length == 2 && - double.TryParse(parts[0], out var at) && - TryParseKeyCode(parts[1], out var key)) - { - keys.Add((at, key, new bool[2])); - } - } - - return keys; - } - - private static bool TryParseKeyCode(string text, out ushort key) - { - return text.StartsWith("0x", StringComparison.OrdinalIgnoreCase) - ? ushort.TryParse(text[2..], System.Globalization.NumberStyles.HexNumber, null, out key) - : ushort.TryParse(text, out key); - } - - /// Called once per render frame; fires and releases scripted keys. - public static void PumpAutoKeys() - { - if (_autoKeys.Count == 0) - { - return; - } - - var elapsed = _autoKeyClock.Elapsed.TotalSeconds; - foreach (var (at, key, state) in _autoKeys) - { - if (!state[0] && elapsed >= at) - { - state[0] = true; - KeyDown(key, isRepeat: false); - Console.Error.WriteLine($"[LOADER][INFO] Metal autokey press 0x{key:X} at {elapsed:F1}s"); - } - else if (state[0] && !state[1] && elapsed >= at + 0.2) - { - state[1] = true; - KeyUp(key); - } - } - } - - public static void KeyDown(ushort keyCode, bool isRepeat) - { - // kVK_F1: parity with the Vulkan window's perf-overlay toggle. - if (keyCode == 0x7A && !isRepeat) - { - VideoOut.PerfOverlay.Toggle(); - } - - lock (Gate) - { - Pressed.Add(keyCode); - } - } - - public static void KeyUp(ushort keyCode) - { - lock (Gate) - { - Pressed.Remove(keyCode); - } - } - - private static bool IsKeyCodeDown(ushort keyCode) - { - lock (Gate) - { - return Pressed.Contains(keyCode); - } - } - - private sealed class MetalWindowInputSource : IPosixWindowInputSource - { - public bool HasKeyboardFocus => _connected; - - public bool IsKeyDown(int virtualKey) => - TryMapVirtualKey(virtualKey, out var keyCode) && IsKeyCodeDown(keyCode); - - public int GetGamepadStates(Span destination) => 0; - - public string? DescribeConnectedGamepad() => null; - } - - /// Windows virtual-key semantics (the seam's contract) to macOS - /// kVK virtual key codes, covering the keys pad emulation polls. - private static bool TryMapVirtualKey(int vk, out ushort keyCode) - { - keyCode = vk switch - { - 0x08 => 0x33, // Backspace -> kVK_Delete - 0x09 => 0x30, // Tab - 0x0D => 0x24, // Enter -> kVK_Return - 0x1B => 0x35, // Escape - 0x20 => 0x31, // Space - 0x25 => 0x7B, // Left - 0x26 => 0x7E, // Up - 0x27 => 0x7C, // Right - 0x28 => 0x7D, // Down - // Letters: macOS ANSI key codes are layout-position based and - // non-contiguous, so map each polled letter explicitly. - 0x41 => 0x00, // A - 0x42 => 0x0B, // B - 0x43 => 0x08, // C - 0x44 => 0x02, // D - 0x45 => 0x0E, // E - 0x46 => 0x03, // F - 0x47 => 0x05, // G - 0x48 => 0x04, // H - 0x49 => 0x22, // I - 0x4A => 0x26, // J - 0x4B => 0x28, // K - 0x4C => 0x25, // L - 0x4D => 0x2E, // M - 0x4E => 0x2D, // N - 0x4F => 0x1F, // O - 0x50 => 0x23, // P - 0x51 => 0x0C, // Q - 0x52 => 0x0F, // R - 0x53 => 0x01, // S - 0x54 => 0x11, // T - 0x55 => 0x20, // U - 0x56 => 0x09, // V - 0x57 => 0x0D, // W - 0x58 => 0x07, // X - 0x59 => 0x10, // Y - 0x5A => 0x06, // Z - _ => ushort.MaxValue, - }; - return keyCode != ushort.MaxValue; - } -} diff --git a/src/SharpEmu.Libs/Pad/HostWindowInput.cs b/src/SharpEmu.Libs/Pad/HostWindowInput.cs index 89ff43e0..116fad99 100644 --- a/src/SharpEmu.Libs/Pad/HostWindowInput.cs +++ b/src/SharpEmu.Libs/Pad/HostWindowInput.cs @@ -2,138 +2,135 @@ // SPDX-License-Identifier: GPL-2.0-or-later using SharpEmu.HLE.Host; -using SharpEmu.HLE.Host.Posix; -using Silk.NET.Input; namespace SharpEmu.Libs.Pad; -/// -/// Keyboard and gamepad state sampled from the presenter's window, feeding -/// the POSIX host input seam (macOS/Linux have no user32/XInput/raw-HID -/// readers). The presenter attaches the window's input context once the -/// window exists; input events arrive on the window thread and pad reads -/// happen on guest threads, so all state is guarded. -/// +/// Cross-platform input state supplied by the SDL game window. public static class HostWindowInput { private static readonly object Gate = new(); - private static readonly HashSet Pressed = new(); - private static volatile bool _connected; - - // Latest window-gamepad snapshot in the host seam's conventions. + private static readonly HashSet PressedKeys = new(); + private static bool _focused; private static bool _gamepadConnected; private static string? _gamepadName; - private static HostGamepadButtons _gamepadButtons; - private static byte _gamepadLeftX = 128; - private static byte _gamepadLeftY = 128; - private static byte _gamepadRightX = 128; - private static byte _gamepadRightY = 128; - private static byte _gamepadL2; - private static byte _gamepadR2; + private static HostGamepadState _gamepadState; + private static IHostGamepadOutput? _gamepadOutput; + private static readonly WindowInputSource Source = new(); - /// True once a window keyboard is delivering events. - public static bool IsConnected => _connected; - - public static void Attach(IInputContext input) - { - foreach (var keyboard in input.Keyboards) - { - keyboard.KeyDown += (_, key, _) => - { - if (key == Key.F1) - { - VideoOut.PerfOverlay.Toggle(); - } - - lock (Gate) - { - Pressed.Add(key); - } - }; - keyboard.KeyUp += (_, key, _) => - { - lock (Gate) - { - Pressed.Remove(key); - } - }; - } - - if (input.Keyboards.Count > 0) - { - _connected = true; - } - - foreach (var gamepad in input.Gamepads) - { - AttachGamepad(gamepad); - } - - input.ConnectionChanged += (device, connected) => - { - if (device is not IGamepad gamepad) - { - return; - } - - if (connected) - { - AttachGamepad(gamepad); - return; - } - - lock (Gate) - { - _gamepadConnected = false; - _gamepadName = null; - _gamepadButtons = HostGamepadButtons.None; - _gamepadLeftX = 128; - _gamepadLeftY = 128; - _gamepadRightX = 128; - _gamepadRightY = 128; - _gamepadL2 = 0; - _gamepadR2 = 0; - } - }; - - PosixHostInput.SetSource(new WindowInputSource()); - } - - public static bool IsKeyDown(Key key) + public static void Connect(IHostGamepadOutput? gamepadOutput = null) { lock (Gate) { - return Pressed.Contains(key); + _focused = true; + _gamepadOutput = gamepadOutput; + PressedKeys.Clear(); + } + + HostWindowInputSource.Set(Source); + } + + public static void Disconnect() + { + lock (Gate) + { + _focused = false; + _gamepadConnected = false; + _gamepadName = null; + _gamepadState = default; + _gamepadOutput = null; + PressedKeys.Clear(); + } + + HostWindowInputSource.Clear(Source); + } + + public static void SetFocused(bool focused) + { + lock (Gate) + { + _focused = focused; + if (!focused) + { + PressedKeys.Clear(); + } } } - private sealed class WindowInputSource : IPosixWindowInputSource + public static void SetKey(int virtualKey, bool down) { - public bool HasKeyboardFocus => _connected; + lock (Gate) + { + if (down) + { + PressedKeys.Add(virtualKey); + } + else + { + PressedKeys.Remove(virtualKey); + } + } + } + + public static void SetGamepad(string? name, HostGamepadState state) + { + lock (Gate) + { + _gamepadConnected = state.Connected; + _gamepadName = name; + _gamepadState = state; + } + } + + public static void ClearGamepad() + { + lock (Gate) + { + _gamepadConnected = false; + _gamepadName = null; + _gamepadState = default; + } + } + + internal static byte ToStickByte(short value) + { + var normalized = value + 32768; + return (byte)Math.Clamp((normalized * 255 + 32767) / 65535, 0, 255); + } + + internal static byte ToTriggerByte(short value) => + (byte)Math.Clamp(value * 255 / 32767, 0, 255); + + private sealed class WindowInputSource : IHostWindowInputSource + { + public bool HasKeyboardFocus + { + get + { + lock (Gate) + { + return _focused; + } + } + } public bool IsKeyDown(int virtualKey) { - return TryMapVirtualKey(virtualKey, out var key) && HostWindowInput.IsKeyDown(key); + lock (Gate) + { + return PressedKeys.Contains(virtualKey); + } } public int GetGamepadStates(Span destination) { lock (Gate) { - if (!_gamepadConnected || destination.Length == 0) + if (!_gamepadConnected || destination.IsEmpty) { return 0; } - destination[0] = new HostGamepadState( - Connected: true, - Buttons: _gamepadButtons, - LeftX: _gamepadLeftX, - LeftY: _gamepadLeftY, - RightX: _gamepadRightX, - RightY: _gamepadRightY, - LeftTrigger: _gamepadL2, - RightTrigger: _gamepadR2); + destination[0] = _gamepadState; return 1; } } @@ -142,139 +139,80 @@ public static class HostWindowInput { lock (Gate) { - return _gamepadConnected ? _gamepadName ?? "GLFW gamepad" : null; + return _gamepadConnected ? _gamepadName ?? "SDL gamepad" : null; } } - } - private static bool TryMapVirtualKey(int vk, out Key key) - { - key = vk switch + public void SetRumble(byte largeMotor, byte smallMotor) { - 0x08 => Key.Backspace, - 0x09 => Key.Tab, - 0x0D => Key.Enter, - 0x1B => Key.Escape, - 0x25 => Key.Left, - 0x26 => Key.Up, - 0x27 => Key.Right, - 0x28 => Key.Down, - >= 0x41 and <= 0x5A => Key.A + (vk - 0x41), - _ => Key.Unknown, - }; - return key != Key.Unknown; - } + IHostGamepadOutput? output; + lock (Gate) + { + output = _gamepadOutput; + } - private static void AttachGamepad(IGamepad gamepad) - { - lock (Gate) - { - _gamepadConnected = true; - _gamepadName = gamepad.Name; + output?.SetRumble(largeMotor, smallMotor); } - gamepad.ButtonDown += (_, button) => + public void SetTriggerRumble(byte? leftTrigger, byte? rightTrigger) { - var bit = MapButton(button.Name); - if (bit == HostGamepadButtons.None) + IHostGamepadOutput? output; + lock (Gate) { - return; + output = _gamepadOutput; } + output?.SetTriggerRumble(leftTrigger, rightTrigger); + } + + public void SetAdaptiveTriggerEffect( + HostAdaptiveTriggerEffect? leftTrigger, + HostAdaptiveTriggerEffect? rightTrigger) + { + IHostGamepadOutput? output; lock (Gate) { - _gamepadButtons |= bit; - } - }; - gamepad.ButtonUp += (_, button) => - { - var bit = MapButton(button.Name); - if (bit == HostGamepadButtons.None) - { - return; + output = _gamepadOutput; } - lock (Gate) - { - _gamepadButtons &= ~bit; - } - }; - gamepad.ThumbstickMoved += (_, thumbstick) => + output?.SetAdaptiveTriggerEffect(leftTrigger, rightTrigger); + } + + public void SetLightbar(byte red, byte green, byte blue) { - // Silk's GLFW backend reports sticks -1..1 with +Y pointing down, - // matching the seam's 0..255 down-growing convention after biasing. - var x = ToStickByte(thumbstick.X); - var y = ToStickByte(thumbstick.Y); + IHostGamepadOutput? output; lock (Gate) { - if (thumbstick.Index == 0) - { - _gamepadLeftX = x; - _gamepadLeftY = y; - } - else - { - _gamepadRightX = x; - _gamepadRightY = y; - } + output = _gamepadOutput; } - }; - gamepad.TriggerMoved += (_, trigger) => + + output?.SetLightbar(red, green, blue); + } + + public void ResetLightbar() { - // GLFW gamepad triggers rest at -1 and saturate at +1. - var value = (byte)Math.Clamp((int)((trigger.Position + 1.0f) * 0.5f * 255.0f), 0, 255); + IHostGamepadOutput? output; lock (Gate) { - if (trigger.Index == 0) - { - _gamepadL2 = value; - if (value > 64) - { - _gamepadButtons |= HostGamepadButtons.L2; - } - else - { - _gamepadButtons &= ~HostGamepadButtons.L2; - } - } - else - { - _gamepadR2 = value; - if (value > 64) - { - _gamepadButtons |= HostGamepadButtons.R2; - } - else - { - _gamepadButtons &= ~HostGamepadButtons.R2; - } - } + output = _gamepadOutput; } - }; + + output?.ResetLightbar(); + } } - - internal static byte ToStickByte(float value) - { - return (byte)Math.Clamp((int)MathF.Round((value + 1.0f) * 127.5f), 0, 255); - } - - private static HostGamepadButtons MapButton(ButtonName name) => name switch - { - // GLFW reports the Xbox layout: A=Cross, B=Circle, X=Square, Y=Triangle. - ButtonName.A => HostGamepadButtons.Cross, - ButtonName.B => HostGamepadButtons.Circle, - ButtonName.X => HostGamepadButtons.Square, - ButtonName.Y => HostGamepadButtons.Triangle, - ButtonName.LeftBumper => HostGamepadButtons.L1, - ButtonName.RightBumper => HostGamepadButtons.R1, - ButtonName.Back => HostGamepadButtons.TouchPad, - ButtonName.Start => HostGamepadButtons.Options, - ButtonName.LeftStick => HostGamepadButtons.L3, - ButtonName.RightStick => HostGamepadButtons.R3, - ButtonName.DPadUp => HostGamepadButtons.Up, - ButtonName.DPadRight => HostGamepadButtons.Right, - ButtonName.DPadDown => HostGamepadButtons.Down, - ButtonName.DPadLeft => HostGamepadButtons.Left, - _ => HostGamepadButtons.None, - }; +} + +public interface IHostGamepadOutput +{ + void SetRumble(byte largeMotor, byte smallMotor); + + void SetTriggerRumble(byte? leftTrigger, byte? rightTrigger); + + void SetAdaptiveTriggerEffect( + HostAdaptiveTriggerEffect? leftTrigger, + HostAdaptiveTriggerEffect? rightTrigger); + + void SetLightbar(byte red, byte green, byte blue); + + void ResetLightbar(); } diff --git a/src/SharpEmu.Libs/Pad/PadExports.cs b/src/SharpEmu.Libs/Pad/PadExports.cs index 12990402..6db7e017 100644 --- a/src/SharpEmu.Libs/Pad/PadExports.cs +++ b/src/SharpEmu.Libs/Pad/PadExports.cs @@ -37,6 +37,7 @@ public static class PadExports private static PadState _cachedInputState; private static bool _initialized; + private static int _motionSensorEnabled; private static int _controlsAnnouncementLogged; [SysAbiExport( @@ -151,9 +152,13 @@ public static class PadExports public static int PadSetMotionSensorState(CpuContext ctx) { var handle = unchecked((int)ctx[CpuRegister.Rdi]); - return IsPrimaryPadHandle(handle) - ? ctx.SetReturn(0) - : ctx.SetReturn(OrbisPadErrorInvalidHandle); + if (!IsPrimaryPadHandle(handle)) + { + return ctx.SetReturn(OrbisPadErrorInvalidHandle); + } + + Volatile.Write(ref _motionSensorEnabled, ctx[CpuRegister.Rsi] != 0 ? 1 : 0); + return ctx.SetReturn(0); } [SysAbiExport( @@ -362,24 +367,170 @@ public static class PadExports } var triggerMask = parameter[0]; - HostPlatform.Current.Input.SetTriggerRumble( - (triggerMask & 0x01) != 0 ? DecodeTriggerVibration(parameter[8..64]) : null, - (triggerMask & 0x02) != 0 ? DecodeTriggerVibration(parameter[64..120]) : null); + HostPlatform.Current.Input.SetAdaptiveTriggerEffect( + (triggerMask & 0x01) != 0 ? DecodeTriggerEffect(parameter[8..64]) : null, + (triggerMask & 0x02) != 0 ? DecodeTriggerEffect(parameter[64..120]) : null); return ctx.SetReturn((int)OrbisGen2Result.ORBIS_GEN2_OK); } - private static byte DecodeTriggerVibration(ReadOnlySpan command) + private static HostAdaptiveTriggerEffect DecodeTriggerEffect(ReadOnlySpan command) { var mode = BinaryPrimitives.ReadUInt32LittleEndian(command); - var amplitude = mode switch + var parameters = command[8..]; + Span native = stackalloc byte[11]; + native.Clear(); + byte fallbackStrength = 0; + switch (mode) { - 3 when command[10] != 0 => command[9], - 6 when command[8] != 0 => command[9..19].ToArray().Max(), - _ => (byte)0, - }; - return (byte)(Math.Min(amplitude, (byte)8) * 255 / 8); + case 1: + EncodeFeedback(native, parameters[0], parameters[1]); + fallbackStrength = ScaleTriggerStrength(parameters[1]); + break; + case 2: + EncodeWeapon(native, parameters[0], parameters[1], parameters[2]); + fallbackStrength = ScaleTriggerStrength(parameters[2]); + break; + case 3: + EncodeZonedEffect(native, 0x26, parameters[0], parameters[1], parameters[2]); + fallbackStrength = ScaleTriggerStrength(parameters[1]); + break; + case 4: + EncodeZonedStrengths(native, 0x21, parameters[..10], 0); + fallbackStrength = ScaleTriggerStrength(Max(parameters[..10])); + break; + case 5: + EncodeSlope(native, parameters[0], parameters[1], parameters[2], parameters[3]); + fallbackStrength = ScaleTriggerStrength(Math.Max(parameters[2], parameters[3])); + break; + case 6: + EncodeZonedStrengths(native, 0x26, parameters[1..11], parameters[0]); + fallbackStrength = parameters[0] == 0 ? (byte)0 : ScaleTriggerStrength(Max(parameters[1..11])); + break; + default: + native[0] = 0x05; + break; + } + + return HostAdaptiveTriggerEffect.FromBytes(native, fallbackStrength); } + private static void EncodeFeedback(Span destination, byte position, byte strength) + { + if (position > 9 || strength is 0 or > 8) + { + destination[0] = 0x05; + return; + } + + Span strengths = stackalloc byte[10]; + strengths[position..].Fill(strength); + EncodeZonedStrengths(destination, 0x21, strengths, 0); + } + + private static void EncodeWeapon(Span destination, byte start, byte end, byte strength) + { + if (start is < 2 or > 7 || end <= start || end > 8 || strength is 0 or > 8) + { + destination[0] = 0x05; + return; + } + + var zones = (ushort)((1 << start) | (1 << end)); + destination[0] = 0x25; + BinaryPrimitives.WriteUInt16LittleEndian(destination[1..], zones); + destination[3] = (byte)(strength - 1); + } + + private static void EncodeZonedEffect( + Span destination, + byte nativeMode, + byte position, + byte strength, + byte frequency) + { + if (position > 9 || strength is 0 or > 8 || frequency == 0) + { + destination[0] = 0x05; + return; + } + + Span strengths = stackalloc byte[10]; + strengths[position..].Fill(strength); + EncodeZonedStrengths(destination, nativeMode, strengths, frequency); + } + + private static void EncodeSlope( + Span destination, + byte startPosition, + byte endPosition, + byte startStrength, + byte endStrength) + { + if (startPosition > 8 || endPosition <= startPosition || endPosition > 9 || + startStrength is 0 or > 8 || endStrength is 0 or > 8) + { + destination[0] = 0x05; + return; + } + + Span strengths = stackalloc byte[10]; + var distance = endPosition - startPosition; + for (var index = startPosition; index < strengths.Length; index++) + { + strengths[index] = index <= endPosition + ? (byte)Math.Round(startStrength + ((endStrength - startStrength) * (index - startPosition) / (double)distance)) + : endStrength; + } + + EncodeZonedStrengths(destination, 0x21, strengths, 0); + } + + private static void EncodeZonedStrengths( + Span destination, + byte nativeMode, + ReadOnlySpan strengths, + byte frequency) + { + ushort activeZones = 0; + uint packedStrengths = 0; + for (var index = 0; index < Math.Min(strengths.Length, 10); index++) + { + var strength = strengths[index]; + if (strength is 0 or > 8) + { + continue; + } + + activeZones |= (ushort)(1 << index); + packedStrengths |= (uint)(strength - 1) << (index * 3); + } + + if (activeZones == 0 || (nativeMode == 0x26 && frequency == 0)) + { + destination[0] = 0x05; + return; + } + + destination[0] = nativeMode; + BinaryPrimitives.WriteUInt16LittleEndian(destination[1..], activeZones); + BinaryPrimitives.WriteUInt32LittleEndian(destination[3..], packedStrengths); + destination[9] = frequency; + } + + private static byte Max(ReadOnlySpan values) + { + byte result = 0; + foreach (var value in values) + { + result = Math.Max(result, value); + } + + return result; + } + + private static byte ScaleTriggerStrength(byte strength) => + (byte)(Math.Min(strength, (byte)8) * 255 / 8); + [SysAbiExport( Nid = "yFVnOdGxvZY", ExportName = "scePadSetVibration", @@ -478,6 +629,17 @@ public static class PadExports data[0x08] = l2; data[0x09] = r2; BinaryPrimitives.WriteSingleLittleEndian(data[0x18..], 1.0f); + if (Volatile.Read(ref _motionSensorEnabled) != 0 && input.Motion.Available) + { + BinaryPrimitives.WriteSingleLittleEndian(data[0x1C..], input.Motion.AccelerationX); + BinaryPrimitives.WriteSingleLittleEndian(data[0x20..], input.Motion.AccelerationY); + BinaryPrimitives.WriteSingleLittleEndian(data[0x24..], input.Motion.AccelerationZ); + BinaryPrimitives.WriteSingleLittleEndian(data[0x28..], input.Motion.AngularVelocityX); + BinaryPrimitives.WriteSingleLittleEndian(data[0x2C..], input.Motion.AngularVelocityY); + BinaryPrimitives.WriteSingleLittleEndian(data[0x30..], input.Motion.AngularVelocityZ); + } + + WriteTouchData(data, input.Touch); data[0x4C] = 1; var timestampTicks = Stopwatch.GetTimestamp(); var timestampMicroseconds = @@ -508,6 +670,10 @@ public static class PadExports var rightY = acceptsKeyboardInput ? ReadAnalogStick(input.IsKeyDown(0x49), input.IsKeyDown(0x4B)) : (byte)128; var l2 = acceptsKeyboardInput && input.IsKeyDown(0x52) ? (byte)255 : (byte)0; var r2 = acceptsKeyboardInput && input.IsKeyDown(0x46) ? (byte)255 : (byte)0; + var gamepadType = HostGamepadType.Generic; + var connection = HostGamepadConnection.Unknown; + var motion = default(HostMotionState); + var touch = default(HostTouchState); Span gamepads = stackalloc HostGamepadState[2]; var gamepadCount = input.GetGamepadStates(gamepads); @@ -523,6 +689,13 @@ public static class PadExports rightY = MergeAxis(pad.RightY, rightY); l2 = Math.Max(l2, pad.LeftTrigger); r2 = Math.Max(r2, pad.RightTrigger); + if (index == 0) + { + gamepadType = pad.Type; + connection = pad.Connection; + motion = pad.Motion; + touch = pad.Touch; + } } if (IsAutoCrossActive()) @@ -538,7 +711,11 @@ public static class PadExports RightX: rightX, RightY: rightY, L2: l2, - R2: r2); + R2: r2, + Type: gamepadType, + Connection: connection, + Motion: motion, + Touch: touch); _lastInputSampleTicks = now; return _cachedInputState; } @@ -592,6 +769,7 @@ public static class PadExports private static uint ToOrbisButtons(HostGamepadButtons buttons) { uint result = 0; + if ((buttons & HostGamepadButtons.Create) != 0) result |= OrbisPadButton.Share; if ((buttons & HostGamepadButtons.Up) != 0) result |= OrbisPadButton.Up; if ((buttons & HostGamepadButtons.Down) != 0) result |= OrbisPadButton.Down; if ((buttons & HostGamepadButtons.Left) != 0) result |= OrbisPadButton.Left; @@ -611,6 +789,34 @@ public static class PadExports return result; } + private static void WriteTouchData(Span data, HostTouchState touch) + { + Span active = stackalloc HostTouchPoint[2]; + var count = 0; + if (touch.First.Active) + { + active[count++] = touch.First; + } + if (touch.Second.Active) + { + active[count++] = touch.Second; + } + + data[0x34] = (byte)count; + for (var index = 0; index < count; index++) + { + var offset = 0x3C + (index * 8); + var point = active[index]; + BinaryPrimitives.WriteUInt16LittleEndian( + data[offset..], + (ushort)Math.Round(Math.Clamp(point.X, 0, 1) * 1919)); + BinaryPrimitives.WriteUInt16LittleEndian( + data[(offset + 2)..], + (ushort)Math.Round(Math.Clamp(point.Y, 0, 1) * 942)); + data[offset + 4] = point.Id; + } + } + private static uint ReadKeyboardButtons(IHostInput input) { uint buttons = 0; diff --git a/src/SharpEmu.Libs/Pad/PadState.cs b/src/SharpEmu.Libs/Pad/PadState.cs index c9342558..2707f1fc 100644 --- a/src/SharpEmu.Libs/Pad/PadState.cs +++ b/src/SharpEmu.Libs/Pad/PadState.cs @@ -1,6 +1,8 @@ // Copyright (C) 2026 SharpEmu Emulator Project // SPDX-License-Identifier: GPL-2.0-or-later +using SharpEmu.HLE.Host; + namespace SharpEmu.Libs.Pad; /// @@ -16,11 +18,16 @@ internal readonly record struct PadState( byte RightX, byte RightY, byte L2, - byte R2); + byte R2, + HostGamepadType Type = HostGamepadType.Generic, + HostGamepadConnection Connection = HostGamepadConnection.Unknown, + HostMotionState Motion = default, + HostTouchState Touch = default); /// SCE_PAD_BUTTON bit values. internal static class OrbisPadButton { + internal const uint Share = 0x0001; internal const uint L3 = 0x0002; internal const uint R3 = 0x0004; internal const uint Options = 0x0008; diff --git a/src/SharpEmu.Libs/Pad/SdlGamepadStateReader.cs b/src/SharpEmu.Libs/Pad/SdlGamepadStateReader.cs new file mode 100644 index 00000000..95e45132 --- /dev/null +++ b/src/SharpEmu.Libs/Pad/SdlGamepadStateReader.cs @@ -0,0 +1,121 @@ +// Copyright (C) 2026 SharpEmu Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +using SharpEmu.HLE.Host; +using SDL; +using static SDL.SDL3; + +namespace SharpEmu.Libs.Pad; + +internal static unsafe class SdlGamepadStateReader +{ + public static void EnableSonyHidApi() + { + fixed (byte* enabled = "1"u8) + fixed (byte* ps4 = SDL_HINT_JOYSTICK_HIDAPI_PS4) + fixed (byte* ps5 = SDL_HINT_JOYSTICK_HIDAPI_PS5) + { + SDL_SetHint(ps4, enabled); + SDL_SetHint(ps5, enabled); + } + } + + public static SDL_Gamepad* OpenPreferredGamepad() + { + using var gamepads = SDL_GetGamepads(); + if (gamepads is null || gamepads.Count == 0) + { + return null; + } + + var selected = gamepads[0]; + for (var index = 0; index < gamepads.Count; index++) + { + var type = SDL_GetRealGamepadTypeForID(gamepads[index]); + if (type is SDL_GamepadType.SDL_GAMEPAD_TYPE_PS5 or SDL_GamepadType.SDL_GAMEPAD_TYPE_PS4) + { + selected = gamepads[index]; + break; + } + } + + return SDL_OpenGamepad(selected); + } + + public static HostGamepadState Read(SDL_Gamepad* gamepad) + { + var buttons = HostGamepadButtons.None; + AddButton(gamepad, SDL_GamepadButton.SDL_GAMEPAD_BUTTON_SOUTH, HostGamepadButtons.Cross, ref buttons); + AddButton(gamepad, SDL_GamepadButton.SDL_GAMEPAD_BUTTON_EAST, HostGamepadButtons.Circle, ref buttons); + AddButton(gamepad, SDL_GamepadButton.SDL_GAMEPAD_BUTTON_WEST, HostGamepadButtons.Square, ref buttons); + AddButton(gamepad, SDL_GamepadButton.SDL_GAMEPAD_BUTTON_NORTH, HostGamepadButtons.Triangle, ref buttons); + AddButton(gamepad, SDL_GamepadButton.SDL_GAMEPAD_BUTTON_LEFT_SHOULDER, HostGamepadButtons.L1, ref buttons); + AddButton(gamepad, SDL_GamepadButton.SDL_GAMEPAD_BUTTON_RIGHT_SHOULDER, HostGamepadButtons.R1, ref buttons); + AddButton(gamepad, SDL_GamepadButton.SDL_GAMEPAD_BUTTON_LEFT_STICK, HostGamepadButtons.L3, ref buttons); + AddButton(gamepad, SDL_GamepadButton.SDL_GAMEPAD_BUTTON_RIGHT_STICK, HostGamepadButtons.R3, ref buttons); + AddButton(gamepad, SDL_GamepadButton.SDL_GAMEPAD_BUTTON_BACK, HostGamepadButtons.Create, ref buttons); + AddButton(gamepad, SDL_GamepadButton.SDL_GAMEPAD_BUTTON_GUIDE, HostGamepadButtons.Ps, ref buttons); + AddButton(gamepad, SDL_GamepadButton.SDL_GAMEPAD_BUTTON_START, HostGamepadButtons.Options, ref buttons); + AddButton(gamepad, SDL_GamepadButton.SDL_GAMEPAD_BUTTON_MISC1, HostGamepadButtons.Mic, ref buttons); + AddButton(gamepad, SDL_GamepadButton.SDL_GAMEPAD_BUTTON_TOUCHPAD, HostGamepadButtons.TouchPad, ref buttons); + AddButton(gamepad, SDL_GamepadButton.SDL_GAMEPAD_BUTTON_DPAD_UP, HostGamepadButtons.Up, ref buttons); + AddButton(gamepad, SDL_GamepadButton.SDL_GAMEPAD_BUTTON_DPAD_RIGHT, HostGamepadButtons.Right, ref buttons); + AddButton(gamepad, SDL_GamepadButton.SDL_GAMEPAD_BUTTON_DPAD_DOWN, HostGamepadButtons.Down, ref buttons); + AddButton(gamepad, SDL_GamepadButton.SDL_GAMEPAD_BUTTON_DPAD_LEFT, HostGamepadButtons.Left, ref buttons); + + var leftTrigger = HostWindowInput.ToTriggerByte( + SDL_GetGamepadAxis(gamepad, SDL_GamepadAxis.SDL_GAMEPAD_AXIS_LEFT_TRIGGER)); + var rightTrigger = HostWindowInput.ToTriggerByte( + SDL_GetGamepadAxis(gamepad, SDL_GamepadAxis.SDL_GAMEPAD_AXIS_RIGHT_TRIGGER)); + if (leftTrigger > 64) + { + buttons |= HostGamepadButtons.L2; + } + if (rightTrigger > 64) + { + buttons |= HostGamepadButtons.R2; + } + + var battery = 0; + SDL_GetGamepadPowerInfo(gamepad, &battery); + return new HostGamepadState( + Connected: true, + Buttons: buttons, + LeftX: HostWindowInput.ToStickByte(SDL_GetGamepadAxis(gamepad, SDL_GamepadAxis.SDL_GAMEPAD_AXIS_LEFTX)), + LeftY: HostWindowInput.ToStickByte(SDL_GetGamepadAxis(gamepad, SDL_GamepadAxis.SDL_GAMEPAD_AXIS_LEFTY)), + RightX: HostWindowInput.ToStickByte(SDL_GetGamepadAxis(gamepad, SDL_GamepadAxis.SDL_GAMEPAD_AXIS_RIGHTX)), + RightY: HostWindowInput.ToStickByte(SDL_GetGamepadAxis(gamepad, SDL_GamepadAxis.SDL_GAMEPAD_AXIS_RIGHTY)), + LeftTrigger: leftTrigger, + RightTrigger: rightTrigger, + Type: MapGamepadType(SDL_GetRealGamepadType(gamepad)), + Connection: GetConnection(gamepad), + BatteryPercent: (byte)Math.Clamp(battery, 0, 100)); + } + + public static HostGamepadType MapGamepadType(SDL_GamepadType type) => type switch + { + SDL_GamepadType.SDL_GAMEPAD_TYPE_PS4 => HostGamepadType.DualShock4, + SDL_GamepadType.SDL_GAMEPAD_TYPE_PS5 => HostGamepadType.DualSense, + _ => HostGamepadType.Generic, + }; + + public static HostGamepadConnection GetConnection(SDL_Gamepad* gamepad) => + SDL_GetGamepadConnectionState(gamepad) switch + { + SDL_JoystickConnectionState.SDL_JOYSTICK_CONNECTION_WIRED => HostGamepadConnection.Wired, + SDL_JoystickConnectionState.SDL_JOYSTICK_CONNECTION_WIRELESS => HostGamepadConnection.Wireless, + _ => HostGamepadConnection.Unknown, + }; + + private static void AddButton( + SDL_Gamepad* gamepad, + SDL_GamepadButton source, + HostGamepadButtons target, + ref HostGamepadButtons buttons) + { + if (SDL_GetGamepadButton(gamepad, source)) + { + buttons |= target; + } + } +} diff --git a/src/SharpEmu.Libs/Pad/SdlLauncherGamepad.cs b/src/SharpEmu.Libs/Pad/SdlLauncherGamepad.cs new file mode 100644 index 00000000..aa556931 --- /dev/null +++ b/src/SharpEmu.Libs/Pad/SdlLauncherGamepad.cs @@ -0,0 +1,85 @@ +// Copyright (C) 2026 SharpEmu Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +using SharpEmu.HLE.Host; +using SDL; +using static SDL.SDL3; + +namespace SharpEmu.Libs.Pad; + +/// Cross-platform SDL gamepad polling for launcher navigation. +public static unsafe class SdlLauncherGamepad +{ + private const SDL_InitFlags InitFlags = SDL_InitFlags.SDL_INIT_GAMEPAD; + private static SDL_Gamepad* _gamepad; + private static bool _initialized; + + public static void EnsureStarted() + { + if (_initialized) + { + return; + } + + SdlGamepadStateReader.EnableSonyHidApi(); + if (!SDL_InitSubSystem(InitFlags)) + { + Console.Error.WriteLine("[GUI][WARN] SDL gamepad initialization failed."); + return; + } + + _initialized = true; + OpenFirstGamepad(); + } + + public static bool TryGetState(out HostGamepadState state) + { + state = default; + if (!_initialized) + { + return false; + } + + SDL_UpdateGamepads(); + if (_gamepad is not null && !SDL_GamepadConnected(_gamepad)) + { + SDL_CloseGamepad(_gamepad); + _gamepad = null; + } + + if (_gamepad is null) + { + OpenFirstGamepad(); + } + + if (_gamepad is null) + { + return false; + } + + state = SdlGamepadStateReader.Read(_gamepad); + return true; + } + + public static void Shutdown() + { + if (!_initialized) + { + return; + } + + if (_gamepad is not null) + { + SDL_CloseGamepad(_gamepad); + _gamepad = null; + } + + SDL_QuitSubSystem(InitFlags); + _initialized = false; + } + + private static void OpenFirstGamepad() + { + _gamepad = SdlGamepadStateReader.OpenPreferredGamepad(); + } +} diff --git a/tests/SharpEmu.Libs.Tests/Pad/HostWindowInputTests.cs b/tests/SharpEmu.Libs.Tests/Pad/HostWindowInputTests.cs index 158c3071..d9e54f5b 100644 --- a/tests/SharpEmu.Libs.Tests/Pad/HostWindowInputTests.cs +++ b/tests/SharpEmu.Libs.Tests/Pad/HostWindowInputTests.cs @@ -2,6 +2,7 @@ // SPDX-License-Identifier: GPL-2.0-or-later using SharpEmu.Libs.Pad; +using SharpEmu.HLE.Host; using Xunit; namespace SharpEmu.Libs.Tests.Pad; @@ -9,11 +10,161 @@ namespace SharpEmu.Libs.Tests.Pad; public sealed class HostWindowInputTests { [Theory] - [InlineData(-1.0f, 0)] - [InlineData(0.0f, 128)] - [InlineData(1.0f, 255)] - public void ToStickByteMapsFullSilkRange(float value, int expected) + [InlineData(short.MinValue, 0)] + [InlineData(0, 128)] + [InlineData(short.MaxValue, 255)] + public void ToStickByteMapsFullSdlRange(short value, int expected) { Assert.Equal((byte)expected, HostWindowInput.ToStickByte(value)); } + + [Fact] + public void ConnectPublishesWindowKeyboardState() + { + HostWindowInput.Connect(); + try + { + HostWindowInput.SetKey(0x57, true); + + var source = Assert.IsAssignableFrom(HostWindowInputSource.Current); + Assert.True(source.HasKeyboardFocus); + Assert.True(source.IsKeyDown(0x57)); + } + finally + { + HostWindowInput.Disconnect(); + } + + Assert.Null(HostWindowInputSource.Current); + } + + [Fact] + public void ConnectedGamepadStateIsExposedByWindowSource() + { + var expected = new HostGamepadState( + true, + HostGamepadButtons.Cross | HostGamepadButtons.Options, + 64, + 128, + 192, + 128, + 0, + 255); + HostWindowInput.Connect(); + try + { + HostWindowInput.SetGamepad("SDL test pad", expected); + Span states = stackalloc HostGamepadState[1]; + + var source = Assert.IsAssignableFrom(HostWindowInputSource.Current); + Assert.Equal(1, source.GetGamepadStates(states)); + Assert.Equal(expected, states[0]); + Assert.Equal("SDL test pad", source.DescribeConnectedGamepad()); + } + finally + { + HostWindowInput.Disconnect(); + } + } + + [Fact] + public void ControllerOutputIsForwardedToSdlOwner() + { + var output = new RecordingGamepadOutput(); + var effect = new HostAdaptiveTriggerEffect( + 0x21, 0x04, 0, 0x07, 0, 0, 0, 0, 0, 0, 0, 255); + HostWindowInput.Connect(output); + try + { + var source = Assert.IsAssignableFrom(HostWindowInputSource.Current); + source.SetRumble(10, 20); + source.SetTriggerRumble(30, 40); + source.SetAdaptiveTriggerEffect(effect, null); + source.SetLightbar(50, 60, 70); + source.ResetLightbar(); + + Assert.Equal((10, 20), output.Rumble); + Assert.Equal(((byte?)30, (byte?)40), output.TriggerRumble); + Assert.Equal(effect, output.LeftTriggerEffect); + Assert.Null(output.RightTriggerEffect); + Assert.Equal((50, 60, 70), output.Lightbar); + Assert.True(output.LightbarReset); + } + finally + { + HostWindowInput.Disconnect(); + } + } + + [Fact] + public void CommonHostInputUsesPublishedWindowSource() + { + var output = new RecordingGamepadOutput(); + var expected = new HostGamepadState( + true, + HostGamepadButtons.Cross, + 32, + 64, + 96, + 128, + 160, + 192); + var input = new WindowHostInput(); + HostWindowInput.Connect(output); + try + { + HostWindowInput.SetKey(0x57, true); + HostWindowInput.SetGamepad("SDL test pad", expected); + Span states = stackalloc HostGamepadState[1]; + + Assert.Equal(1, input.GetGamepadStates(states)); + Assert.Equal(expected, states[0]); + Assert.Equal("SDL test pad", input.DescribeConnectedGamepad()); + Assert.True(input.IsHostWindowFocused()); + Assert.True(input.IsKeyDown(0x57)); + + input.SetRumble(10, 20); + input.SetLightbar(30, 40, 50); + Assert.Equal((10, 20), output.Rumble); + Assert.Equal((30, 40, 50), output.Lightbar); + } + finally + { + HostWindowInput.Disconnect(); + } + } + + private sealed class RecordingGamepadOutput : IHostGamepadOutput + { + public (byte Large, byte Small) Rumble { get; private set; } + + public (byte? Left, byte? Right) TriggerRumble { get; private set; } + + public HostAdaptiveTriggerEffect? LeftTriggerEffect { get; private set; } + + public HostAdaptiveTriggerEffect? RightTriggerEffect { get; private set; } + + public (byte Red, byte Green, byte Blue) Lightbar { get; private set; } + + public bool LightbarReset { get; private set; } + + public void SetRumble(byte largeMotor, byte smallMotor) => + Rumble = (largeMotor, smallMotor); + + public void SetTriggerRumble(byte? leftTrigger, byte? rightTrigger) => + TriggerRumble = (leftTrigger, rightTrigger); + + public void SetAdaptiveTriggerEffect( + HostAdaptiveTriggerEffect? leftTrigger, + HostAdaptiveTriggerEffect? rightTrigger) + { + LeftTriggerEffect = leftTrigger; + RightTriggerEffect = rightTrigger; + } + + public void SetLightbar(byte red, byte green, byte blue) => + Lightbar = (red, green, blue); + + public void ResetLightbar() => LightbarReset = true; + } }