diff --git a/src/haptics/authored_ir.cpp b/src/haptics/authored_ir.cpp index fddfa661..c1e6c721 100644 --- a/src/haptics/authored_ir.cpp +++ b/src/haptics/authored_ir.cpp @@ -245,6 +245,8 @@ namespace haptics { _active_since = {}; _last_nonzero_low = 0; _last_nonzero_high = 0; + _short_release_low = false; + _short_release_high = false; } const bool must_stop = (frame->flags & AH_AUTHORED_FRAME_STREAM_END) != 0; @@ -270,12 +272,33 @@ namespace haptics { if (must_stop) { _low_gate = {}; _high_gate = {}; + _short_release_low = false; + _short_release_high = false; } const auto low_target = must_stop ? 0.0f : shaped( low_energy, legacy_low_makeup_gain, _low_gate, duration_seconds); const auto high_target = must_stop ? 0.0f : shaped( high_energy, legacy_high_makeup_gain, _high_gate, duration_seconds); + // A force clear is only appropriate when a target drops out during the + // minimum hold window. Effects that were already active beyond that + // window must use the configured release tail instead. + const bool within_min_hold = + _active_since != std::chrono::steady_clock::time_point {} && + now - _active_since < legacy_min_active_hold; + if (must_stop || low_target > 0.0f) { + _short_release_low = false; + } + else if (within_min_hold) { + _short_release_low = true; + } + if (must_stop || high_target > 0.0f) { + _short_release_high = false; + } + else if (within_min_hold) { + _short_release_high = true; + } + const auto smooth = [duration_seconds](float previous, float target, float attack, float release) { const auto tau = target > previous ? attack : release; @@ -286,14 +309,11 @@ namespace haptics { _smoothed_low, low_target, legacy_low_attack_seconds, legacy_low_release_seconds); _smoothed_high = must_stop ? 0.0f : smooth( _smoothed_high, high_target, legacy_high_attack_seconds, legacy_high_release_seconds); - // Once the short-pulse hold has elapsed, do not let the release tail keep - // the motors active indefinitely. The hold is for dispatch reliability, - // not an extension of the authored signal. - if (!must_stop && _active_since != std::chrono::steady_clock::time_point {} && - now - _active_since >= legacy_min_active_hold) { - if (low_target <= 0.0f) _smoothed_low = 0.0f; - if (high_target <= 0.0f) _smoothed_high = 0.0f; - } + const bool hold_expired = + _active_since != std::chrono::steady_clock::time_point {} && + now - _active_since >= legacy_min_active_hold; + if (hold_expired && _short_release_low && low_target <= 0.0f) _smoothed_low = 0.0f; + if (hold_expired && _short_release_high && high_target <= 0.0f) _smoothed_high = 0.0f; if (low_target <= 0.0f && _smoothed_low < legacy_output_floor) _smoothed_low = 0.0f; if (high_target <= 0.0f && _smoothed_high < legacy_output_floor) _smoothed_high = 0.0f; @@ -313,6 +333,8 @@ namespace haptics { _active_since = {}; _last_nonzero_low = 0; _last_nonzero_high = 0; + _short_release_low = false; + _short_release_high = false; } // The 20 ms rate limit is the only emission gate. Held silence additionally // stays quiet instead of re-sending zero rumble at 50 Hz. @@ -345,6 +367,8 @@ namespace haptics { _active_since = {}; _last_nonzero_low = 0; _last_nonzero_high = 0; + _short_release_low = false; + _short_release_high = false; if (!had_output) return std::nullopt; _last_emit = now; return legacy_rumble_t {_controller_id, 0, 0}; diff --git a/src/haptics/authored_ir.h b/src/haptics/authored_ir.h index 6857d91a..be9d3117 100644 --- a/src/haptics/authored_ir.h +++ b/src/haptics/authored_ir.h @@ -111,6 +111,11 @@ namespace haptics { float _smoothed_high = 0.0f; gate_state_t _low_gate; gate_state_t _high_gate; + // Set only when a motor starts releasing before the short-pulse hold + // expires. Such pulses are force-cleared at the boundary; long effects + // retain their normal release tail. + bool _short_release_low = false; + bool _short_release_high = false; bool _have_input = false; }; } // namespace haptics diff --git a/tests/unit/test_authored_ir.cpp b/tests/unit/test_authored_ir.cpp index 4437679c..87721f26 100644 --- a/tests/unit/test_authored_ir.cpp +++ b/tests/unit/test_authored_ir.cpp @@ -216,6 +216,46 @@ TEST(AuthoredDualSenseIr, LegacyFallbackClearsFloorAfterRelease) { EXPECT_EQ(latest->high_frequency, 0u); } +TEST(AuthoredDualSenseIr, LegacyFallbackKeepsReleaseTailForLongEffect) { + using namespace std::chrono_literals; + haptics::legacy_rumble_session_t session; + ASSERT_TRUE(session.ready()); + const auto start = std::chrono::steady_clock::time_point {1s}; + const auto burst = sine_pcm(120.0, 32000.0); + const std::vector silence(240 * 4); + + // Keep the authored signal active beyond the short-pulse hold window. + bool saw_output = false; + for (std::uint32_t chunk = 0; chunk <= 20; ++chunk) { + const auto output = session.process( + 0, chunk == 0 ? 0x01 : 0, 240, chunk, chunk * 5000, + burst, start + std::chrono::milliseconds(chunk * 5)); + saw_output = saw_output || output.has_value(); + } + ASSERT_TRUE(saw_output); + + std::optional release_start; + std::optional latest; + for (std::uint32_t chunk = 21; chunk <= 80; ++chunk) { + const auto output = session.process( + 0, 0, 240, chunk, chunk * 5000, silence, + start + std::chrono::milliseconds(chunk * 5)); + if (output) { + if (!release_start) release_start = output; + latest = output; + } + } + + ASSERT_TRUE(release_start.has_value()); + // A long effect releases through the configured tail instead of being hard + // cut at the 80 ms short-pulse boundary. + EXPECT_GT(release_start->low_frequency, 0u); + EXPECT_GT(release_start->high_frequency, 0u); + ASSERT_TRUE(latest.has_value()); + EXPECT_EQ(latest->low_frequency, 0u); + EXPECT_EQ(latest->high_frequency, 0u); +} + TEST(AuthoredDualSenseIr, LegacyFallbackDiscontinuityDoesNotReuseHold) { using namespace std::chrono_literals; haptics::legacy_rumble_session_t session; diff --git a/tools/sunshine-ds5-sidecar/ControllerSession.cs b/tools/sunshine-ds5-sidecar/ControllerSession.cs index 2ea294c0..5655e644 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 5760a88f..0bb2886a 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 6cc21715..2d8f3bcd 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 00000000..c85ba4b7 --- /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 00000000..8b03c3fc --- /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}}}