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;
+ }
}