diff --git a/tools/sunshine-ds5-sidecar/ControllerSession.cs b/tools/sunshine-ds5-sidecar/ControllerSession.cs index 2ea294c01..5655e6446 100644 --- a/tools/sunshine-ds5-sidecar/ControllerSession.cs +++ b/tools/sunshine-ds5-sidecar/ControllerSession.cs @@ -68,6 +68,10 @@ internal ControllerSession(byte deviceId, _controller.UsbAudio.Output.FramesReceived += OnAudioFrames; _controller.UsbAudio.Output.StreamingChanged += OnAudioStreamingChanged; } + // Emit a centered, untouched idle frame immediately: without it the + // device reports an all-zero buffer until the first client input, + // which raw consumers decode as a down touch contact at (0,0). + _controller.SubmitState(in _state); } internal byte DeviceId { get; } @@ -165,12 +169,23 @@ internal void SubmitMotion(ReadOnlySpan payload) _state.AccelGX = x / 9.80665f; _state.AccelGY = y / 9.80665f; _state.AccelGZ = z / 9.80665f; + // The alwaysArmed extendedReport encodes sensors from the raw + // Sony firmware fields, not the calibrated ones. Scales follow + // SDL's hidapi_ps5: 8192 LSB per g, HID gyro units are 1/64 of + // 1024 LSB per deg/s (= 16 LSB per deg/s), pitch/yaw/roll map + // to SDL gyro X/Y/Z one-to-one. + _state.AccelX = RawAccel(_state.AccelGX); + _state.AccelY = RawAccel(_state.AccelGY); + _state.AccelZ = RawAccel(_state.AccelGZ); } else if (type == 2) { _state.GyroDpsX = x; _state.GyroDpsY = y; _state.GyroDpsZ = z; + _state.GyroPitch = RawGyro(x); + _state.GyroYaw = RawGyro(y); + _state.GyroRoll = RawGyro(z); } else { @@ -346,6 +361,12 @@ private void SetTouch(int slot, bool active, uint pointerId, float x, float y) } } + private static short RawAccel(float g) => + (short)Math.Clamp((int)Math.Round(g * 8192f), short.MinValue, short.MaxValue); + + private static short RawGyro(float degreesPerSecond) => + (short)Math.Clamp((int)Math.Round(degreesPerSecond * 16f), short.MinValue, short.MaxValue); + private static HMButton MapButtons(uint flags) { var result = HMButton.None; diff --git a/tools/sunshine-ds5-sidecar/SidecarServer.cs b/tools/sunshine-ds5-sidecar/SidecarServer.cs index 5760a88fa..0bb2886a1 100644 --- a/tools/sunshine-ds5-sidecar/SidecarServer.cs +++ b/tools/sunshine-ds5-sidecar/SidecarServer.cs @@ -20,6 +20,7 @@ internal sealed class SidecarServer : IAsyncDisposable internal SidecarServer(string pipeName) { _pipeName = pipeName; + LoadPatchedProfiles(); _context.LoadDefaultProfiles(); _authoredHapticsAvailable = _context.GetProfile("dualsense-composite") is not null && HMContext.IsUsbipBackendAvailable; @@ -240,6 +241,39 @@ private void Detach(uint requestId, ReadOnlySpan payload) Emit(new Protocol.Message(Protocol.MessageType.DetachReply, requestId, new[] { payload[0] })); } + private void LoadPatchedProfiles() + { + // Upstream v1.6.1 USB DualSense profiles leave extendedReport unarmed, + // so the vendor-blob encoder never runs and the Sony tail of report + // 0x01 (touch fingers at bytes 33/37, rolling counter, sensors, + // battery) idles at 0x00. Windows and raw HID consumers decode byte + // 33 == 0x00 as a touch contact that is permanently down at (0,0), + // which the PTP stack turns into a held drag (menus auto-focus and + // inertia-scroll). Register our alwaysArmed copies before the stock + // catalog: profile loads skip duplicate IDs, so the first + // registration wins. + try + { + var assembly = typeof(SidecarServer).Assembly; + var directory = Path.Combine(Path.GetTempPath(), "sunshine-ds5-profiles"); + Directory.CreateDirectory(directory); + foreach (var id in new[] { "dualsense", "dualsense-composite" }) + { + var resourceName = $"Sunshine.Ds5Sidecar.profiles.{id}.json"; + using var stream = assembly.GetManifestResourceStream(resourceName) + ?? throw new InvalidOperationException($"Embedded profile '{resourceName}' is missing"); + using var file = File.Create(Path.Combine(directory, id + ".json")); + stream.CopyTo(file); + } + if (_context.LoadProfilesFromDirectory(directory) < 2) + Console.Error.WriteLine("Patched DualSense profiles did not fully register"); + } + catch (Exception error) + { + Console.Error.WriteLine($"Unable to load patched DualSense profiles: {error.Message}"); + } + } + private ControllerSession GetController(ReadOnlySpan payload) { if (payload.IsEmpty || !_controllers.TryGetValue(payload[0], out var controller)) diff --git a/tools/sunshine-ds5-sidecar/Sunshine.Ds5Sidecar.csproj b/tools/sunshine-ds5-sidecar/Sunshine.Ds5Sidecar.csproj index 6cc21715b..2d8f3bcd6 100644 --- a/tools/sunshine-ds5-sidecar/Sunshine.Ds5Sidecar.csproj +++ b/tools/sunshine-ds5-sidecar/Sunshine.Ds5Sidecar.csproj @@ -18,4 +18,15 @@ true + + + + + diff --git a/tools/sunshine-ds5-sidecar/profiles/dualsense-composite.json b/tools/sunshine-ds5-sidecar/profiles/dualsense-composite.json new file mode 100644 index 000000000..c85ba4b7e --- /dev/null +++ b/tools/sunshine-ds5-sidecar/profiles/dualsense-composite.json @@ -0,0 +1 @@ +{"id":"dualsense-composite","name":"DualSense (PS5) — Full","vendor":"Sony","vid":"0x054C","pid":"0x0CE6","productString":"DualSense Wireless Controller","manufacturerString":"Sony Interactive Entertainment","type":"gamepad","connection":"usb","descriptor":"05010905a1018501093009310932093509330934150026ff007508950681020600ff09209501810205010939150025073500463b016514750495018142650005091901290f150025017501950f81020600ff0921950d81020600ff0922150026ff0075089534810285020923952f9102850509339528b10285080934952fb102850909249513b102850a0925951ab10285200926953fb102852109279504b10285220940953fb10285800928953fb10285810929953fb1028582092a9509b1028583092b953fb1028584092c953fb1028585092d9502b10285a0092e9501b10285e0092f953fb10285f00930953fb10285f10931953fb10285f20932950fb10285f40935953fb10285f509369503b102c0","inputReportSize":64,"notes":"Issue #39: the four-interface USB composite a real DualSense presents, authored from DJm00n/ControllersInfo DescriptorDump_Wireless_Controller (DualSense Model CFI-ZCT1W): configuration wTotalLength 227, bNumInterfaces 4, self-powered, 500 mA. Interface 3 is the HID function and is byte-identical to the 'dualsense' profile, so the report descriptor, codec, buttonMap, axisMap and extendedReport blocks below are unchanged and every input that works there works here. Interfaces 0-2 are USB Audio Class: the OUT stream carries 4 channels at 48 kHz (channels 1/2 speaker, 3/4 voice-coil haptics, wChannelConfig 0x0033) and the IN stream carries the 2-channel headset microphone. Uses the USB/IP create path (backend: usbip). The transport ships inside HIDMaestro.Core.dll and deploys itself on first use, so this profile needs nothing installed by hand. 'dualsense' remains the UMDF2 single-interface profile and is unaffected. PRODUCT STRING (issue #44): serves 'DualSense Wireless Controller', which is what current hardware reports. The 2020 launch pad reported the shorter 'Wireless Controller', and that is what the ControllersInfo dump above records, but bcdDevice is 0x0100 on both so the revision cannot be used to pick a string and only one can be served. Current hardware wins because a consumer keyed to the old string is already broken against a real modern pad. The launch string is still available on 'dualsense-bt' (its 'dualsense-bt-full' sibling carries the current one).","buttonMap":[1,2,0,3,4,5,8,9,10,11,12,13,-1,-1,-1,14],"triggerButtons":[6,7],"axisMap":{"0x32":"rightStickX","0x35":"rightStickY","0x33":"leftTrigger","0x34":"rightTrigger"},"extendedReport":{"alwaysArmed":true,"reportId":"0x01","size":64,"fields":[{"byte":1,"type":"uint8-axis","semantic":"leftStickX","center":128},{"byte":2,"type":"uint8-axis","semantic":"leftStickY","center":128},{"byte":3,"type":"uint8-axis","semantic":"rightStickX","center":128},{"byte":4,"type":"uint8-axis","semantic":"rightStickY","center":128},{"byte":5,"type":"uint8-trigger","semantic":"leftTrigger"},{"byte":6,"type":"uint8-trigger","semantic":"rightTrigger"},{"byte":7,"type":"uint8-rolling","semantic":"sequenceNum","initial":0},{"byte":8,"bits":"0-3","type":"hat-octant","semantic":"hat","neutralValue":8},{"byte":8,"bits":"4-7","type":"button-mask","buttons":["X","A","B","Y"]},{"byte":9,"type":"button-mask","buttons":["LeftBumper","RightBumper","LT_DIGITAL","RT_DIGITAL","Back","Start","LeftStick","RightStick"]},{"byte":10,"type":"button-mask","buttons":["Guide","Touchpad","Misc1"]},{"byte":16,"type":"int16-le","semantic":"gyroPitch"},{"byte":18,"type":"int16-le","semantic":"gyroYaw"},{"byte":20,"type":"int16-le","semantic":"gyroRoll"},{"byte":22,"type":"int16-le","semantic":"accelX"},{"byte":24,"type":"int16-le","semantic":"accelY"},{"byte":26,"type":"int16-le","semantic":"accelZ"},{"byte":28,"type":"uint32-le","semantic":"sensorTimestamp"},{"byte":33,"type":"touchpad-finger","semantic":"touchpadFinger0"},{"byte":37,"type":"touchpad-finger","semantic":"touchpadFinger1"},{"byte":53,"bits":"0-3","type":"uint8-battery","semantic":"batteryLevel"},{"byte":53,"bits":"4-7","type":"bitfield","buttons":["batteryCharging","batteryFull"]}]},"extendedOutputReport":{"reportId":"0x02","size":48,"fields":[{"byte":1,"type":"uint8","semantic":"validFlag0"},{"byte":2,"type":"uint8","semantic":"validFlag1"},{"byte":3,"type":"uint8","semantic":"rightMotor"},{"byte":4,"type":"uint8","semantic":"leftMotor"},{"byte":5,"type":"uint8","semantic":"headphoneVolume"},{"byte":6,"type":"uint8","semantic":"speakerVolume"},{"byte":7,"type":"uint8","semantic":"micVolume"},{"byte":8,"type":"uint8","semantic":"audioControlFlags"},{"byte":9,"type":"uint8","semantic":"muteLed"},{"bytes":"11-21","type":"bytes-passthrough","semantic":"rightTriggerEffect"},{"bytes":"22-32","type":"bytes-passthrough","semantic":"leftTriggerEffect"},{"byte":39,"type":"uint8","semantic":"validFlag2"},{"byte":42,"type":"uint8","semantic":"lightbarSetup"},{"byte":43,"type":"uint8","semantic":"ledBrightness"},{"byte":44,"type":"uint8","semantic":"playerIndicator"},{"bytes":"45-47","type":"rgb24","semantic":"lightbar"},{"bytes":"1-47","type":"bytes-passthrough","semantic":"effectPayload"}]},"layout":{"kind":"gamepad","source":"https://en.wikipedia.org/wiki/DualShock","sticks":[{"side":"left","xAxis":"X","yAxis":"Y","clickButton":12},{"side":"right","xAxis":"Z","yAxis":"Rz","clickButton":13}],"triggers":[{"axis":"Rx","side":"left","kind":"analog"},{"axis":"Ry","side":"right","kind":"analog"}],"dpad":{"encoding":"hat","hatAxis":"Hat","hatPositions":8},"faceButtons":[{"role":"face_x","buttonIndex":0},{"role":"face_a","buttonIndex":1},{"role":"face_b","buttonIndex":2},{"role":"face_y","buttonIndex":3}],"shoulderButtons":[{"role":"left_bumper","buttonIndex":4},{"role":"right_bumper","buttonIndex":5},{"role":"left_trigger_click","buttonIndex":6},{"role":"right_trigger_click","buttonIndex":7}],"systemButtons":[{"role":"share","buttonIndex":8},{"role":"options","buttonIndex":9},{"role":"ps","buttonIndex":10},{"role":"guide","buttonIndex":11}],"extraButtons":[{"role":"mute","buttonIndex":14}],"haptics":{"rumble":"voice_coil_haptic","triggerHaptics":true},"imu":{"accelerometer":true,"gyroscope":true,"magnetometer":false}},"backend":"usbip","usbConfiguration":{"configurationValue":1,"attributes":192,"maxPowerMilliamps":500,"interfaces":[{"interfaceNumber":0,"function":"audioControl","altSettings":[{"altSetting":0,"interfaceClass":1,"interfaceSubClass":1,"interfaceProtocol":0,"endpoints":[]}]},{"interfaceNumber":1,"function":"audioStreamingOut","altSettings":[{"altSetting":0,"interfaceClass":1,"interfaceSubClass":2,"interfaceProtocol":0,"endpoints":[]},{"altSetting":1,"interfaceClass":1,"interfaceSubClass":2,"interfaceProtocol":0,"endpoints":[{"address":1,"transferType":"isochronous","syncType":"adaptive","maxPacketSize":392,"interval":4}],"audioStream":{"channels":4,"bitsPerSample":16,"sampleRateHz":48000,"channelConfig":51,"channelRoles":["speakerLeft","speakerRight","hapticLeft","hapticRight"]}}]},{"interfaceNumber":2,"function":"audioStreamingIn","altSettings":[{"altSetting":0,"interfaceClass":1,"interfaceSubClass":2,"interfaceProtocol":0,"endpoints":[]},{"altSetting":1,"interfaceClass":1,"interfaceSubClass":2,"interfaceProtocol":0,"endpoints":[{"address":130,"transferType":"isochronous","syncType":"asynchronous","maxPacketSize":196,"interval":4}],"audioStream":{"channels":2,"bitsPerSample":16,"sampleRateHz":48000,"channelConfig":3,"channelRoles":["microphone","microphone"]}}]},{"interfaceNumber":3,"function":"hid","altSettings":[{"altSetting":0,"interfaceClass":3,"interfaceSubClass":0,"interfaceProtocol":0,"endpoints":[{"address":132,"transferType":"interrupt","maxPacketSize":64,"interval":6},{"address":3,"transferType":"interrupt","maxPacketSize":64,"interval":6}]}]}],"busSpeed":"high","deviceDescriptor":"12010002000000404c05e60c000101020001","configurationDescriptor":"0902e300040100c0fa0904000000010100000a2401000149000201020c24020101010604330000000c24060201010300000000000924030301030402000c2402040204030203000000092406050401030000092403060101010500090401000001020000090401010101020000072401010101000b2402010402100180bb0009050109880104000007250100000000090402000001020000090402010101020000072401060101000b2402010202100180bb0009058205c400040000072501000000000904030002030000000921110100012211010705840340000607050303400006","otherSpeedConfigurationDescriptor":"0902e300040100c0fa0904000000010100000a2401000149000201020c24020101010604330000000c24060201010300000000000924030301030402000c2402040204030100000000092406050401030000092403060101010500090401000001020000090401010101020000072401010101000b2402010402100180bb0009050109880101000007250101000000090402000001020000090402010101020000072401060101000b2402010202100180bb0009058205c400010000072501000000000904030002030000000921110100012211010705840340000607050303400006","audioControls":[{"unitId":2,"controlInterface":0,"function":"speaker","muteCur":0,"volumeMinRaw":-25600,"volumeMaxRaw":0,"volumeResRaw":256,"volumeCurRaw":-25600},{"unitId":5,"controlInterface":0,"function":"microphone","muteCur":0,"volumeMinRaw":0,"volumeMaxRaw":12288,"volumeResRaw":122,"volumeCurRaw":3809}]}} diff --git a/tools/sunshine-ds5-sidecar/profiles/dualsense.json b/tools/sunshine-ds5-sidecar/profiles/dualsense.json new file mode 100644 index 000000000..8b03c3fcd --- /dev/null +++ b/tools/sunshine-ds5-sidecar/profiles/dualsense.json @@ -0,0 +1 @@ +{"id":"dualsense","name":"DualSense (PS5)","vendor":"Sony","vid":"0x054C","pid":"0x0CE6","productString":"DualSense Wireless Controller","manufacturerString":"Sony Interactive Entertainment","type":"gamepad","connection":"usb","descriptor":"05010905a1018501093009310932093509330934150026ff007508950681020600ff09209501810205010939150025073500463b016514750495018142650005091901290f150025017501950f81020600ff0921950d81020600ff0922150026ff0075089534810285020923952f9102850509339528b10285080934952fb102850909249513b102850a0925951ab10285200926953fb102852109279504b10285220940953fb10285800928953fb10285810929953fb1028582092a9509b1028583092b953fb1028584092c953fb1028585092d9502b10285a0092e9501b10285e0092f953fb10285f00930953fb10285f10931953fb10285f20932950fb10285f40935953fb10285f509369503b102c0","inputReportSize":64,"notes":"273-byte descriptor. Source: DJm00n/ControllersInfo, confirmed by nondebug/dualsense. 4 USB interfaces. Both original and lighter 'V2' hardware revision share this PID. PRODUCT STRING (issue #44): serves 'DualSense Wireless Controller', which is what current hardware reports. The 2020 launch pad reported the shorter 'Wireless Controller', and that is what the ControllersInfo dump above records, but bcdDevice is 0x0100 on both so the revision cannot be used to pick a string and only one can be served. Current hardware wins because a consumer keyed to the old string is already broken against a real modern pad. The launch string is still available on 'dualsense-bt' (its 'dualsense-bt-full' sibling carries the current one).","buttonMap":[1,2,0,3,4,5,8,9,10,11,12,13,-1,-1,-1,14],"triggerButtons":[6,7],"axisMap":{"0x32":"rightStickX","0x35":"rightStickY","0x33":"leftTrigger","0x34":"rightTrigger"},"extendedReport":{"alwaysArmed":true,"reportId":"0x01","size":64,"fields":[{"byte":1,"type":"uint8-axis","semantic":"leftStickX","center":128},{"byte":2,"type":"uint8-axis","semantic":"leftStickY","center":128},{"byte":3,"type":"uint8-axis","semantic":"rightStickX","center":128},{"byte":4,"type":"uint8-axis","semantic":"rightStickY","center":128},{"byte":5,"type":"uint8-trigger","semantic":"leftTrigger"},{"byte":6,"type":"uint8-trigger","semantic":"rightTrigger"},{"byte":7,"type":"uint8-rolling","semantic":"sequenceNum","initial":0},{"byte":8,"bits":"0-3","type":"hat-octant","semantic":"hat","neutralValue":8},{"byte":8,"bits":"4-7","type":"button-mask","buttons":["X","A","B","Y"]},{"byte":9,"type":"button-mask","buttons":["LeftBumper","RightBumper","LT_DIGITAL","RT_DIGITAL","Back","Start","LeftStick","RightStick"]},{"byte":10,"type":"button-mask","buttons":["Guide","Touchpad","Misc1"]},{"byte":16,"type":"int16-le","semantic":"gyroPitch"},{"byte":18,"type":"int16-le","semantic":"gyroYaw"},{"byte":20,"type":"int16-le","semantic":"gyroRoll"},{"byte":22,"type":"int16-le","semantic":"accelX"},{"byte":24,"type":"int16-le","semantic":"accelY"},{"byte":26,"type":"int16-le","semantic":"accelZ"},{"byte":28,"type":"uint32-le","semantic":"sensorTimestamp"},{"byte":33,"type":"touchpad-finger","semantic":"touchpadFinger0"},{"byte":37,"type":"touchpad-finger","semantic":"touchpadFinger1"},{"byte":53,"bits":"0-3","type":"uint8-battery","semantic":"batteryLevel"},{"byte":53,"bits":"4-7","type":"bitfield","buttons":["batteryCharging","batteryFull"]}]},"extendedOutputReport":{"reportId":"0x02","size":48,"fields":[{"byte":1,"type":"uint8","semantic":"validFlag0"},{"byte":2,"type":"uint8","semantic":"validFlag1"},{"byte":3,"type":"uint8","semantic":"rightMotor"},{"byte":4,"type":"uint8","semantic":"leftMotor"},{"byte":5,"type":"uint8","semantic":"headphoneVolume"},{"byte":6,"type":"uint8","semantic":"speakerVolume"},{"byte":7,"type":"uint8","semantic":"micVolume"},{"byte":8,"type":"uint8","semantic":"audioControlFlags"},{"byte":9,"type":"uint8","semantic":"muteLed"},{"bytes":"11-21","type":"bytes-passthrough","semantic":"rightTriggerEffect"},{"bytes":"22-32","type":"bytes-passthrough","semantic":"leftTriggerEffect"},{"byte":39,"type":"uint8","semantic":"validFlag2"},{"byte":42,"type":"uint8","semantic":"lightbarSetup"},{"byte":43,"type":"uint8","semantic":"ledBrightness"},{"byte":44,"type":"uint8","semantic":"playerIndicator"},{"bytes":"45-47","type":"rgb24","semantic":"lightbar"},{"bytes":"1-47","type":"bytes-passthrough","semantic":"effectPayload"}]},"layout":{"kind":"gamepad","source":"https://en.wikipedia.org/wiki/DualShock","sticks":[{"side":"left","xAxis":"X","yAxis":"Y","clickButton":12},{"side":"right","xAxis":"Z","yAxis":"Rz","clickButton":13}],"triggers":[{"axis":"Rx","side":"left","kind":"analog"},{"axis":"Ry","side":"right","kind":"analog"}],"dpad":{"encoding":"hat","hatAxis":"Hat","hatPositions":8},"faceButtons":[{"role":"face_x","buttonIndex":0},{"role":"face_a","buttonIndex":1},{"role":"face_b","buttonIndex":2},{"role":"face_y","buttonIndex":3}],"shoulderButtons":[{"role":"left_bumper","buttonIndex":4},{"role":"right_bumper","buttonIndex":5},{"role":"left_trigger_click","buttonIndex":6},{"role":"right_trigger_click","buttonIndex":7}],"systemButtons":[{"role":"share","buttonIndex":8},{"role":"options","buttonIndex":9},{"role":"ps","buttonIndex":10},{"role":"guide","buttonIndex":11}],"extraButtons":[{"role":"mute","buttonIndex":14}],"haptics":{"rumble":"voice_coil_haptic","triggerHaptics":true},"imu":{"accelerometer":true,"gyroscope":true,"magnetometer":false}}}