mirror of
https://github.com/par274/sharpemu.git
synced 2026-07-31 15:09:42 +08:00
[input] replaced per-platform pad readers with sdl gamepad input
This commit is contained in:
@@ -28,8 +28,44 @@ public enum HostGamepadButtons : uint
|
|||||||
R3 = 1 << 13,
|
R3 = 1 << 13,
|
||||||
Options = 1 << 14,
|
Options = 1 << 14,
|
||||||
TouchPad = 1 << 15,
|
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);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Snapshot of one host gamepad: sticks are 0..255 with 128 centered and Y growing
|
/// 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
|
/// 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 RightX,
|
||||||
byte RightY,
|
byte RightY,
|
||||||
byte LeftTrigger,
|
byte LeftTrigger,
|
||||||
byte RightTrigger);
|
byte RightTrigger,
|
||||||
|
HostGamepadType Type = HostGamepadType.Generic,
|
||||||
|
HostGamepadConnection Connection = HostGamepadConnection.Unknown,
|
||||||
|
HostMotionState Motion = default,
|
||||||
|
HostTouchState Touch = default,
|
||||||
|
byte BatteryPercent = 0);
|
||||||
|
|
||||||
|
/// <summary>A complete 11-byte DualSense adaptive-trigger command.</summary>
|
||||||
|
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<byte> 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<byte> 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -32,6 +32,11 @@ public interface IHostInput
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
void SetTriggerRumble(byte? leftTrigger, byte? rightTrigger);
|
void SetTriggerRumble(byte? leftTrigger, byte? rightTrigger);
|
||||||
|
|
||||||
|
/// <summary>Applies native DualSense trigger effects when supported.</summary>
|
||||||
|
void SetAdaptiveTriggerEffect(
|
||||||
|
HostAdaptiveTriggerEffect? leftTrigger,
|
||||||
|
HostAdaptiveTriggerEffect? rightTrigger);
|
||||||
|
|
||||||
void SetLightbar(byte red, byte green, byte blue);
|
void SetLightbar(byte red, byte green, byte blue);
|
||||||
|
|
||||||
void ResetLightbar();
|
void ResetLightbar();
|
||||||
|
|||||||
@@ -0,0 +1,42 @@
|
|||||||
|
// Copyright (C) 2026 SharpEmu Emulator Project
|
||||||
|
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||||
|
|
||||||
|
namespace SharpEmu.HLE.Host;
|
||||||
|
|
||||||
|
/// <summary>Input snapshots produced by the active host window.</summary>
|
||||||
|
public interface IHostWindowInputSource
|
||||||
|
{
|
||||||
|
bool HasKeyboardFocus { get; }
|
||||||
|
|
||||||
|
bool IsKeyDown(int virtualKey);
|
||||||
|
|
||||||
|
int GetGamepadStates(Span<HostGamepadState> 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();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Process-wide bridge between the window layer and host input.</summary>
|
||||||
|
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);
|
||||||
|
}
|
||||||
@@ -1,186 +0,0 @@
|
|||||||
// Copyright (C) 2026 SharpEmu Emulator Project
|
|
||||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
|
||||||
|
|
||||||
namespace SharpEmu.HLE.Host.Posix;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 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
|
|
||||||
/// <see cref="SetSource"/> 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.
|
|
||||||
/// </summary>
|
|
||||||
public interface IPosixWindowInputSource
|
|
||||||
{
|
|
||||||
/// <summary>True while the window's keyboard is delivering events.</summary>
|
|
||||||
bool HasKeyboardFocus { get; }
|
|
||||||
|
|
||||||
/// <summary>Windows virtual-key semantics; the source translates.</summary>
|
|
||||||
bool IsKeyDown(int virtualKey);
|
|
||||||
|
|
||||||
/// <summary>Same contract as <see cref="IHostInput.GetGamepadStates"/>.</summary>
|
|
||||||
int GetGamepadStates(Span<HostGamepadState> 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;
|
|
||||||
|
|
||||||
/// <summary>Called by the presenter's window layer when input is ready.</summary>
|
|
||||||
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<HostGamepadState> 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);
|
|
||||||
}
|
|
||||||
@@ -1,6 +1,8 @@
|
|||||||
// Copyright (C) 2026 SharpEmu Emulator Project
|
// Copyright (C) 2026 SharpEmu Emulator Project
|
||||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||||
|
|
||||||
|
using SharpEmu.HLE.Host.Sdl;
|
||||||
|
|
||||||
namespace SharpEmu.HLE.Host.Posix;
|
namespace SharpEmu.HLE.Host.Posix;
|
||||||
|
|
||||||
internal sealed class PosixHostPlatform : IHostPlatform
|
internal sealed class PosixHostPlatform : IHostPlatform
|
||||||
@@ -11,7 +13,7 @@ internal sealed class PosixHostPlatform : IHostPlatform
|
|||||||
|
|
||||||
public IHostSymbolResolver Symbols { get; } = new PosixHostSymbolResolver();
|
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();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,43 @@
|
|||||||
|
// Copyright (C) 2026 SharpEmu Emulator Project
|
||||||
|
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||||
|
|
||||||
|
namespace SharpEmu.HLE.Host;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Routes emulated input through the active cross-platform host window.
|
||||||
|
/// </summary>
|
||||||
|
internal sealed class WindowHostInput : IHostInput
|
||||||
|
{
|
||||||
|
public void EnsureStarted()
|
||||||
|
{
|
||||||
|
// SDL owns device discovery and pumps it on the window thread.
|
||||||
|
}
|
||||||
|
|
||||||
|
public int GetGamepadStates(Span<HostGamepadState> 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;
|
||||||
|
}
|
||||||
@@ -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;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 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.
|
|
||||||
/// </summary>
|
|
||||||
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
|
|
||||||
|
|
||||||
/// <summary>Starts the background reader once; safe to call repeatedly.</summary>
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>Sets rumble; large = left/strong motor, small = right/weak.</summary>
|
|
||||||
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<byte> 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<byte> 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<byte> 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,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -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;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Minimal Win32 HID interop used to talk to a DualSense controller
|
|
||||||
/// directly, without any external input library.
|
|
||||||
/// </summary>
|
|
||||||
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);
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Enumerates the device paths of all present HID interfaces.
|
|
||||||
/// </summary>
|
|
||||||
internal static List<string> EnumerateHidDevicePaths()
|
|
||||||
{
|
|
||||||
var paths = new List<string>();
|
|
||||||
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<SpDeviceInterfaceData>(),
|
|
||||||
};
|
|
||||||
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 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.
|
|
||||||
/// </summary>
|
|
||||||
internal sealed partial class WindowsHostInput : IHostInput
|
|
||||||
{
|
|
||||||
public void EnsureStarted()
|
|
||||||
{
|
|
||||||
WindowsDualSenseReader.EnsureStarted();
|
|
||||||
WindowsXInputReader.EnsureStarted();
|
|
||||||
}
|
|
||||||
|
|
||||||
public int GetGamepadStates(Span<HostGamepadState> 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;
|
|
||||||
}
|
|
||||||
@@ -1,6 +1,8 @@
|
|||||||
// Copyright (C) 2026 SharpEmu Emulator Project
|
// Copyright (C) 2026 SharpEmu Emulator Project
|
||||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||||
|
|
||||||
|
using SharpEmu.HLE.Host.Sdl;
|
||||||
|
|
||||||
namespace SharpEmu.HLE.Host.Windows;
|
namespace SharpEmu.HLE.Host.Windows;
|
||||||
|
|
||||||
internal sealed class WindowsHostPlatform : IHostPlatform
|
internal sealed class WindowsHostPlatform : IHostPlatform
|
||||||
@@ -11,7 +13,7 @@ internal sealed class WindowsHostPlatform : IHostPlatform
|
|||||||
|
|
||||||
public IHostSymbolResolver Symbols { get; } = new WindowsHostSymbolResolver();
|
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();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Reads Xbox 360 / Xbox One (and other XInput-compatible) controllers via
|
|
||||||
/// the Windows XInput API on a background thread, translated to
|
|
||||||
/// <see cref="HostGamepadState"/> conventions. Supports rumble and hot-plug
|
|
||||||
/// retry; the first connected slot (of four) is used.
|
|
||||||
/// </summary>
|
|
||||||
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;
|
|
||||||
|
|
||||||
/// <summary>Starts the background reader once; safe to call repeatedly.</summary>
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>Sets rumble; large = left/strong motor, small = right/weak.</summary>
|
|
||||||
internal static void SetRumble(byte largeMotor, byte smallMotor)
|
|
||||||
{
|
|
||||||
lock (Gate)
|
|
||||||
{
|
|
||||||
if (_motorLeft == largeMotor && _motorRight == smallMotor)
|
|
||||||
{
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
_motorLeft = largeMotor;
|
|
||||||
_motorRight = smallMotor;
|
|
||||||
SendRumbleLocked();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>Approximates per-trigger vibration on the two XInput body motors.</summary>
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
@@ -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;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 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.
|
|
||||||
/// </summary>
|
|
||||||
internal static class MetalHostInput
|
|
||||||
{
|
|
||||||
private static readonly object Gate = new();
|
|
||||||
private static readonly HashSet<ushort> Pressed = new();
|
|
||||||
private static volatile bool _connected;
|
|
||||||
|
|
||||||
/// <summary>Registers this window's keyboard as the host input source.</summary>
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>Called once per render frame; fires and releases scripted keys.</summary>
|
|
||||||
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<HostGamepadState> destination) => 0;
|
|
||||||
|
|
||||||
public string? DescribeConnectedGamepad() => null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>Windows virtual-key semantics (the seam's contract) to macOS
|
|
||||||
/// kVK virtual key codes, covering the keys pad emulation polls.</summary>
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -2,138 +2,135 @@
|
|||||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||||
|
|
||||||
using SharpEmu.HLE.Host;
|
using SharpEmu.HLE.Host;
|
||||||
using SharpEmu.HLE.Host.Posix;
|
|
||||||
using Silk.NET.Input;
|
|
||||||
|
|
||||||
namespace SharpEmu.Libs.Pad;
|
namespace SharpEmu.Libs.Pad;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>Cross-platform input state supplied by the SDL game window.</summary>
|
||||||
/// 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.
|
|
||||||
/// </summary>
|
|
||||||
public static class HostWindowInput
|
public static class HostWindowInput
|
||||||
{
|
{
|
||||||
private static readonly object Gate = new();
|
private static readonly object Gate = new();
|
||||||
private static readonly HashSet<Key> Pressed = new();
|
private static readonly HashSet<int> PressedKeys = new();
|
||||||
private static volatile bool _connected;
|
private static bool _focused;
|
||||||
|
|
||||||
// Latest window-gamepad snapshot in the host seam's conventions.
|
|
||||||
private static bool _gamepadConnected;
|
private static bool _gamepadConnected;
|
||||||
private static string? _gamepadName;
|
private static string? _gamepadName;
|
||||||
private static HostGamepadButtons _gamepadButtons;
|
private static HostGamepadState _gamepadState;
|
||||||
private static byte _gamepadLeftX = 128;
|
private static IHostGamepadOutput? _gamepadOutput;
|
||||||
private static byte _gamepadLeftY = 128;
|
private static readonly WindowInputSource Source = new();
|
||||||
private static byte _gamepadRightX = 128;
|
|
||||||
private static byte _gamepadRightY = 128;
|
|
||||||
private static byte _gamepadL2;
|
|
||||||
private static byte _gamepadR2;
|
|
||||||
|
|
||||||
/// <summary>True once a window keyboard is delivering events.</summary>
|
public static void Connect(IHostGamepadOutput? gamepadOutput = null)
|
||||||
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)
|
|
||||||
{
|
{
|
||||||
lock (Gate)
|
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)
|
public bool IsKeyDown(int virtualKey)
|
||||||
{
|
{
|
||||||
return TryMapVirtualKey(virtualKey, out var key) && HostWindowInput.IsKeyDown(key);
|
lock (Gate)
|
||||||
|
{
|
||||||
|
return PressedKeys.Contains(virtualKey);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public int GetGamepadStates(Span<HostGamepadState> destination)
|
public int GetGamepadStates(Span<HostGamepadState> destination)
|
||||||
{
|
{
|
||||||
lock (Gate)
|
lock (Gate)
|
||||||
{
|
{
|
||||||
if (!_gamepadConnected || destination.Length == 0)
|
if (!_gamepadConnected || destination.IsEmpty)
|
||||||
{
|
{
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
destination[0] = new HostGamepadState(
|
destination[0] = _gamepadState;
|
||||||
Connected: true,
|
|
||||||
Buttons: _gamepadButtons,
|
|
||||||
LeftX: _gamepadLeftX,
|
|
||||||
LeftY: _gamepadLeftY,
|
|
||||||
RightX: _gamepadRightX,
|
|
||||||
RightY: _gamepadRightY,
|
|
||||||
LeftTrigger: _gamepadL2,
|
|
||||||
RightTrigger: _gamepadR2);
|
|
||||||
return 1;
|
return 1;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -142,139 +139,80 @@ public static class HostWindowInput
|
|||||||
{
|
{
|
||||||
lock (Gate)
|
lock (Gate)
|
||||||
{
|
{
|
||||||
return _gamepadConnected ? _gamepadName ?? "GLFW gamepad" : null;
|
return _gamepadConnected ? _gamepadName ?? "SDL gamepad" : null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
private static bool TryMapVirtualKey(int vk, out Key key)
|
public void SetRumble(byte largeMotor, byte smallMotor)
|
||||||
{
|
|
||||||
key = vk switch
|
|
||||||
{
|
{
|
||||||
0x08 => Key.Backspace,
|
IHostGamepadOutput? output;
|
||||||
0x09 => Key.Tab,
|
lock (Gate)
|
||||||
0x0D => Key.Enter,
|
{
|
||||||
0x1B => Key.Escape,
|
output = _gamepadOutput;
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static void AttachGamepad(IGamepad gamepad)
|
output?.SetRumble(largeMotor, smallMotor);
|
||||||
{
|
|
||||||
lock (Gate)
|
|
||||||
{
|
|
||||||
_gamepadConnected = true;
|
|
||||||
_gamepadName = gamepad.Name;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
gamepad.ButtonDown += (_, button) =>
|
public void SetTriggerRumble(byte? leftTrigger, byte? rightTrigger)
|
||||||
{
|
{
|
||||||
var bit = MapButton(button.Name);
|
IHostGamepadOutput? output;
|
||||||
if (bit == HostGamepadButtons.None)
|
lock (Gate)
|
||||||
{
|
{
|
||||||
return;
|
output = _gamepadOutput;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
output?.SetTriggerRumble(leftTrigger, rightTrigger);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void SetAdaptiveTriggerEffect(
|
||||||
|
HostAdaptiveTriggerEffect? leftTrigger,
|
||||||
|
HostAdaptiveTriggerEffect? rightTrigger)
|
||||||
|
{
|
||||||
|
IHostGamepadOutput? output;
|
||||||
lock (Gate)
|
lock (Gate)
|
||||||
{
|
{
|
||||||
_gamepadButtons |= bit;
|
output = _gamepadOutput;
|
||||||
}
|
|
||||||
};
|
|
||||||
gamepad.ButtonUp += (_, button) =>
|
|
||||||
{
|
|
||||||
var bit = MapButton(button.Name);
|
|
||||||
if (bit == HostGamepadButtons.None)
|
|
||||||
{
|
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
lock (Gate)
|
output?.SetAdaptiveTriggerEffect(leftTrigger, rightTrigger);
|
||||||
{
|
}
|
||||||
_gamepadButtons &= ~bit;
|
|
||||||
}
|
public void SetLightbar(byte red, byte green, byte blue)
|
||||||
};
|
|
||||||
gamepad.ThumbstickMoved += (_, thumbstick) =>
|
|
||||||
{
|
{
|
||||||
// Silk's GLFW backend reports sticks -1..1 with +Y pointing down,
|
IHostGamepadOutput? output;
|
||||||
// matching the seam's 0..255 down-growing convention after biasing.
|
|
||||||
var x = ToStickByte(thumbstick.X);
|
|
||||||
var y = ToStickByte(thumbstick.Y);
|
|
||||||
lock (Gate)
|
lock (Gate)
|
||||||
{
|
{
|
||||||
if (thumbstick.Index == 0)
|
output = _gamepadOutput;
|
||||||
{
|
|
||||||
_gamepadLeftX = x;
|
|
||||||
_gamepadLeftY = y;
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
_gamepadRightX = x;
|
|
||||||
_gamepadRightY = y;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
};
|
|
||||||
gamepad.TriggerMoved += (_, trigger) =>
|
output?.SetLightbar(red, green, blue);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void ResetLightbar()
|
||||||
{
|
{
|
||||||
// GLFW gamepad triggers rest at -1 and saturate at +1.
|
IHostGamepadOutput? output;
|
||||||
var value = (byte)Math.Clamp((int)((trigger.Position + 1.0f) * 0.5f * 255.0f), 0, 255);
|
|
||||||
lock (Gate)
|
lock (Gate)
|
||||||
{
|
{
|
||||||
if (trigger.Index == 0)
|
output = _gamepadOutput;
|
||||||
{
|
|
||||||
_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?.ResetLightbar();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
internal static byte ToStickByte(float value)
|
|
||||||
{
|
public interface IHostGamepadOutput
|
||||||
return (byte)Math.Clamp((int)MathF.Round((value + 1.0f) * 127.5f), 0, 255);
|
{
|
||||||
}
|
void SetRumble(byte largeMotor, byte smallMotor);
|
||||||
|
|
||||||
private static HostGamepadButtons MapButton(ButtonName name) => name switch
|
void SetTriggerRumble(byte? leftTrigger, byte? rightTrigger);
|
||||||
{
|
|
||||||
// GLFW reports the Xbox layout: A=Cross, B=Circle, X=Square, Y=Triangle.
|
void SetAdaptiveTriggerEffect(
|
||||||
ButtonName.A => HostGamepadButtons.Cross,
|
HostAdaptiveTriggerEffect? leftTrigger,
|
||||||
ButtonName.B => HostGamepadButtons.Circle,
|
HostAdaptiveTriggerEffect? rightTrigger);
|
||||||
ButtonName.X => HostGamepadButtons.Square,
|
|
||||||
ButtonName.Y => HostGamepadButtons.Triangle,
|
void SetLightbar(byte red, byte green, byte blue);
|
||||||
ButtonName.LeftBumper => HostGamepadButtons.L1,
|
|
||||||
ButtonName.RightBumper => HostGamepadButtons.R1,
|
void ResetLightbar();
|
||||||
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,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ public static class PadExports
|
|||||||
private static PadState _cachedInputState;
|
private static PadState _cachedInputState;
|
||||||
|
|
||||||
private static bool _initialized;
|
private static bool _initialized;
|
||||||
|
private static int _motionSensorEnabled;
|
||||||
private static int _controlsAnnouncementLogged;
|
private static int _controlsAnnouncementLogged;
|
||||||
|
|
||||||
[SysAbiExport(
|
[SysAbiExport(
|
||||||
@@ -151,9 +152,13 @@ public static class PadExports
|
|||||||
public static int PadSetMotionSensorState(CpuContext ctx)
|
public static int PadSetMotionSensorState(CpuContext ctx)
|
||||||
{
|
{
|
||||||
var handle = unchecked((int)ctx[CpuRegister.Rdi]);
|
var handle = unchecked((int)ctx[CpuRegister.Rdi]);
|
||||||
return IsPrimaryPadHandle(handle)
|
if (!IsPrimaryPadHandle(handle))
|
||||||
? ctx.SetReturn(0)
|
{
|
||||||
: ctx.SetReturn(OrbisPadErrorInvalidHandle);
|
return ctx.SetReturn(OrbisPadErrorInvalidHandle);
|
||||||
|
}
|
||||||
|
|
||||||
|
Volatile.Write(ref _motionSensorEnabled, ctx[CpuRegister.Rsi] != 0 ? 1 : 0);
|
||||||
|
return ctx.SetReturn(0);
|
||||||
}
|
}
|
||||||
|
|
||||||
[SysAbiExport(
|
[SysAbiExport(
|
||||||
@@ -362,24 +367,170 @@ public static class PadExports
|
|||||||
}
|
}
|
||||||
|
|
||||||
var triggerMask = parameter[0];
|
var triggerMask = parameter[0];
|
||||||
HostPlatform.Current.Input.SetTriggerRumble(
|
HostPlatform.Current.Input.SetAdaptiveTriggerEffect(
|
||||||
(triggerMask & 0x01) != 0 ? DecodeTriggerVibration(parameter[8..64]) : null,
|
(triggerMask & 0x01) != 0 ? DecodeTriggerEffect(parameter[8..64]) : null,
|
||||||
(triggerMask & 0x02) != 0 ? DecodeTriggerVibration(parameter[64..120]) : null);
|
(triggerMask & 0x02) != 0 ? DecodeTriggerEffect(parameter[64..120]) : null);
|
||||||
return ctx.SetReturn((int)OrbisGen2Result.ORBIS_GEN2_OK);
|
return ctx.SetReturn((int)OrbisGen2Result.ORBIS_GEN2_OK);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static byte DecodeTriggerVibration(ReadOnlySpan<byte> command)
|
private static HostAdaptiveTriggerEffect DecodeTriggerEffect(ReadOnlySpan<byte> command)
|
||||||
{
|
{
|
||||||
var mode = BinaryPrimitives.ReadUInt32LittleEndian(command);
|
var mode = BinaryPrimitives.ReadUInt32LittleEndian(command);
|
||||||
var amplitude = mode switch
|
var parameters = command[8..];
|
||||||
|
Span<byte> native = stackalloc byte[11];
|
||||||
|
native.Clear();
|
||||||
|
byte fallbackStrength = 0;
|
||||||
|
switch (mode)
|
||||||
{
|
{
|
||||||
3 when command[10] != 0 => command[9],
|
case 1:
|
||||||
6 when command[8] != 0 => command[9..19].ToArray().Max(),
|
EncodeFeedback(native, parameters[0], parameters[1]);
|
||||||
_ => (byte)0,
|
fallbackStrength = ScaleTriggerStrength(parameters[1]);
|
||||||
};
|
break;
|
||||||
return (byte)(Math.Min(amplitude, (byte)8) * 255 / 8);
|
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<byte> destination, byte position, byte strength)
|
||||||
|
{
|
||||||
|
if (position > 9 || strength is 0 or > 8)
|
||||||
|
{
|
||||||
|
destination[0] = 0x05;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
Span<byte> strengths = stackalloc byte[10];
|
||||||
|
strengths[position..].Fill(strength);
|
||||||
|
EncodeZonedStrengths(destination, 0x21, strengths, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void EncodeWeapon(Span<byte> 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<byte> destination,
|
||||||
|
byte nativeMode,
|
||||||
|
byte position,
|
||||||
|
byte strength,
|
||||||
|
byte frequency)
|
||||||
|
{
|
||||||
|
if (position > 9 || strength is 0 or > 8 || frequency == 0)
|
||||||
|
{
|
||||||
|
destination[0] = 0x05;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
Span<byte> strengths = stackalloc byte[10];
|
||||||
|
strengths[position..].Fill(strength);
|
||||||
|
EncodeZonedStrengths(destination, nativeMode, strengths, frequency);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void EncodeSlope(
|
||||||
|
Span<byte> 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<byte> 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<byte> destination,
|
||||||
|
byte nativeMode,
|
||||||
|
ReadOnlySpan<byte> 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<byte> 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(
|
[SysAbiExport(
|
||||||
Nid = "yFVnOdGxvZY",
|
Nid = "yFVnOdGxvZY",
|
||||||
ExportName = "scePadSetVibration",
|
ExportName = "scePadSetVibration",
|
||||||
@@ -478,6 +629,17 @@ public static class PadExports
|
|||||||
data[0x08] = l2;
|
data[0x08] = l2;
|
||||||
data[0x09] = r2;
|
data[0x09] = r2;
|
||||||
BinaryPrimitives.WriteSingleLittleEndian(data[0x18..], 1.0f);
|
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;
|
data[0x4C] = 1;
|
||||||
var timestampTicks = Stopwatch.GetTimestamp();
|
var timestampTicks = Stopwatch.GetTimestamp();
|
||||||
var timestampMicroseconds =
|
var timestampMicroseconds =
|
||||||
@@ -508,6 +670,10 @@ public static class PadExports
|
|||||||
var rightY = acceptsKeyboardInput ? ReadAnalogStick(input.IsKeyDown(0x49), input.IsKeyDown(0x4B)) : (byte)128;
|
var rightY = acceptsKeyboardInput ? ReadAnalogStick(input.IsKeyDown(0x49), input.IsKeyDown(0x4B)) : (byte)128;
|
||||||
var l2 = acceptsKeyboardInput && input.IsKeyDown(0x52) ? (byte)255 : (byte)0;
|
var l2 = acceptsKeyboardInput && input.IsKeyDown(0x52) ? (byte)255 : (byte)0;
|
||||||
var r2 = acceptsKeyboardInput && input.IsKeyDown(0x46) ? (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<HostGamepadState> gamepads = stackalloc HostGamepadState[2];
|
Span<HostGamepadState> gamepads = stackalloc HostGamepadState[2];
|
||||||
var gamepadCount = input.GetGamepadStates(gamepads);
|
var gamepadCount = input.GetGamepadStates(gamepads);
|
||||||
@@ -523,6 +689,13 @@ public static class PadExports
|
|||||||
rightY = MergeAxis(pad.RightY, rightY);
|
rightY = MergeAxis(pad.RightY, rightY);
|
||||||
l2 = Math.Max(l2, pad.LeftTrigger);
|
l2 = Math.Max(l2, pad.LeftTrigger);
|
||||||
r2 = Math.Max(r2, pad.RightTrigger);
|
r2 = Math.Max(r2, pad.RightTrigger);
|
||||||
|
if (index == 0)
|
||||||
|
{
|
||||||
|
gamepadType = pad.Type;
|
||||||
|
connection = pad.Connection;
|
||||||
|
motion = pad.Motion;
|
||||||
|
touch = pad.Touch;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (IsAutoCrossActive())
|
if (IsAutoCrossActive())
|
||||||
@@ -538,7 +711,11 @@ public static class PadExports
|
|||||||
RightX: rightX,
|
RightX: rightX,
|
||||||
RightY: rightY,
|
RightY: rightY,
|
||||||
L2: l2,
|
L2: l2,
|
||||||
R2: r2);
|
R2: r2,
|
||||||
|
Type: gamepadType,
|
||||||
|
Connection: connection,
|
||||||
|
Motion: motion,
|
||||||
|
Touch: touch);
|
||||||
_lastInputSampleTicks = now;
|
_lastInputSampleTicks = now;
|
||||||
return _cachedInputState;
|
return _cachedInputState;
|
||||||
}
|
}
|
||||||
@@ -592,6 +769,7 @@ public static class PadExports
|
|||||||
private static uint ToOrbisButtons(HostGamepadButtons buttons)
|
private static uint ToOrbisButtons(HostGamepadButtons buttons)
|
||||||
{
|
{
|
||||||
uint result = 0;
|
uint result = 0;
|
||||||
|
if ((buttons & HostGamepadButtons.Create) != 0) result |= OrbisPadButton.Share;
|
||||||
if ((buttons & HostGamepadButtons.Up) != 0) result |= OrbisPadButton.Up;
|
if ((buttons & HostGamepadButtons.Up) != 0) result |= OrbisPadButton.Up;
|
||||||
if ((buttons & HostGamepadButtons.Down) != 0) result |= OrbisPadButton.Down;
|
if ((buttons & HostGamepadButtons.Down) != 0) result |= OrbisPadButton.Down;
|
||||||
if ((buttons & HostGamepadButtons.Left) != 0) result |= OrbisPadButton.Left;
|
if ((buttons & HostGamepadButtons.Left) != 0) result |= OrbisPadButton.Left;
|
||||||
@@ -611,6 +789,34 @@ public static class PadExports
|
|||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static void WriteTouchData(Span<byte> data, HostTouchState touch)
|
||||||
|
{
|
||||||
|
Span<HostTouchPoint> 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)
|
private static uint ReadKeyboardButtons(IHostInput input)
|
||||||
{
|
{
|
||||||
uint buttons = 0;
|
uint buttons = 0;
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
// Copyright (C) 2026 SharpEmu Emulator Project
|
// Copyright (C) 2026 SharpEmu Emulator Project
|
||||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||||
|
|
||||||
|
using SharpEmu.HLE.Host;
|
||||||
|
|
||||||
namespace SharpEmu.Libs.Pad;
|
namespace SharpEmu.Libs.Pad;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -16,11 +18,16 @@ internal readonly record struct PadState(
|
|||||||
byte RightX,
|
byte RightX,
|
||||||
byte RightY,
|
byte RightY,
|
||||||
byte L2,
|
byte L2,
|
||||||
byte R2);
|
byte R2,
|
||||||
|
HostGamepadType Type = HostGamepadType.Generic,
|
||||||
|
HostGamepadConnection Connection = HostGamepadConnection.Unknown,
|
||||||
|
HostMotionState Motion = default,
|
||||||
|
HostTouchState Touch = default);
|
||||||
|
|
||||||
/// <summary>SCE_PAD_BUTTON bit values.</summary>
|
/// <summary>SCE_PAD_BUTTON bit values.</summary>
|
||||||
internal static class OrbisPadButton
|
internal static class OrbisPadButton
|
||||||
{
|
{
|
||||||
|
internal const uint Share = 0x0001;
|
||||||
internal const uint L3 = 0x0002;
|
internal const uint L3 = 0x0002;
|
||||||
internal const uint R3 = 0x0004;
|
internal const uint R3 = 0x0004;
|
||||||
internal const uint Options = 0x0008;
|
internal const uint Options = 0x0008;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
|
||||||
|
/// <summary>Cross-platform SDL gamepad polling for launcher navigation.</summary>
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@
|
|||||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||||
|
|
||||||
using SharpEmu.Libs.Pad;
|
using SharpEmu.Libs.Pad;
|
||||||
|
using SharpEmu.HLE.Host;
|
||||||
using Xunit;
|
using Xunit;
|
||||||
|
|
||||||
namespace SharpEmu.Libs.Tests.Pad;
|
namespace SharpEmu.Libs.Tests.Pad;
|
||||||
@@ -9,11 +10,161 @@ namespace SharpEmu.Libs.Tests.Pad;
|
|||||||
public sealed class HostWindowInputTests
|
public sealed class HostWindowInputTests
|
||||||
{
|
{
|
||||||
[Theory]
|
[Theory]
|
||||||
[InlineData(-1.0f, 0)]
|
[InlineData(short.MinValue, 0)]
|
||||||
[InlineData(0.0f, 128)]
|
[InlineData(0, 128)]
|
||||||
[InlineData(1.0f, 255)]
|
[InlineData(short.MaxValue, 255)]
|
||||||
public void ToStickByteMapsFullSilkRange(float value, int expected)
|
public void ToStickByteMapsFullSdlRange(short value, int expected)
|
||||||
{
|
{
|
||||||
Assert.Equal((byte)expected, HostWindowInput.ToStickByte(value));
|
Assert.Equal((byte)expected, HostWindowInput.ToStickByte(value));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ConnectPublishesWindowKeyboardState()
|
||||||
|
{
|
||||||
|
HostWindowInput.Connect();
|
||||||
|
try
|
||||||
|
{
|
||||||
|
HostWindowInput.SetKey(0x57, true);
|
||||||
|
|
||||||
|
var source = Assert.IsAssignableFrom<IHostWindowInputSource>(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<HostGamepadState> states = stackalloc HostGamepadState[1];
|
||||||
|
|
||||||
|
var source = Assert.IsAssignableFrom<IHostWindowInputSource>(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<IHostWindowInputSource>(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<HostGamepadState> 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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user