Skip to content

IMU on Steam Deck and DualSense targets breaks motion in SDL apps: unscaled/rotated accel (Deck), fake sensor timestamp and misaligned calibration report (DualSense) #715

Description

@SimpleMatt7

Summary

On a ROG Ally X, no InputPlumber target gives SDL applications usable motion data, although the IMU source
itself is fine (a DSU bridge reading the same IIO device works). Four independent problems, all still present
in v0.80.0:

  1. Steam Deck targets (deck, deck-uhid): accelerometer not scaled. Values in m/s² are written with
    x as i16, so gravity reads about ±10 instead of about ±16384. Any sensor fusion (tilt, steering) fails.
  2. Steam Deck targets: axes not remapped. SDL reads the Deck report as (sX, sZ, -sY), so writing
    InputPlumber's (x, y, z) unchanged rotates the data 90° about X after SDL.
  3. DualSense targets: fake sensor timestamp. sensor_timestamp advances by 3 (= 1 µs) on every
    write_event, not with real time. SDL forwards it as the sensor timestamp, so apps that integrate the gyro
    with it integrate over microseconds and effectively ignore the gyro.
  4. DualSense targets: calibration feature report is one byte short in the bias block. SDL rejects the
    calibration and falls back to defaults.

Related: #262 and draft PR #612 (SI normalization). #612 fixes the scale but not problems 2–4; details below.

Environment

  • Device: ASUS ROG Ally X, IMU bmi323-imu via IIO (200 Hz)
  • Distro: Bazzite 44 (44.20260908), InputPlumber 0.79.0-4; code checked unchanged in v0.80.0
  • Consumers: SDL3 through Eden (Switch emulator) and a small hidraw/evdev reader
  • Game used: Mario Kart 8 Deluxe motion steering

1 + 2. Steam Deck targets: accelerometer scale and axes

Code (v0.80.0): src/input/target/steam_deck.rs lines 681–705 (Gamepad::Accelerometer / Gamepad::Gyro)
and 772–796 (Capability::Gyroscope(_) / Capability::Accelerometer(_)); src/input/target/steam_deck_uhid.rs
lines 286–310 and 377–401.

SDL3 src/joystick/hidapi/SDL_hidapi_steamdeck.c:

values[0] = (deckState.sAccelX / 32768.0f) * 2.0f * SDL_STANDARD_GRAVITY;
values[1] = (deckState.sAccelZ / 32768.0f) * 2.0f * SDL_STANDARD_GRAVITY;
values[2] = (-deckState.sAccelY / 32768.0f) * 2.0f * SDL_STANDARD_GRAVITY;

(the gyro uses the same (X, Z, -Y) order, with 32768 = 2000 °/s). SDL_hidapi_ps5.c reads the DualSense
unrotated, which is why the same (x, y, z) is correct for DualSense but not for Deck.

Measured from the raw deck-uhid hidraw report (type 9, bytes 24–35), 10 s of shaking and rotating, 4451 reports:

acc  X  min=  -11  max=    8     <- about 1600x too small
acc  Y  min=   -9  max=   16
acc  Z  min=  -27  max=   32
gyro X  min=-9227  max= 7301     <- gyro data present

Tested fix. Rescaling the accelerometer and writing both sensors as (X, -Z, Y) in all four arms:

 Gamepad::Accelerometer => {
     if let InputValue::Vector3 { x, y, z } = value {
+        // Steam Deck IMU axes are (X, -Z, Y) relative to InputPlumber's (X, Y, Z).
         if let Some(x) = x {
-            self.state.accel_x = Integer::from_primitive(x as i16);
+            self.state.accel_x = Integer::from_primitive(deck_accel_value(x));
         }
-        if let Some(y) = y {
-            self.state.accel_y = Integer::from_primitive(y as i16);
-        }
         if let Some(z) = z {
-            self.state.accel_z = Integer::from_primitive(z as i16);
+            self.state.accel_y = Integer::from_primitive(deck_accel_value(-z));
+        }
+        if let Some(y) = y {
+            self.state.accel_z = Integer::from_primitive(deck_accel_value(y));
         }
     }
 }
 Gamepad::Gyro => {
     if let InputValue::Vector3 { x, y, z } = value {
+        // Steam Deck IMU axes are (X, -Z, Y) relative to InputPlumber's (X, Y, Z).
         if let Some(x) = x {
             self.state.pitch = Integer::from_primitive(x as i16);
         }
-        if let Some(y) = y {
-            self.state.yaw = Integer::from_primitive(y as i16);
-        }
         if let Some(z) = z {
-            self.state.roll = Integer::from_primitive(z as i16);
+            self.state.yaw = Integer::from_primitive((-z) as i16);
+        }
+        if let Some(y) = y {
+            self.state.roll = Integer::from_primitive(y as i16);
         }
     }
 }
