Files
netris-nestri/apps/nesgamepad/src/udev.rs
T
451b495de6 feat: controller/gamepad support (#350)
Adds `nesgamepad`, basically "diet-coke vimputti" for Nestri's microVM
needs here.

Comes with direct mapping for:
- Sony
  - DualShock 4 (v2), DualSense
- Microsoft
  - Xbox 360 pad, Xbox One S pad
- Nintendo
  - Pro Controller
- Generic pad for unknown controllers

---------

Co-authored-by: DatCaptainHorse <DatCaptainHorse@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 17:50:02 +03:00

392 lines
15 KiB
Rust

//! Telling libudev about a device, in a box that has no udev.
//!
//! Games find controllers through libudev -- SDL, and Wine's device bus under
//! Proton, both enumerate with it and watch its monitor for hotplug. Without a
//! udev daemon the kernel still creates the device and its node, but two things
//! a daemon adds are missing, and each is enough on its own to make a
//! controller invisible:
//!
//! - **Properties.** Whether an input device is a joystick is not something
//! the kernel says; `ID_INPUT_JOYSTICK` is the daemon's classification,
//! stored in its database under `/run/udev/data`. Readers ask for it by name
//! and skip a device that lacks it.
//! - **Hotplug.** libudev's monitor listens on the netlink group the *daemon*
//! rebroadcasts on, not the one the kernel announces on, so a device created
//! while a game is running is never seen by it.
//!
//! This does both, for the devices this process makes and nothing else.
//!
//! # What a broadcast must look like to be believed
//!
//! Taken from libudev's receiving side (systemd's `device-monitor.c`), because
//! every one of these is a silent drop on failure:
//!
//! - The monitor only listens without a daemon at all when `/dev` is a
//! devtmpfs, which in a box it is.
//! - The sender must be uid 0. This process runs as root for that reason.
//! - A message with the `libudev` header marks the device initialized; one in
//! the kernel's own `action@devpath` form does not, and readers that check
//! drop it. The header carries MurmurHash2 hashes that subscribers filter on
//! *in the kernel*, so a wrong hash is a message nobody receives.
use std::collections::BTreeMap;
use std::io;
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicU64, Ordering};
const DATA_DIR: &str = "/run/udev/data";
const TAGS_DIR: &str = "/run/udev/tags";
/// The netlink group udevd rebroadcasts on, as opposed to the kernel's 1.
const GROUP_UDEV: u32 = 2;
const UDEV_MONITOR_MAGIC: u32 = 0xfeed_cafe;
/// Tags every input device a seat owns gets from udev's stock rules. A reader
/// enumerating by tag, rather than by subsystem, finds nothing without them.
const TAGS: [&str; 2] = ["seat", "uaccess"];
/// systemd's `MurmurHash2`, seed and all. Its hashes go into a header the
/// kernel filters on, so this has to agree bit for bit.
fn murmur2(data: &[u8], seed: u32) -> u32 {
const M: u32 = 0x5bd1_e995;
let mut h = seed ^ data.len() as u32;
let (chunks, tail) = data.as_chunks::<4>();
for chunk in chunks {
let mut k = u32::from_le_bytes(*chunk);
k = k.wrapping_mul(M);
k ^= k >> 24;
k = k.wrapping_mul(M);
h = h.wrapping_mul(M) ^ k;
}
if !tail.is_empty() {
for (i, &b) in tail.iter().enumerate().rev() {
h ^= u32::from(b) << (8 * i);
}
h = h.wrapping_mul(M);
}
h ^= h >> 13;
h = h.wrapping_mul(M);
h ^ (h >> 15)
}
fn bloom(tag: &str) -> u64 {
let hash = murmur2(tag.as_bytes(), 0);
[0, 6, 12, 18]
.iter()
.fold(0, |bits, shift| bits | 1u64 << ((hash >> shift) & 63))
}
/// One device as udev would describe it.
#[derive(Debug, Clone)]
pub struct Record {
/// Path under `/sys`, which is what udev calls the devpath.
pub devpath: String,
/// The database's name for it: `c13:80` for a node, `+input:input5` for
/// a device without one.
pub db_name: String,
pub properties: BTreeMap<String, String>,
}
impl Record {
/// Describe a device from what the kernel says about it, plus what udev's
/// rules would have added.
///
/// The kernel's own `uevent` file is the base, because that is exactly
/// what udevd starts from; `extra` is the classification on top.
pub fn read(syspath: &Path, subsystem: &str, extra: &[(&str, String)]) -> io::Result<Self> {
let mut properties = BTreeMap::new();
for line in std::fs::read_to_string(syspath.join("uevent"))?.lines() {
if let Some((key, value)) = line.split_once('=') {
properties.insert(key.to_owned(), value.to_owned());
}
}
let devpath = devpath(syspath)?;
let sysname = syspath
.file_name()
.map(|n| n.to_string_lossy().into_owned())
.unwrap_or_default();
let db_name = match (properties.get("MAJOR"), properties.get("MINOR")) {
(Some(major), Some(minor)) => format!("c{major}:{minor}"),
_ => format!("+{subsystem}:{sysname}"),
};
// The kernel names the node relative to /dev; udev names it in full.
if let Some(devname) = properties.get_mut("DEVNAME")
&& !devname.starts_with('/')
{
*devname = format!("/dev/{devname}");
}
properties.insert("DEVPATH".into(), devpath.clone());
properties.insert("SUBSYSTEM".into(), subsystem.into());
properties.insert("USEC_INITIALIZED".into(), monotonic_usec().to_string());
let tags = format!(":{}:", TAGS.join(":"));
properties.insert("TAGS".into(), tags.clone());
properties.insert("CURRENT_TAGS".into(), tags);
for (key, value) in extra {
properties.insert((*key).to_owned(), value.clone());
}
Ok(Self {
devpath,
db_name,
properties,
})
}
/// The database entry, in the format udevd writes.
fn db_entry(&self) -> String {
let mut entry = format!("I:{}\n", self.properties["USEC_INITIALIZED"]);
// Only what a rule added goes in the database; the rest a reader gets
// from sysfs itself.
for (key, value) in &self.properties {
if key.starts_with("ID_") {
entry.push_str(&format!("E:{key}={value}\n"));
}
}
for tag in TAGS {
entry.push_str(&format!("G:{tag}\nQ:{tag}\n"));
}
entry.push_str("V:1\n");
entry
}
}
/// udev's devpath: the syspath without `/sys`, keeping the leading slash.
///
/// `Path::strip_prefix` drops that slash, and libudev joins `/sys` straight
/// onto whatever it is given -- so without it every device lands at
/// `/sysdevices/...`, and every broadcast is rejected with nothing logged.
fn devpath(syspath: &Path) -> io::Result<String> {
let rest = syspath
.strip_prefix("/sys")
.map_err(|_| io::Error::other("not under /sys"))?;
Ok(format!("/{}", rest.to_string_lossy()))
}
fn monotonic_usec() -> u64 {
let mut ts = libc::timespec {
tv_sec: 0,
tv_nsec: 0,
};
// SAFETY: a valid clock and a timespec to fill.
unsafe { libc::clock_gettime(libc::CLOCK_MONOTONIC, &mut ts) };
ts.tv_sec as u64 * 1_000_000 + ts.tv_nsec as u64 / 1_000
}
/// Stands in for udevd's database and broadcasts.
pub struct Udev {
socket: std::os::fd::OwnedFd,
seqnum: AtomicU64,
}
impl Udev {
pub fn new() -> io::Result<Self> {
let udev = Self::broadcaster()?;
for dir in [PathBuf::from(DATA_DIR)]
.into_iter()
.chain(TAGS.iter().map(|t| Path::new(TAGS_DIR).join(t)))
{
std::fs::create_dir_all(&dir)?;
}
Ok(udev)
}
/// The broadcasting half alone, which touches nothing on disk.
fn broadcaster() -> io::Result<Self> {
// SAFETY: plain socket creation; the result is checked.
let fd = unsafe {
libc::socket(
libc::AF_NETLINK,
libc::SOCK_RAW | libc::SOCK_CLOEXEC,
libc::NETLINK_KOBJECT_UEVENT,
)
};
if fd < 0 {
return Err(io::Error::last_os_error());
}
// SAFETY: a descriptor just returned to us and owned by nothing else.
let socket = unsafe { std::os::fd::FromRawFd::from_raw_fd(fd) };
Ok(Self {
socket,
seqnum: AtomicU64::new(1),
})
}
/// Record a device and announce it.
pub fn add(&self, record: &Record) -> io::Result<()> {
std::fs::write(Path::new(DATA_DIR).join(&record.db_name), record.db_entry())?;
for tag in TAGS {
std::fs::write(Path::new(TAGS_DIR).join(tag).join(&record.db_name), "")?;
}
self.broadcast("add", record)
}
/// Announce a device gone and forget it. Every step is attempted even if
/// an earlier one fails: a stale database entry is harmless next to a
/// removal nobody heard.
pub fn remove(&self, record: &Record) -> io::Result<()> {
let _ = std::fs::remove_file(Path::new(DATA_DIR).join(&record.db_name));
for tag in TAGS {
let _ = std::fs::remove_file(Path::new(TAGS_DIR).join(tag).join(&record.db_name));
}
self.broadcast("remove", record)
}
fn broadcast(&self, action: &str, record: &Record) -> io::Result<()> {
let seqnum = self.seqnum.fetch_add(1, Ordering::Relaxed);
let message = encode(action, seqnum, record);
let mut addr: libc::sockaddr_nl = unsafe { std::mem::zeroed() };
addr.nl_family = libc::AF_NETLINK as u16;
addr.nl_groups = GROUP_UDEV;
// SAFETY: the address and buffer outlive the call.
let n = unsafe {
libc::sendto(
std::os::fd::AsRawFd::as_raw_fd(&self.socket),
message.as_ptr().cast(),
message.len(),
0,
(&addr as *const libc::sockaddr_nl).cast(),
std::mem::size_of::<libc::sockaddr_nl>() as u32,
)
};
if n < 0 {
let error = io::Error::last_os_error();
// What a multicast send reports when nobody is listening yet,
// which before a game has started is the normal case.
if error.raw_os_error() == Some(libc::ECONNREFUSED) {
return Ok(());
}
return Err(error);
}
Ok(())
}
}
/// A libudev monitor message: the `monitor_netlink_header`, then the
/// properties as NUL-terminated `KEY=value` strings.
fn encode(action: &str, seqnum: u64, record: &Record) -> Vec<u8> {
let mut properties = Vec::new();
let mut push = |key: &str, value: &str| {
properties.extend_from_slice(key.as_bytes());
properties.push(b'=');
properties.extend_from_slice(value.as_bytes());
properties.push(0);
};
push("ACTION", action);
push("SEQNUM", &seqnum.to_string());
for (key, value) in &record.properties {
push(key, value);
}
const HEADER_LEN: u32 = 40;
let tags = TAGS.iter().fold(0u64, |bits, tag| bits | bloom(tag));
let mut message = Vec::with_capacity(HEADER_LEN as usize + properties.len());
message.extend_from_slice(b"libudev\0");
// The magic, hashes and bloom are in network order; the lengths are not.
message.extend_from_slice(&UDEV_MONITOR_MAGIC.to_be_bytes());
message.extend_from_slice(&HEADER_LEN.to_ne_bytes());
message.extend_from_slice(&HEADER_LEN.to_ne_bytes());
message.extend_from_slice(&(properties.len() as u32).to_ne_bytes());
let subsystem = record
.properties
.get("SUBSYSTEM")
.map_or("", String::as_str);
message.extend_from_slice(&murmur2(subsystem.as_bytes(), 0).to_be_bytes());
// Neither input nor hidraw devices have a devtype, and zero is what udevd
// sends for none.
message.extend_from_slice(&0u32.to_be_bytes());
message.extend_from_slice(&((tags >> 32) as u32).to_be_bytes());
message.extend_from_slice(&(tags as u32).to_be_bytes());
debug_assert_eq!(message.len(), HEADER_LEN as usize);
message.extend_from_slice(&properties);
message
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_devpath_starts_where_sys_ends() {
assert_eq!(
devpath(Path::new("/sys/devices/virtual/input/input5/event3")).unwrap(),
"/devices/virtual/input/input5/event3"
);
assert!(devpath(Path::new("/dev/input/event3")).is_err());
}
#[test]
fn hashes_agree_with_systemd() {
// Computed by compiling systemd's own MurmurHash2.c.
for (input, expected) in [
("input", 0xc1a2_8470),
("seat", 0x435b_3e40),
("uaccess", 0xe88e_d0cc),
("hidraw", 0xc2ca_f397),
("a", 0x9268_5f5e),
("ab", 0x1aa1_4063),
("abc", 0x1357_7c9b),
] {
assert_eq!(murmur2(input.as_bytes(), 0), expected, "{input}");
}
}
fn record() -> Record {
let mut properties = BTreeMap::new();
properties.insert(
"DEVPATH".into(),
"/devices/virtual/input/input5/event3".into(),
);
properties.insert("SUBSYSTEM".into(), "input".into());
properties.insert("USEC_INITIALIZED".into(), "42".into());
properties.insert("ID_INPUT_JOYSTICK".into(), "1".into());
properties.insert("NAME".into(), "\"x\"".into());
properties.insert("TAGS".into(), ":seat:uaccess:".into());
Record {
devpath: "/devices/virtual/input/input5/event3".into(),
db_name: "c13:67".into(),
properties,
}
}
#[test]
fn a_broadcast_has_the_header_libudev_checks() {
let message = encode("add", 7, &record());
assert_eq!(&message[..8], b"libudev\0");
assert_eq!(&message[8..12], &[0xfe, 0xed, 0xca, 0xfe]);
let off = u32::from_ne_bytes(message[16..20].try_into().unwrap()) as usize;
let len = u32::from_ne_bytes(message[20..24].try_into().unwrap()) as usize;
assert_eq!(off + len, message.len());
assert_eq!(&message[24..28], &0xc1a2_8470u32.to_be_bytes());
let props: Vec<&[u8]> = message[off..].split(|&b| b == 0).collect();
for needed in [&b"ACTION=add"[..], b"SEQNUM=7", b"SUBSYSTEM=input"] {
assert!(
props.contains(&needed),
"{}",
String::from_utf8_lossy(needed)
);
}
}
/// The broadcast as real libudev receives it. Needs uid 0 and its own
/// network namespace, both of which `unshare -rn` gives without root, and
/// a libudev monitor listening in that namespace to report what it got.
#[test]
#[ignore = "sends a real udev broadcast; run inside `unshare -rn` beside a monitor"]
fn a_broadcast_for_a_monitor() {
Udev::broadcaster()
.unwrap()
.broadcast("add", &record())
.unwrap();
}
#[test]
fn the_database_holds_the_classification_and_the_tags() {
let entry = record().db_entry();
assert!(entry.starts_with("I:42\n"));
assert!(entry.contains("E:ID_INPUT_JOYSTICK=1\n"));
assert!(!entry.contains("NAME"));
assert!(entry.contains("G:seat\n") && entry.contains("Q:uaccess\n"));
assert!(entry.ends_with("V:1\n"));
}
}