Building DualSense Haptics for X-Plane 12 on Linux
I fly X-Plane 12 on Linux, and a few weeks ago I wanted the DualSense in my hands to actually do something: a thump on touchdown, rumble on the runway, a bit of buzz if I’m overspeeding. X-Plane doesn’t support DualSense haptics natively, and I couldn’t find anyone who’d built this for Linux, so I built it myself. This is the write-up of how it actually works, and the two bugs that nearly made me give up on it in the first week.
I should say up front, I’m not a serious flight simmer. No HOTAS, no rudder pedals, no yoke bolted to a desk, none of that. I just plug in the same PS5 controller I use for everything else and fly with that, because it’s what I already own and it’s good enough to get my fix after a long day. So when the controller in my hands just sat there doing nothing while the plane touched down or bounced through turbulence, it bugged me more than it probably should have. That’s really the whole origin of this thing, wanting the one piece of hardware I actually fly with to feel like it’s part of the flight instead of just steering it.
The shape of the thing
There are two halves to this, and neither one talks to the other’s SDK. X-Plane doesn’t know the DualSense exists, and the DualSense doesn’t know X-Plane exists. The daemon sits in the middle and translates.
flowchart LR
A[X-Plane 12
UDP :49000] -->|RREF stream| B[Parse]
B --> C[Effect Mixer]
C -->|HID report| D[DualSense
USB or Bluetooth] On one side, X-Plane has a UDP protocol called RREF that’s been in the sim for years. You send it a small packet naming a dataref and a frequency, and it streams back (index, value) pairs at whatever rate you asked for. No plugin required, it’s just sitting on port 49000 waiting.
On the other side, the DualSense will take raw HID output reports over hidraw on Linux without needing Sony’s SDK or Steam Input. The classic rumble path, the same one the kernel’s hid-playstation driver uses internally, is just two bytes in a report: one for the left actuator, one for the right.
The middle is a plain effect mixer. Groundspeed feeds runway rumble. A rising edge on weight-on-wheels plus the vertical speed at that instant feeds a touchdown thump. Angle of attack relative to stall AoA feeds buffet. Nothing fancy, just curves.
Requesting a dataref from X-Plane is just a fixed-layout UDP packet:
pub fn subscribe(&self, dataref: &str, index: i32, freq: i32) -> Result<()> {
let mut packet = Vec::with_capacity(5 + 4 + 4 + DATAREF_FIELD_LEN);
packet.extend_from_slice(b"RREF\0");
packet.extend_from_slice(&freq.to_le_bytes());
packet.extend_from_slice(&index.to_le_bytes());
let mut dref_field = vec![0u8; DATAREF_FIELD_LEN];
dref_field[..dataref.len()].copy_from_slice(dataref.as_bytes());
packet.extend_from_slice(&dref_field);
self.socket.send_to(&packet, self.xplane_addr)?;
Ok(())
} freq is updates per second, index is a tag you choose so you can tell datarefs apart in the responses that come back. Set freq to zero later and it stops sending.
USB and Bluetooth are not the same report
This is the part that took the most reading. The DualSense’s output report is a different shape depending on how it’s connected.
Over USB, the report ID is 0x02, it’s 64 bytes total, and the two rumble bytes sit at fixed offsets right after a couple of flag bytes. Over Bluetooth, the report ID is 0x31, the whole thing is 78 bytes, and everything shifts by a few bytes to make room for a small BT-specific header: a rolling sequence number and a fixed tag byte.
The part that actually got me was the last four bytes. Bluetooth reports need a CRC-32 appended, or the controller just silently drops them. No error, no ack, nothing. It just doesn’t rumble and you sit there wondering if your byte offsets are wrong when actually the offsets were fine and the checksum was missing.
The checksum itself has one quirk worth knowing: it’s not a CRC-32 over the raw report bytes. You seed the CRC state with a single constant byte (0xA2) before feeding it the actual payload. That seed byte isn’t part of the report itself, it’s just how Sony’s firmware expects the CRC to be primed. Once I found that in a couple of open source Linux tools for the DualSense and cross-checked it against the kernel driver source, it clicked into place immediately.
I wrote the report builder as a pair of free functions, one per transport, and unit tested both against known-good byte layouts before I ever touched real hardware. That paid off, because when I finally ran it against the actual controller over Bluetooth, it worked on the first try. All the pain had already happened in the test suite.
Here’s the Bluetooth report builder, CRC and all:
pub fn build_bt_report_raw(
state: OutputState,
rt: TriggerEffect,
lt: TriggerEffect,
seq: u8,
) -> Vec<u8> {
let mut report = vec![0u8; BT_REPORT_LEN]; // 78 bytes
report[0] = 0x31; // BT output report id
report[1] = seq << 4; // rolling sequence number
report[2] = 0x10; // fixed tag byte
fill_common(&mut report[3..3 + 47], state, rt, lt);
let len = report.len();
let crc = bt_crc32(&report[0..len - 4]);
report[len - 4..].copy_from_slice(&crc.to_le_bytes());
report
}
fn bt_crc32(payload: &[u8]) -> u32 {
let mut hasher = crc32fast::Hasher::new();
hasher.update(&[0xA2]); // the seed byte, not part of the report itself
hasher.update(payload);
hasher.finalize()
} That seed byte is the whole trick. Feed the CRC state 0xA2 first, then the actual report bytes, then take the result. Skip the seed and the controller just ignores the report, no error, no rumble, nothing.
Permissions were their own small project
Getting hidraw access without running as root turned out to be its own detour. My first instinct was the usual udev pattern, TAG+="uaccess", which is supposed to grant access automatically to whoever’s logged in at the active seat. That’s the “correct” modern way to do it on most distros.
It didn’t work here. Active seat session, right user, rule matching the device, and still permission denied with no ACL entry showing up on the device at all. I don’t have a fully satisfying answer for why uaccess didn’t apply in this particular KDE/Wayland/sddm setup, and after enough time poking at it I stopped caring about the why and switched to something boring and reliable instead: a dedicated group, a static GROUP= rule, done. Not as elegant, but it doesn’t depend on logind doing the right thing behind the scenes.
Small side note for anyone chasing something similar: don’t trust ATTRS{idVendor} and ATTRS{idProduct} for a Bluetooth HID device. Those attributes live on the USB descriptor chain and just aren’t there for a BT-paired controller. You need to match on KERNELS against the Bluetooth address string instead, which shows up in a format like 0005:054C:0CE6.0016.
The bug that made it buzz nonstop
Once permissions were sorted and reports were going out, the daemon technically worked. It also never shut up. Rumble on the ground, rumble in the air, rumble sitting at the gate with the engines idling.
The actual cause was almost funny once I found it. My flight state struct held the sim’s normal load factor (g_nrml), which sits at 1.0 in level flight at rest. I’d let that field fall back to Rust’s derived default of 0.0 before the first real UDP packet arrived. My turbulence effect measured how far that value deviated from 1.0, so for a moment on every startup, and for anyone whose subscription hadn’t caught up yet, the daemon thought the plane was in permanent freefall and rumbled accordingly.
The fix was one line, initializing that field to 1.0 instead of trusting the derived default. But it’s the kind of bug that only shows up once you’re running against something with real state and real timing, no unit test written in isolation was going to catch a race between “daemon started” and “first dataref packet arrived.”
impl FlightState {
pub fn new() -> Self {
Self {
stall_alpha_deg: 14.0,
g_nrml: 1.0, // 1g at rest. Derive's default of 0.0 reads as
// permanent freefall until the first real packet lands.
..Default::default()
}
}
} The other half of the fix was less a bug and more a design mistake. A few of my effects were continuous by nature (idle engine hum, general jitter from the sim’s own physics noise) and I’d given them just enough gain that they never actually reached zero. Real haptic feedback in games is silent almost all the time and only speaks up for something specific. I added a hard deadzone: anything below a small threshold gets clamped to exactly zero instead of leaving a faint hum running underneath everything. Transients like touchdown bypass that deadzone entirely, so a real event never gets eaten by the same threshold that keeps cruise flight quiet.
const OUTPUT_DEADZONE: f32 = 0.10;
// continuous effects respect the deadzone; a transient always gets through
let level = if transient_level > 0.0 {
level.clamp(0.0, 1.0)
} else if level < OUTPUT_DEADZONE {
0.0
} else {
level.clamp(0.0, 1.0)
}; Where it’s at
Right now it does runway rumble, touchdown thump, stall buffet, and overspeed buzz, all gated so they’re silent until something’s actually happening. Adaptive trigger support is sketched out in the code but not wired into anything yet, that’s the obvious next thing to build, probably as a bit of resistance on the throttle as speed builds.
If you’re building something similar against a DualSense on Linux, the short version is: raw hidraw writes work fine, don’t trust uaccess blindly, remember the Bluetooth CRC, and initialize your defaults to something physically sane before your first real data point ever arrives.