From 165927882b52480e36679fb3bf755c56a66ecdab Mon Sep 17 00:00:00 2001 From: Brando Date: Sat, 11 Jul 2026 04:54:38 -0400 Subject: [PATCH] Pad: native DualSense support via raw HID (#52) * Pad: native DualSense support via raw HID Read a real DualSense (or DualSense Edge) controller directly over Win32 HID and feed its state into scePadRead/scePadReadState, replacing the keyboard-only input path. No new dependencies. - Device discovery by Sony VID/PID through setupapi/hid.dll, with hot-plug: disconnects fall back to keyboard and reconnect automatically - USB input report 0x01 and Bluetooth extended report 0x31 (activated via the feature report 0x05 handshake) are both parsed - Full mapping to SCE_PAD_BUTTON conventions: face buttons, d-pad hat, L1/R1/L2/R2 digital bits, analog triggers, L3/R3, Options, touchpad click, both sticks - Controller and keyboard input merge: buttons OR together, controller sticks win past a small deadzone, triggers take the max * Pad: rumble and lightbar output for DualSense Wire scePadSetVibration, scePadSetLightBar and scePadResetLightBar to real DualSense output reports. The output payload follows the same layout as the Linux hid-playstation driver: both rumble motors, lightbar RGB and the player LED indicator. - USB uses output report 0x02; Bluetooth uses the 0x31 wrapper with a sequence tag and CRC32 (0xA2-seeded) trailer, transport detected from the first input report - Output goes through a dedicated device handle so writes never contend with the blocking input read loop - On connect the controller gets a default state (blue lightbar, player 1 LED); rumble state resets on disconnect Verified on hardware over USB: lightbar color cycling and both motors. Bluetooth output is implemented per spec but not yet hardware-tested. --- src/SharpEmu.Libs/Pad/DualSenseReader.cs | 470 +++++++++++++++++++++++ src/SharpEmu.Libs/Pad/HidNative.cs | 136 +++++++ src/SharpEmu.Libs/Pad/PadExports.cs | 111 +++++- 3 files changed, 713 insertions(+), 4 deletions(-) create mode 100644 src/SharpEmu.Libs/Pad/DualSenseReader.cs create mode 100644 src/SharpEmu.Libs/Pad/HidNative.cs diff --git a/src/SharpEmu.Libs/Pad/DualSenseReader.cs b/src/SharpEmu.Libs/Pad/DualSenseReader.cs new file mode 100644 index 0000000..92ad3ee --- /dev/null +++ b/src/SharpEmu.Libs/Pad/DualSenseReader.cs @@ -0,0 +1,470 @@ +// Copyright (C) 2026 SharpEmu Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +using Microsoft.Win32.SafeHandles; + +namespace SharpEmu.Libs.Pad; + +/// +/// Snapshot of the DualSense state, already translated to ORBIS pad +/// conventions (SCE_PAD_BUTTON bits; sticks 0..255 with 128 centered; +/// triggers 0..255). +/// +internal readonly record struct DualSenseState( + bool Connected, + uint Buttons, + byte LeftX, + byte LeftY, + byte RightX, + byte RightY, + byte L2, + byte R2); + +/// +/// 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. +/// +internal static class DualSenseReader +{ + private const ushort SonyVendorId = 0x054C; + private const ushort DualSenseProductId = 0x0CE6; + private const ushort DualSenseEdgeProductId = 0x0DF2; + + // SCE_PAD_BUTTON bit values. + private const uint ButtonL3 = 0x0002; + private const uint ButtonR3 = 0x0004; + private const uint ButtonOptions = 0x0008; + private const uint ButtonUp = 0x0010; + private const uint ButtonRight = 0x0020; + private const uint ButtonDown = 0x0040; + private const uint ButtonLeft = 0x0080; + private const uint ButtonL2 = 0x0100; + private const uint ButtonR2 = 0x0200; + private const uint ButtonL1 = 0x0400; + private const uint ButtonR1 = 0x0800; + private const uint ButtonTriangle = 0x1000; + private const uint ButtonCircle = 0x2000; + private const uint ButtonCross = 0x4000; + private const uint ButtonSquare = 0x8000; + private const uint ButtonTouchPad = 0x100000; + + private static readonly object Gate = new(); + private static DualSenseState _state; + private static bool _started; + + // Output (rumble/lightbar) state, all guarded by Gate. + private static string? _devicePath; + private static bool _bluetooth; + private static bool _outputReady; + private static bool _lightbarSetupPending; + private static byte _outputSequence; + private static FileStream? _outputStream; + private static byte _motorLeft; + private static byte _motorRight; + private static byte _lightbarRed; + private static byte _lightbarGreen; + private static byte _lightbarBlue = 64; // PS-style blue default + private static byte _playerLeds = 0x04; // center LED = player 1 + + /// Starts the background reader once; safe to call repeatedly. + internal static void EnsureStarted() + { + if (!OperatingSystem.IsWindows()) + { + return; + } + + lock (Gate) + { + if (_started) + { + return; + } + + _started = true; + var thread = new Thread(ReadLoop) + { + IsBackground = true, + Name = "DualSenseReader", + }; + thread.Start(); + } + } + + internal static bool TryGetState(out DualSenseState state) + { + lock (Gate) + { + state = _state; + } + + return state.Connected; + } + + private static void SetState(in DualSenseState state) + { + lock (Gate) + { + _state = state; + } + } + + /// Sets rumble; large = left/strong motor, small = right/weak. + internal static void SetRumble(byte largeMotor, byte smallMotor) + { + lock (Gate) + { + if (_motorLeft == largeMotor && _motorRight == smallMotor) + { + return; + } + + _motorLeft = largeMotor; + _motorRight = smallMotor; + SendOutputLocked(); + } + } + + internal static void SetLightbar(byte red, byte green, byte blue) + { + lock (Gate) + { + if (_lightbarRed == red && _lightbarGreen == green && _lightbarBlue == blue) + { + return; + } + + _lightbarRed = red; + _lightbarGreen = green; + _lightbarBlue = blue; + SendOutputLocked(); + } + } + + internal static void ResetLightbar() => SetLightbar(0, 0, 64); + + private static void OnDeviceIdentified(string path, bool bluetooth) + { + lock (Gate) + { + _devicePath = path; + _bluetooth = bluetooth; + _outputReady = true; + _lightbarSetupPending = true; + // Announce ourselves on the hardware: default lightbar + player 1 LED. + SendOutputLocked(); + } + } + + private static void OnDeviceLost() + { + lock (Gate) + { + _devicePath = null; + _outputReady = false; + _motorLeft = 0; + _motorRight = 0; + _outputStream?.Dispose(); + _outputStream = null; + } + } + + private static void SendOutputLocked() + { + if (!_outputReady || _devicePath is null) + { + return; // flushed by OnDeviceIdentified once connected + } + + try + { + if (_outputStream is null) + { + var handle = HidNative.CreateFile( + _devicePath, + HidNative.GenericRead | HidNative.GenericWrite, + HidNative.FileShareRead | HidNative.FileShareWrite, + 0, HidNative.OpenExisting, 0, 0); + if (handle.IsInvalid) + { + handle.Dispose(); + return; // read-only device access: outputs unavailable + } + + _outputStream = new FileStream(handle, FileAccess.Write, bufferSize: 1); + } + + var report = BuildOutputReportLocked(); + _outputStream.Write(report, 0, report.Length); + _outputStream.Flush(); + } + catch (Exception) + { + _outputStream?.Dispose(); + _outputStream = null; + } + } + + private static byte[] BuildOutputReportLocked() + { + // Common 47-byte output payload (offsets per the DualSense output + // report layout, same as Linux hid-playstation). + Span common = stackalloc byte[47]; + common[0] = 0x03; // valid_flag0: compatible vibration + haptics select + common[1] = 0x04 | 0x10; // valid_flag1: lightbar + player indicator + common[2] = _motorRight; // right (weak) motor + common[3] = _motorLeft; // left (strong) motor + if (_lightbarSetupPending) + { + common[38] |= 0x02; // valid_flag2: lightbar setup control enable + common[41] = 0x01; // lightbar_setup: light on + _lightbarSetupPending = false; + } + + common[43] = _playerLeds; + common[44] = _lightbarRed; + common[45] = _lightbarGreen; + common[46] = _lightbarBlue; + + if (!_bluetooth) + { + var usbReport = new byte[48]; + usbReport[0] = 0x02; + common.CopyTo(usbReport.AsSpan(1)); + return usbReport; + } + + // Bluetooth: 0x31 wrapper with sequence tag and CRC32 over a 0xA2 + // seed byte plus the first 74 report bytes. + var btReport = new byte[78]; + btReport[0] = 0x31; + btReport[1] = (byte)((_outputSequence & 0x0F) << 4); + _outputSequence = (byte)((_outputSequence + 1) & 0x0F); + btReport[2] = 0x10; + common.CopyTo(btReport.AsSpan(3)); + var crc = Crc32(0xA2, btReport.AsSpan(0, 74)); + btReport[74] = (byte)crc; + btReport[75] = (byte)(crc >> 8); + btReport[76] = (byte)(crc >> 16); + btReport[77] = (byte)(crc >> 24); + return btReport; + } + + private static uint Crc32(byte seed, ReadOnlySpan data) + { + var crc = Crc32Update(0xFFFFFFFFu, seed); + foreach (var value in data) + { + crc = Crc32Update(crc, value); + } + + return ~crc; + } + + private static uint Crc32Update(uint crc, byte value) + { + crc ^= value; + for (var bit = 0; bit < 8; bit++) + { + crc = (crc >> 1) ^ (0xEDB88320u & (uint)-(int)(crc & 1)); + } + + return crc; + } + + private static void ReadLoop() + { + var announcedConnect = false; + while (true) + { + SafeFileHandle? handle = null; + try + { + handle = OpenDualSense(out var devicePath); + if (handle is null || devicePath is null) + { + SetState(default); + announcedConnect = false; + Thread.Sleep(1000); + continue; + } + + // Bluetooth quirk: the DualSense sends a simplified report + // until feature report 0x05 is requested, which switches it + // to the full 0x31 input report. Harmless over USB. + var feature = new byte[41]; + feature[0] = 0x05; + _ = HidNative.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 HidNative.EnumerateHidDevicePaths()) + { + // Open without access rights just to query VID/PID. + using var probe = HidNative.CreateFile( + path, 0, HidNative.FileShareRead | HidNative.FileShareWrite, 0, HidNative.OpenExisting, 0, 0); + if (probe.IsInvalid) + { + continue; + } + + var attributes = new HidNative.HiddAttributes { Size = 12 }; + if (!HidNative.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 = HidNative.CreateFile( + path, + HidNative.GenericRead | HidNative.GenericWrite, + HidNative.FileShareRead | HidNative.FileShareWrite, + 0, HidNative.OpenExisting, 0, 0); + if (handle.IsInvalid) + { + handle.Dispose(); + handle = HidNative.CreateFile( + path, + HidNative.GenericRead, + HidNative.FileShareRead | HidNative.FileShareWrite, + 0, HidNative.OpenExisting, 0, 0); + } + + if (!handle.IsInvalid) + { + devicePath = path; + return handle; + } + + handle.Dispose(); + } + + return null; + } + + private static bool TryParseReport(ReadOnlySpan report, out DualSenseState 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]; + + uint buttons = 0; + buttons |= (buttons0 & 0x10) != 0 ? ButtonSquare : 0; + buttons |= (buttons0 & 0x20) != 0 ? ButtonCross : 0; + buttons |= (buttons0 & 0x40) != 0 ? ButtonCircle : 0; + buttons |= (buttons0 & 0x80) != 0 ? ButtonTriangle : 0; + buttons |= HatToButtons(buttons0 & 0x0F); + buttons |= (buttons1 & 0x01) != 0 ? ButtonL1 : 0; + buttons |= (buttons1 & 0x02) != 0 ? ButtonR1 : 0; + buttons |= (buttons1 & 0x04) != 0 ? ButtonL2 : 0; + buttons |= (buttons1 & 0x08) != 0 ? ButtonR2 : 0; + buttons |= (buttons1 & 0x20) != 0 ? ButtonOptions : 0; + buttons |= (buttons1 & 0x40) != 0 ? ButtonL3 : 0; + buttons |= (buttons1 & 0x80) != 0 ? ButtonR3 : 0; + buttons |= (buttons2 & 0x02) != 0 ? ButtonTouchPad : 0; + + state = new DualSenseState( + Connected: true, + Buttons: buttons, + LeftX: leftX, + LeftY: leftY, + RightX: rightX, + RightY: rightY, + L2: l2, + R2: r2); + return true; + } + + private static uint HatToButtons(int hat) => hat switch + { + 0 => ButtonUp, + 1 => ButtonUp | ButtonRight, + 2 => ButtonRight, + 3 => ButtonRight | ButtonDown, + 4 => ButtonDown, + 5 => ButtonDown | ButtonLeft, + 6 => ButtonLeft, + 7 => ButtonLeft | ButtonUp, + _ => 0, + }; +} diff --git a/src/SharpEmu.Libs/Pad/HidNative.cs b/src/SharpEmu.Libs/Pad/HidNative.cs new file mode 100644 index 0000000..b52a589 --- /dev/null +++ b/src/SharpEmu.Libs/Pad/HidNative.cs @@ -0,0 +1,136 @@ +// Copyright (C) 2026 SharpEmu Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +using System.Runtime.InteropServices; +using Microsoft.Win32.SafeHandles; + +namespace SharpEmu.Libs.Pad; + +/// +/// Minimal Win32 HID interop used to talk to a DualSense controller +/// directly, without any external input library. +/// +internal static partial class HidNative +{ + 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; + } + + [DllImport("hid.dll")] + internal static extern void HidD_GetHidGuid(out Guid hidGuid); + + [DllImport("hid.dll")] + internal static extern bool HidD_GetAttributes(SafeFileHandle hidDeviceObject, ref HiddAttributes attributes); + + [DllImport("hid.dll")] + internal static extern bool HidD_GetFeature(SafeFileHandle hidDeviceObject, byte[] reportBuffer, int reportBufferLength); + + [DllImport("setupapi.dll", CharSet = CharSet.Unicode)] + internal static extern nint SetupDiGetClassDevs(ref Guid classGuid, nint enumerator, nint hwndParent, int flags); + + [DllImport("setupapi.dll")] + internal static extern bool SetupDiEnumDeviceInterfaces( + nint deviceInfoSet, + nint deviceInfoData, + ref Guid interfaceClassGuid, + int memberIndex, + ref SpDeviceInterfaceData deviceInterfaceData); + + [DllImport("setupapi.dll", CharSet = CharSet.Unicode)] + internal static extern bool SetupDiGetDeviceInterfaceDetail( + nint deviceInfoSet, + ref SpDeviceInterfaceData deviceInterfaceData, + nint deviceInterfaceDetailData, + int deviceInterfaceDetailDataSize, + out int requiredSize, + nint deviceInfoData); + + [DllImport("setupapi.dll")] + internal static extern bool SetupDiDestroyDeviceInfoList(nint deviceInfoSet); + + [DllImport("kernel32.dll", CharSet = CharSet.Unicode, SetLastError = true)] + internal static extern SafeFileHandle CreateFile( + string fileName, + uint desiredAccess, + uint shareMode, + nint securityAttributes, + uint creationDisposition, + uint flagsAndAttributes, + nint templateFile); + + /// + /// Enumerates the device paths of all present HID interfaces. + /// + internal static List EnumerateHidDevicePaths() + { + var paths = new List(); + HidD_GetHidGuid(out var hidGuid); + var deviceInfoSet = SetupDiGetClassDevs(ref hidGuid, 0, 0, DigcfPresent | DigcfDeviceInterface); + if (deviceInfoSet == -1 || deviceInfoSet == 0) + { + return paths; + } + + try + { + var interfaceData = new SpDeviceInterfaceData + { + CbSize = Marshal.SizeOf(), + }; + + for (var index = 0; SetupDiEnumDeviceInterfaces(deviceInfoSet, 0, ref hidGuid, index, ref interfaceData); index++) + { + SetupDiGetDeviceInterfaceDetail(deviceInfoSet, ref interfaceData, 0, 0, out var requiredSize, 0); + if (requiredSize <= 0) + { + continue; + } + + var detailBuffer = Marshal.AllocHGlobal(requiredSize); + try + { + // SP_DEVICE_INTERFACE_DETAIL_DATA_W.cbSize is 8 on x64 + // (DWORD + aligned WCHAR[1]); the path string follows it. + Marshal.WriteInt32(detailBuffer, 8); + if (SetupDiGetDeviceInterfaceDetail(deviceInfoSet, ref interfaceData, detailBuffer, requiredSize, out _, 0) && + Marshal.PtrToStringUni(detailBuffer + 4) is { Length: > 0 } path) + { + paths.Add(path); + } + } + finally + { + Marshal.FreeHGlobal(detailBuffer); + } + } + } + finally + { + SetupDiDestroyDeviceInfoList(deviceInfoSet); + } + + return paths; + } +} diff --git a/src/SharpEmu.Libs/Pad/PadExports.cs b/src/SharpEmu.Libs/Pad/PadExports.cs index 1a10c67..dc9f3f8 100644 --- a/src/SharpEmu.Libs/Pad/PadExports.cs +++ b/src/SharpEmu.Libs/Pad/PadExports.cs @@ -30,6 +30,7 @@ public static class PadExports public static int PadInit(CpuContext ctx) { _initialized = true; + DualSenseReader.EnsureStarted(); return ctx.SetReturn(0); } @@ -59,7 +60,10 @@ public static class PadExports return ctx.SetReturn(OrbisPadErrorDeviceNotConnected); } - Console.Error.WriteLine("[LOADER][INFO] Keyboard controls: Arrow keys = D-pad, WASD = left stick, IJKL = right stick, Z/Enter = Cross, X/Esc = Circle, C = Square, V = Triangle, Q = L1, E = R1, R = L2, F = R2, Tab/Backspace = Options"); + DualSenseReader.EnsureStarted(); + Console.Error.WriteLine(DualSenseReader.TryGetState(out _) + ? "[LOADER][INFO] Controls: DualSense connected (keyboard fallback also active)." + : "[LOADER][INFO] Keyboard controls: Arrow keys = D-pad, WASD = left stick, IJKL = right stick, Z/Enter = Cross, X/Esc = Circle, C = Square, V = Triangle, Q = L1, E = R1, R = L2, F = R2, Tab/Backspace = Options. A DualSense will be used automatically when plugged in."); return ctx.SetReturn(PrimaryPadHandle); } @@ -170,23 +174,116 @@ public static class PadExports return ctx.SetReturn((int)OrbisGen2Result.ORBIS_GEN2_OK); } + [SysAbiExport( + Nid = "yFVnOdGxvZY", + ExportName = "scePadSetVibration", + Target = Generation.Gen4 | Generation.Gen5, + LibraryName = "libScePad")] + public static int PadSetVibration(CpuContext ctx) + { + var handle = unchecked((int)ctx[CpuRegister.Rdi]); + var parameterAddress = ctx[CpuRegister.Rsi]; + if (handle != PrimaryPadHandle) + { + return ctx.SetReturn(OrbisPadErrorInvalidHandle); + } + + if (parameterAddress == 0) + { + return ctx.SetReturn((int)OrbisGen2Result.ORBIS_GEN2_ERROR_INVALID_ARGUMENT); + } + + // ScePadVibrationParam: { uint8_t largeMotor; uint8_t smallMotor; } + Span parameter = stackalloc byte[2]; + if (!ctx.Memory.TryRead(parameterAddress, parameter)) + { + return ctx.SetReturn((int)OrbisGen2Result.ORBIS_GEN2_ERROR_MEMORY_FAULT); + } + + DualSenseReader.SetRumble(parameter[0], parameter[1]); + return ctx.SetReturn(0); + } + + [SysAbiExport( + Nid = "RR4novUEENY", + ExportName = "scePadSetLightBar", + Target = Generation.Gen4 | Generation.Gen5, + LibraryName = "libScePad")] + public static int PadSetLightBar(CpuContext ctx) + { + var handle = unchecked((int)ctx[CpuRegister.Rdi]); + var parameterAddress = ctx[CpuRegister.Rsi]; + if (handle != PrimaryPadHandle) + { + return ctx.SetReturn(OrbisPadErrorInvalidHandle); + } + + if (parameterAddress == 0) + { + return ctx.SetReturn((int)OrbisGen2Result.ORBIS_GEN2_ERROR_INVALID_ARGUMENT); + } + + // ScePadColor: { uint8_t r; uint8_t g; uint8_t b; uint8_t reserved; } + Span color = stackalloc byte[4]; + if (!ctx.Memory.TryRead(parameterAddress, color)) + { + return ctx.SetReturn((int)OrbisGen2Result.ORBIS_GEN2_ERROR_MEMORY_FAULT); + } + + DualSenseReader.SetLightbar(color[0], color[1], color[2]); + return ctx.SetReturn(0); + } + + [SysAbiExport( + Nid = "DscD1i9HX1w", + ExportName = "scePadResetLightBar", + Target = Generation.Gen4 | Generation.Gen5, + LibraryName = "libScePad")] + public static int PadResetLightBar(CpuContext ctx) + { + var handle = unchecked((int)ctx[CpuRegister.Rdi]); + if (handle != PrimaryPadHandle) + { + return ctx.SetReturn(OrbisPadErrorInvalidHandle); + } + + DualSenseReader.ResetLightbar(); + return ctx.SetReturn(0); + } + private static bool WriteNeutralPadData(CpuContext ctx, ulong dataAddress) { Span data = stackalloc byte[PadDataSize]; data.Clear(); var acceptsKeyboardInput = IsEmulatorWindowFocused(); var buttons = acceptsKeyboardInput ? ReadKeyboardButtons() : 0; - BinaryPrimitives.WriteUInt32LittleEndian(data[0x00..], buttons); var leftX = acceptsKeyboardInput ? ReadAnalogStick(IsKeyDown(0x41), IsKeyDown(0x44)) : (byte)128; var leftY = acceptsKeyboardInput ? ReadAnalogStick(IsKeyDown(0x57), IsKeyDown(0x53)) : (byte)128; var rightX = acceptsKeyboardInput ? ReadAnalogStick(IsKeyDown(0x4A), IsKeyDown(0x4C)) : (byte)128; var rightY = acceptsKeyboardInput ? ReadAnalogStick(IsKeyDown(0x49), IsKeyDown(0x4B)) : (byte)128; + var l2 = acceptsKeyboardInput && IsKeyDown(0x52) ? (byte)255 : (byte)0; + var r2 = acceptsKeyboardInput && IsKeyDown(0x46) ? (byte)255 : (byte)0; + + if (DualSenseReader.TryGetState(out var pad)) + { + buttons |= pad.Buttons; + // The controller stick wins whenever it is deflected past a + // small deadzone; otherwise any keyboard value stays. + leftX = MergeAxis(pad.LeftX, leftX); + leftY = MergeAxis(pad.LeftY, leftY); + rightX = MergeAxis(pad.RightX, rightX); + rightY = MergeAxis(pad.RightY, rightY); + l2 = Math.Max(l2, pad.L2); + r2 = Math.Max(r2, pad.R2); + } + + BinaryPrimitives.WriteUInt32LittleEndian(data[0x00..], buttons); data[0x04] = leftX; data[0x05] = leftY; data[0x06] = rightX; data[0x07] = rightY; - data[0x08] = acceptsKeyboardInput && IsKeyDown(0x52) ? (byte)255 : (byte)0; - data[0x09] = acceptsKeyboardInput && IsKeyDown(0x46) ? (byte)255 : (byte)0; + data[0x08] = l2; + data[0x09] = r2; BinaryPrimitives.WriteSingleLittleEndian(data[0x18..], 1.0f); data[0x4C] = 1; var timestampTicks = Stopwatch.GetTimestamp(); @@ -254,4 +351,10 @@ public static class PadExports if (positive && !negative) return 255; return 128; } + + private static byte MergeAxis(byte controller, byte keyboard) + { + const int Deadzone = 10; + return Math.Abs(controller - 128) > Deadzone ? controller : keyboard; + } }