/// Steam Deck accelerometer: 16384 units per G. InputPlumber reports m/s².
fn deck_accel_value(value_meters_sec: f64) -> i16 {
    (value_meters_sec / 9.80665 * 16384.0) as i16
}

Built from the v0.79.0 tag and verified on the device with static poses (raw deck-uhid report):

Pose Expected (SDL convention) Measured
Flat, screen up acc Y ≈ −16384 acc Y = −16545
Upright, screen facing user acc Z ≈ +16384 acc Z = +16350
Right edge up acc X ≈ +16384 acc X = +16230

These now match the DualSense convention after SDL.

Two further notes, from source reading and not fixed by the patch above:

  • Gyro scale. bmi_imu.rs (lines 112–121) intentionally provides °/s × 12, while the Deck report expects
    32768 / 2000 = 16.384 LSB per °/s, so after SDL the gyro is about 73% of the real rate.
  • Report cadence. The Deck report has no sensor timestamp; SDL assumes update_rate_us = 4000 per report.
    I measured about 143 reports/s on deck (vhci) and about 440 reports/s on deck-uhid, so SDL integrates the
    gyro with the wrong dt either way. For motion to be correct through SDL, the target has to send a state report
    every 4 ms.
  • deck-uhid uses PID 0x12FD, which SDL does not treat as a Deck (only 0x1205). Understood from deck target issues (was IMU Support for Steam Deck (on Gentoo)) #433 that
    this is intentional; mentioning it because it means only deck can carry motion to SDL apps.

3. DualSense targets: sensor timestamp

src/input/target/dualsense.rs line 974 (v0.80.0):

self.sensor_timestamp = self.sensor_timestamp.wrapping_add(3);

This runs once per write_event, i.e. per input event. SDL (SDL_hidapi_ps5.c) treats the 32-bit field as
0.33 µs units and passes the accumulated value to SDL_SendJoystickSensor. Eden
(src/input_common/drivers/sdl_driver.cpp) uses the difference between consecutive sensor timestamps as the
motion dt, so the gyro is integrated over ~1 µs per report and steering comes almost only from gravity
(in Mario Kart 8 the device had to be tilted about 90° to steer fully).

Suggested fix: derive the field from a monotonic clock at report time, e.g.
(start.elapsed().as_micros() * 3) as u32.

4. DualSense targets: calibration report 0x05

src/input/target/dualsense.rs line 864 returns:

05 | ff fc ff fe ff | 83 22 78 dd 92 22 5f dd 95 22 6d dd | 1c 02 1c 02 | f2 1f ed df e3 20 da e0 ee 1f df df | 0b 00 ...

SDL (HIDAPI_DriverPS5_LoadCalibrationData) and hid-playstation read three 16-bit gyro biases at bytes 1–6
and the gyro plus/minus values from byte 7. Here the bias block has only 5 bytes, so byte 6 (0x83) completes
the roll bias (0x83ff = −31745) and every later field is read one byte off. SDL flags the calibration as bad
and uses its defaults; combined with the °/s × 12 source scale the gyro ends up at about 75%.

Suggested fix: make the bias block 6 bytes. Since the virtual device already sends bias-free data, zero biases
seem right: 0x05, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x83, 0x22, 0x78, 0xdd, ....

Also: the Capability::Accelerometer(_) arm (line 652) writes x as i16 without denormalize_accel_value,
unlike the Gamepad::Accelerometer arm (line 559).

Notes on draft PR #612

  • Deck constants: DECK_SI_TO_ACCEL = 1632.65 is 16011 LSB/g, but SDL uses 16384 LSB/g
    (32768 / (2 · 9.80665) = 1670.7 per m/s²). DECK_RADS_TO_GYRO = 916.73 is 16.0 LSB per °/s; SDL uses
    16.384 (938.7 per rad/s). Both are about 2.4% low.
  • The Deck targets still write x → accel_x, y → accel_y, z → accel_z, so problem 2 remains.
  • The DualSense timestamp and calibration report are not touched.

Impact

SteamOS also drives third-party handheld controllers through InputPlumber's Deck targets via steamos-manager
(ValveSoftware/SteamOS#1891, #1908), so SteamOS users on these devices are likely affected too.

Happy to test builds on the Ally X.

Disclosure: this investigation and write-up, including the proposed patch, were done with the help of Claude (Anthropic). I built and tested the patch and took the measurements on my ROG Ally X.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions