diff --git a/Cargo.lock b/Cargo.lock index 2c01cd4b..e0107ec0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2361,6 +2361,19 @@ dependencies = [ "ureq", ] +[[package]] +name = "nesgamepad" +version = "0.1.0" +dependencies = [ + "anyhow", + "clap", + "libc", + "nesprotocol", + "tokio", + "tracing", + "tracing-subscriber", +] + [[package]] name = "neshub" version = "0.2.0" diff --git a/Cargo.toml b/Cargo.toml index 90b8be6f..e661c596 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -16,6 +16,7 @@ members = [ "apps/nescapture", "apps/nescope", "apps/nesdoctor", + "apps/nesgamepad", "apps/neshub", "apps/nesinit", "apps/neswire", diff --git a/apps/nesgamepad/Cargo.toml b/apps/nesgamepad/Cargo.toml new file mode 100644 index 00000000..fa12ddec --- /dev/null +++ b/apps/nesgamepad/Cargo.toml @@ -0,0 +1,17 @@ +[package] +name = "nesgamepad" +version = "0.1.0" +description = "Turns the controllers plugged into a client into input devices inside the box" +edition.workspace = true +license.workspace = true +repository.workspace = true + +[dependencies] +anyhow.workspace = true +clap.workspace = true +libc.workspace = true +tokio.workspace = true +tracing.workspace = true +tracing-subscriber.workspace = true + +nesprotocol = { path = "../../crates/nesprotocol" } diff --git a/apps/nesgamepad/README.md b/apps/nesgamepad/README.md new file mode 100644 index 00000000..ed76a1ec --- /dev/null +++ b/apps/nesgamepad/README.md @@ -0,0 +1,106 @@ +# nesgamepad + +Turns the controllers plugged into a client into devices inside the box, where +a game finds them the way it finds real hardware. + +[`neshub`](../neshub) forwards the client's messages about its controllers +unread, tagged with which client sent them. The client only describes a +controller -- vendor, product, name, and its whole state as a positional +snapshot on every change, which is all any platform's gamepad API can say -- and +nesgamepad decides what a game finds for it. Rumble a game asks for goes back +the same way. The wire format is in +[`nesprotocol::gamepad`](../../crates/nesprotocol/src/gamepad.rs). + +A box with no controllers connected has no controller devices at all. That +matters: some games stop listening to the keyboard and mouse as soon as one +exists. + +--- + +## What a controller becomes + +Each controller is matched to a template by vendor and product +([`template.rs`](src/template.rs)). One that matches no template, from a vendor +the box knows, gets that vendor's default, so it still reads as its vendor's in +a game; only one the box cannot tell at all is generic. + +| Vendor | Templates | Default | +| --- | --- | --- | +| Sony | DualShock 4 (the original, v2, wireless adapter), DualSense | DualShock 4 | +| Microsoft | Xbox 360 pad, Xbox One S pad | Xbox 360 pad | +| Nintendo | Pro Controller | Pro Controller | +| Anything else | | generic | + +**The DualShock 4 is rebuilt as the device itself.** Some games read a +controller as a HID device and parse its reports by hand, to tell one family +from another and show the right buttons, and Proton hands them the raw device +only when it has a hidraw node. So nesgamepad makes one through `/dev/uhid`, +from the real controller's report descriptor, and writes the reports the real +one would send for the state the client reports, at the rate it sends them. +What a game asks of the device -- calibration, firmware, its address -- is +answered from values read off real hardware, and rumble it writes goes back to +the client. What the snapshot cannot carry, motion and touch, reads as a +controller at rest. See [`replica.rs`](src/replica.rs). + +Beside it goes **a gamepad under a neutral identity**, for games that read only +XInput. Proton gives XInput only to controllers it reads through SDL, and drops +one when a raw device with the same vendor and product exists, so the copy +carries neither -- which also keeps a game that recognises the family by those +numbers from mistaking the copy for the device. A game that can read a +controller both ways uses one at a time, so no press is answered twice. + +**Every other template is one uinput gamepad**, built the way the Linux driver +for it builds its device (hid-playstation, xpad, hid-nintendo), from +[`layout.rs`](src/layout.rs). A game rarely reads a controller by its raw +codes -- SDL, Wine and Steam look the identity up in a mapping database written +against the exact layout that driver produces -- and claiming a real device's +identity with a different layout scrambles its buttons, which is worse than +being unrecognised. So a fallback presents its template's identity, not the +client's, and a generic controller gives up its identity altogether. + +## Standing in for udev + +A box has no udev, and games find controllers through libudev. nesgamepad +writes udev's database entries for its own devices and sends the hotplug +broadcast udev would have sent, for those devices and nothing else. The +details libudev checks, each of which fails silently, are written down in +[`udev.rs`](src/udev.rs). + +It runs as root because libudev ignores a broadcast from anyone else, because it +opens each new device node to the workload, and because `/dev/uhid` is +root's alone. + +## Running it + +```bash +cargo run --release --bin nesgamepad +``` + +| Flag | Env | Default | Description | +| --- | --- | --- | --- | +| `--ipc` | `NESTRI_GAMEPAD_IPC` | `/tmp/nestri-gamepad.sock` | The hub's gamepad socket, which this dials | + +`RUST_LOG` takes a standard tracing filter, e.g. `RUST_LOG=nesgamepad=debug`. +Every state change is logged at `trace`. + +## Testing + +```bash +cargo test -p nesgamepad +``` + +Four more build real devices through `/dev/uinput` -- every template among them +-- and read them back the way a game would, including a rumble upload, and four +build one through `/dev/uhid` -- one of them a rebuilt DualShock 4 -- and check +reports in both directions and a feature report a game asks for. They are +ignored by default because they create a device on whatever machine runs them, +and the uhid ones need root: + +```bash +cargo test -p nesgamepad kernel_tests -- --ignored +``` + +One more sends a real udev broadcast. It needs to be uid 0 in a network +namespace of its own, which `unshare -rn` gives without root, and is only +meaningful with a libudev monitor listening in that namespace to report +whether the broadcast was accepted. diff --git a/apps/nesgamepad/src/kernel_tests.rs b/apps/nesgamepad/src/kernel_tests.rs new file mode 100644 index 00000000..32ce9bc8 --- /dev/null +++ b/apps/nesgamepad/src/kernel_tests.rs @@ -0,0 +1,373 @@ +//! Against the real kernel: build a device through `/dev/uinput` and read it +//! back the way a game would. +//! +//! Ignored by default, because they need write access to `/dev/uinput` and +//! create a real (short-lived) input device on whatever runs them. Run with +//! `cargo test -p nesgamepad -- --ignored` on a machine where that is fine. +//! Nothing here touches udev's database: that is the host's, not ours. + +use std::fs::OpenOptions; +use std::os::fd::AsRawFd; +use std::path::Path; +use std::sync::Arc; +use std::time::{Duration, Instant}; + +use nesprotocol::gamepad::{PadState, button}; + +use crate::layout; +use crate::uinput::code::{self, BUS_USB}; +use crate::uinput::{Device, Request, Spec}; + +fn build(layout: layout::Layout) -> (Device, layout::Layout) { + let keys: Vec = layout.buttons.iter().map(|&(_, key)| key).collect(); + let device = Device::create(&Spec { + name: &layout.name, + bus: layout.bus, + vendor: layout.vendor, + product: layout.product, + version: layout.version, + keys: &keys, + axes: &layout.axes(), + }) + .expect("create a device; is /dev/uinput writable?"); + (device, layout) +} + +/// Open a new device's node, waiting out the host's udev granting access to +/// it, which happens a moment after the node itself appears. +fn open(path: &Path, write: bool) -> std::fs::File { + let deadline = Instant::now() + Duration::from_secs(3); + loop { + match OpenOptions::new().read(true).write(write).open(path) { + Ok(file) => return file, + Err(e) if Instant::now() > deadline => panic!("{}: {e}", path.display()), + Err(_) => std::thread::sleep(Duration::from_millis(20)), + } + } +} + +fn read(path: impl AsRef) -> String { + std::fs::read_to_string(path).unwrap().trim().to_owned() +} + +fn event_node(device: &Device) -> std::path::PathBuf { + let dir = device.syspath(); + let name = std::fs::read_dir(&dir) + .unwrap() + .filter_map(Result::ok) + .map(|e| e.file_name().to_string_lossy().into_owned()) + .find(|n| n.starts_with("event")) + .expect("an event node"); + Path::new("/dev/input").join(name) +} + +#[test] +#[ignore = "creates a real input device through /dev/uinput"] +fn a_hid_playstation_pad_reads_like_the_real_one() { + let (device, _) = build(layout::dualsense()); + let sys = device.syspath(); + // Every capability below was read from a DualShock 4 v2 on USB through + // hid-playstation, which builds the gamepad node of every controller it + // drives with the same function. FF differs on purpose: the real one also + // lists the periodic effects ff-memless emulates, which this does not + // offer. + assert_eq!( + read(sys.join("name")), + "Sony Interactive Entertainment DualSense Wireless Controller" + ); + assert_eq!(read(sys.join("id/bustype")), "0003"); + assert_eq!(read(sys.join("id/vendor")), "054c"); + assert_eq!(read(sys.join("id/product")), "0ce6"); + assert_eq!(read(sys.join("id/version")), "8111"); + assert_eq!(read(sys.join("capabilities/ev")), "20000b"); + assert_eq!( + read(sys.join("capabilities/key")), + "7fdb000000000000 0 0 0 0" + ); + assert_eq!(read(sys.join("capabilities/abs")), "3003f"); + assert_eq!(read(sys.join("capabilities/ff")), "10000 0"); +} + +#[test] +#[ignore = "creates a real input device through /dev/uinput"] +fn every_template_has_its_drivers_capabilities() { + // As each driver's tables add up: xpad's with the d-pad as a hat and the + // triggers as axes, hid-nintendo's for a Pro Controller. + for (layout, key, abs) in [ + (layout::xbox_360(), "7cdb000000000000 0 0 0 0", "3003f"), + (layout::xbox_one(), "7cdb000000000000 0 0 0 0", "3003f"), + (layout::switch_pro(), "7ffb000000000000 0 0 0 0", "3001b"), + ] { + let (device, _) = build(layout.clone()); + let sys = device.syspath(); + assert_eq!(read(sys.join("name")), layout.name); + assert_eq!(read(sys.join("capabilities/key")), key, "{}", layout.name); + assert_eq!(read(sys.join("capabilities/abs")), abs, "{}", layout.name); + } +} + +/// `EVIOCGABS(code)`: an axis's current value and range. +fn abs(fd: i32, code: u16) -> [i32; 6] { + let mut info = [0i32; 6]; + let request = (2u64 << 30) | (24u64 << 16) | ((b'E' as u64) << 8) | (0x40 + code as u64); + assert!(unsafe { libc::ioctl(fd, request as _, info.as_mut_ptr()) } >= 0); + info +} + +#[test] +#[ignore = "creates a real input device through /dev/uinput"] +fn state_written_is_state_a_game_reads() { + let (device, layout) = build(layout::dualsense()); + let node = open(&event_node(&device), false); + let fd = node.as_raw_fd(); + + let resting = abs(fd, code::ABS_X); + assert_eq!(&resting[1..3], &[0, 255]); + + let state = PadState { + left_x: i16::MAX, + right_trigger: u16::MAX, + buttons: button::DPAD_DOWN, + ..PadState::default() + }; + device.write(&layout.events(&state)).unwrap(); + assert_eq!(abs(fd, code::ABS_X)[0], 255); + assert_eq!(abs(fd, code::ABS_RZ)[0], 255); + assert_eq!(abs(fd, code::ABS_HAT0Y)[0], 1); +} + +/// `struct ff_effect` for a rumble, as a game uploads it. +#[repr(C)] +#[allow(dead_code)] +struct Rumble { + kind: u16, + id: i16, + direction: u16, + trigger: [u16; 2], + replay: [u16; 2], + /// The union starts eight-aligned. + gap: u16, + strong: u16, + weak: u16, + pad: [u8; 28], +} + +const _: () = assert!(std::mem::size_of::() == 48); + +#[test] +#[ignore = "creates a real input device through /dev/uinput"] +fn a_game_uploading_rumble_is_answered_and_heard() { + let (device, _) = build(layout::dualsense()); + let node = open(&event_node(&device), true); + + // The upload blocks in the kernel until the device's owner answers it, so + // the game's side runs on a thread of its own, exactly as it would be a + // process of its own. + let game = std::thread::spawn(move || { + let mut effect = Rumble { + kind: code::FF_RUMBLE, + id: -1, + direction: 0, + trigger: [0; 2], + replay: [300, 0], + gap: 0, + strong: 0xc000, + weak: 0x4000, + pad: [0; 28], + }; + const EVIOCSFF: u64 = (1u64 << 30) | (48u64 << 16) | ((b'E' as u64) << 8) | 0x80; + let rc = unsafe { libc::ioctl(node.as_raw_fd(), EVIOCSFF as _, &mut effect) }; + assert!( + rc >= 0, + "upload refused: {}", + std::io::Error::last_os_error() + ); + // Play it once. + let play = crate::uinput::InputEvent::default(); + let mut raw: [u8; 24] = unsafe { std::mem::transmute(play) }; + raw[16..18].copy_from_slice(&code::EV_FF.to_ne_bytes()); + raw[18..20].copy_from_slice(&(effect.id as u16).to_ne_bytes()); + raw[20..24].copy_from_slice(&1i32.to_ne_bytes()); + use std::io::Write; + (&node).write_all(&raw).unwrap(); + effect.id + }); + + let deadline = Instant::now() + Duration::from_secs(5); + let mut seen = Vec::new(); + while Instant::now() < deadline && !seen.iter().any(|r| matches!(r, Request::Play { .. })) { + seen.extend(device.drain().unwrap()); + std::thread::sleep(Duration::from_millis(5)); + } + let id = game.join().unwrap(); + + let uploaded = seen.iter().find_map(|r| match r { + Request::Upload(effect) => Some(*effect), + _ => None, + }); + let uploaded = uploaded.expect("the upload reached the device"); + assert_eq!(uploaded.id, id); + assert_eq!(uploaded.rumble(), Some((0xc000, 0x4000))); + assert_eq!(uploaded.length_ms(), 300); + assert!( + seen.iter() + .any(|r| matches!(r, Request::Play { id: played, on: true } if *played == id)), + "{seen:?}" + ); +} + +/// A small gamepad, written by hand: report 1 is eight buttons and two axes, +/// report 2 a four-byte feature, report 3 a one-byte output. +#[rustfmt::skip] +const GAMEPAD_DESCRIPTOR: &[u8] = &[ + 0x05, 0x01, 0x09, 0x05, 0xa1, 0x01, + 0x85, 0x01, + 0x05, 0x09, 0x19, 0x01, 0x29, 0x08, 0x15, 0x00, 0x25, 0x01, 0x75, 0x01, 0x95, 0x08, 0x81, 0x02, + 0x05, 0x01, 0x09, 0x30, 0x09, 0x31, 0x15, 0x00, 0x26, 0xff, 0x00, 0x75, 0x08, 0x95, 0x02, 0x81, 0x02, + 0x85, 0x02, + 0x06, 0x00, 0xff, 0x09, 0x01, 0x15, 0x00, 0x26, 0xff, 0x00, 0x75, 0x08, 0x95, 0x04, 0xb1, 0x02, + 0x85, 0x03, + 0x09, 0x02, 0x75, 0x08, 0x95, 0x01, 0x91, 0x02, + 0xc0, +]; + +/// The device, started and with its hidraw node found. +fn build_hid() -> (Arc, std::path::PathBuf) { + use crate::uhid::{Device, Event, Spec}; + let device = Arc::new( + Device::create(&Spec { + name: "nesgamepad test pad", + uniq: "test", + bus: BUS_USB, + vendor: 0x1234, + product: 0x5678, + version: 0x0100, + country: 0, + descriptor: GAMEPAD_DESCRIPTOR, + }) + .expect("create a HID device; this needs root for /dev/uhid"), + ); + let deadline = Instant::now() + Duration::from_secs(3); + while !device.drain().unwrap().contains(&Event::Start) { + assert!(Instant::now() < deadline, "the kernel never started it"); + std::thread::sleep(Duration::from_millis(5)); + } + loop { + if let Some((_, nodes)) = + crate::pads::discover(BUS_USB, 0x1234, 0x5678, &Default::default()) + { + let name = nodes.hidraw[0].file_name().unwrap().to_owned(); + return (device, Path::new("/dev").join(name)); + } + assert!(Instant::now() < deadline, "no hidraw node appeared"); + std::thread::sleep(Duration::from_millis(5)); + } +} + +#[test] +#[ignore = "needs root: creates a device through /dev/uhid"] +fn a_report_written_is_the_report_a_game_reads() { + use std::io::Read; + let (device, node) = build_hid(); + let mut hidraw = open(&node, false); + device.input(&[0x01, 0b1000_0001, 0x10, 0xf0]).unwrap(); + let mut buf = [0u8; 64]; + let n = hidraw.read(&mut buf).unwrap(); + assert_eq!(&buf[..n], &[0x01, 0b1000_0001, 0x10, 0xf0]); +} + +#[test] +#[ignore = "needs root: creates a device through /dev/uhid"] +fn a_game_reading_a_feature_report_gets_the_answer_given() { + use crate::uhid::Event; + let (device, node) = build_hid(); + let hidraw = open(&node, true); + let game = std::thread::spawn(move || { + // HIDIOCGFEATURE(5): report id in, report out. + let mut buf = [0x02u8, 0, 0, 0, 0]; + let request = (3u64 << 30) | (5u64 << 16) | ((b'H' as u64) << 8) | 0x07; + let n = unsafe { libc::ioctl(hidraw.as_raw_fd(), request as _, buf.as_mut_ptr()) }; + assert!(n >= 0, "{}", std::io::Error::last_os_error()); + buf + }); + let deadline = Instant::now() + Duration::from_secs(3); + loop { + let events = device.drain().unwrap(); + if let Some(Event::GetReport { id, number, kind }) = events + .into_iter() + .find(|e| matches!(e, Event::GetReport { .. })) + { + assert_eq!((number, kind), (0x02, crate::uhid::REPORT_FEATURE)); + device + .get_report_reply(id, 0, &[0x02, 0xde, 0xad, 0xbe, 0xef]) + .unwrap(); + break; + } + assert!(Instant::now() < deadline, "the request never arrived"); + std::thread::sleep(Duration::from_millis(5)); + } + assert_eq!(game.join().unwrap(), [0x02, 0xde, 0xad, 0xbe, 0xef]); +} + +#[test] +#[ignore = "needs root: creates a device through /dev/uhid"] +fn a_report_a_game_writes_comes_out_of_the_device() { + use crate::uhid::Event; + use std::io::Write; + let (device, node) = build_hid(); + let mut hidraw = open(&node, true); + hidraw.write_all(&[0x03, 0xaa]).unwrap(); + let deadline = Instant::now() + Duration::from_secs(3); + loop { + let events = device.drain().unwrap(); + if let Some(Event::Output { kind, data }) = events + .into_iter() + .find(|e| matches!(e, Event::Output { .. })) + { + assert_eq!(kind, crate::uhid::REPORT_OUTPUT); + assert_eq!(data, [0x03, 0xaa]); + return; + } + assert!(Instant::now() < deadline, "the output never arrived"); + std::thread::sleep(Duration::from_millis(5)); + } +} + +#[test] +#[ignore = "needs root: creates a device through /dev/uhid"] +fn a_rebuilt_dualshock_4_is_taken_by_the_kernel_and_read_as_sent() { + use crate::replica::{Model, Reporter}; + use crate::uhid::{Device, Event}; + use std::io::Read; + let model = Model::DualShock4; + let device = Device::create(&model.spec(0x09cc, "02:00:00:00:00:01")) + .expect("create a HID device; this needs root for /dev/uhid"); + let deadline = Instant::now() + Duration::from_secs(3); + while !device.drain().unwrap().contains(&Event::Start) { + assert!( + Instant::now() < deadline, + "the kernel refused the descriptor" + ); + std::thread::sleep(Duration::from_millis(5)); + } + let node = loop { + if let Some((_, nodes)) = + crate::pads::discover(BUS_USB, 0x054c, 0x09cc, &Default::default()) + { + break Path::new("/dev").join(nodes.hidraw[0].file_name().unwrap()); + } + assert!(Instant::now() < deadline, "no hidraw node appeared"); + std::thread::sleep(Duration::from_millis(5)); + }; + let mut hidraw = open(&node, false); + let mut reporter = Reporter::new(model); + reporter.set(PadState { + buttons: button::SOUTH, + ..PadState::default() + }); + let report = reporter.next(); + device.input(&report).unwrap(); + let mut buf = [0u8; 128]; + let n = hidraw.read(&mut buf).unwrap(); + assert_eq!(&buf[..n], &report[..]); +} diff --git a/apps/nesgamepad/src/layout.rs b/apps/nesgamepad/src/layout.rs new file mode 100644 index 00000000..d65721ae --- /dev/null +++ b/apps/nesgamepad/src/layout.rs @@ -0,0 +1,418 @@ +//! What a controller looks like to a game as an evdev device: its identity and +//! its layout. +//! +//! # Why the layout follows the identity +//! +//! A game rarely reads a controller's buttons by code. SDL, Wine and Steam each +//! build an id from the device's bus, vendor, product and version, look it up +//! in a mapping database, and read the device through that mapping -- which +//! was written against the exact set of axes and buttons the *Linux driver* +//! for that device exposes, in the order the driver exposes them. A device +//! claiming a real controller's identity with any other layout gets its +//! buttons scrambled, which is worse than being unrecognised. +//! +//! So each template here is built the way the driver for its identity builds +//! it, read from that driver's source, or it is [`generic`]: the kernel's own +//! gamepad layout under a neutral identity, which every mapping layer knows how +//! to read without a database entry. Which template a controller gets is +//! `crate::template`'s to decide. + +use nesprotocol::gamepad::button; +use nesprotocol::gamepad::{PadIdentity, PadState}; + +use crate::uinput::code::{self, BUS_USB, BUS_VIRTUAL}; +use crate::uinput::{AbsAxis, InputEvent}; + +/// A controller as the box will present it. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Layout { + pub name: String, + pub bus: u16, + pub vendor: u16, + pub product: u16, + pub version: u16, + /// Wire button bit, and the key code it becomes. A bit of zero is a key + /// the real device has and the wire cannot carry: registered, because a + /// mapping counts the device's keys to number them, and never pressed. + pub buttons: &'static [(u32, u16)], + /// Left x, left y, right x, right y. + pub sticks: [AbsAxis; 4], + /// Left, right. `None` for a controller whose triggers are only buttons. + pub triggers: Option<[AbsAxis; 2]>, + /// Which driver this imitates, for the log. + pub driver: &'static str, +} + +/// The kernel's generic gamepad buttons, by position. +/// +/// Also exactly what hid-playstation registers, which is why its template +/// shares it. The d-pad is not here: every layout below reports it as a hat. +const GAMEPAD_BUTTONS: &[(u32, u16)] = &[ + (button::SOUTH, code::BTN_SOUTH), + (button::EAST, code::BTN_EAST), + (button::NORTH, code::BTN_NORTH), + (button::WEST, code::BTN_WEST), + (button::LEFT_SHOULDER, code::BTN_TL), + (button::RIGHT_SHOULDER, code::BTN_TR), + (button::LEFT_TRIGGER, code::BTN_TL2), + (button::RIGHT_TRIGGER, code::BTN_TR2), + (button::SELECT, code::BTN_SELECT), + (button::START, code::BTN_START), + (button::MODE, code::BTN_MODE), + (button::LEFT_STICK, code::BTN_THUMBL), + (button::RIGHT_STICK, code::BTN_THUMBR), +]; + +/// What xpad registers for the 360 and One pads it drives with the d-pad as +/// a hat and the triggers as axes. +/// +/// xpad names its face buttons by letter, not position: the left one, X, is +/// reported as `BTN_X`, which is the same code as `BTN_NORTH`, and the top +/// one, Y, as `BTN_Y`, the code of `BTN_WEST`. Mappings written against xpad +/// expect exactly that, so it is kept. +const XPAD_BUTTONS: &[(u32, u16)] = &[ + (button::SOUTH, code::BTN_SOUTH), + (button::EAST, code::BTN_EAST), + (button::WEST, code::BTN_X), + (button::NORTH, code::BTN_Y), + (button::LEFT_SHOULDER, code::BTN_TL), + (button::RIGHT_SHOULDER, code::BTN_TR), + (button::SELECT, code::BTN_SELECT), + (button::START, code::BTN_START), + (button::MODE, code::BTN_MODE), + (button::LEFT_STICK, code::BTN_THUMBL), + (button::RIGHT_STICK, code::BTN_THUMBR), +]; + +/// What hid-nintendo registers for a Pro Controller: positional, with ZL and +/// ZR as buttons only, and Capture, which the wire does not carry. +const PRO_CONTROLLER_BUTTONS: &[(u32, u16)] = &[ + (button::SOUTH, code::BTN_SOUTH), + (button::EAST, code::BTN_EAST), + (button::NORTH, code::BTN_NORTH), + (button::WEST, code::BTN_WEST), + (button::LEFT_SHOULDER, code::BTN_TL), + (button::RIGHT_SHOULDER, code::BTN_TR), + (button::LEFT_TRIGGER, code::BTN_TL2), + (button::RIGHT_TRIGGER, code::BTN_TR2), + (button::SELECT, code::BTN_SELECT), + (button::START, code::BTN_START), + (button::MODE, code::BTN_MODE), + (button::LEFT_STICK, code::BTN_THUMBL), + (button::RIGHT_STICK, code::BTN_THUMBR), + (0, code::BTN_Z), +]; + +pub const SONY: u16 = 0x054c; +pub const MICROSOFT: u16 = 0x045e; +pub const NINTENDO: u16 = 0x057e; + +fn axis(code: u16, min: i32, max: i32, fuzz: i32, flat: i32) -> AbsAxis { + AbsAxis { + code, + min, + max, + fuzz, + flat, + } +} + +fn sticks(min: i32, max: i32, fuzz: i32, flat: i32) -> [AbsAxis; 4] { + [code::ABS_X, code::ABS_Y, code::ABS_RX, code::ABS_RY].map(|c| axis(c, min, max, fuzz, flat)) +} + +fn triggers(max: i32) -> Option<[AbsAxis; 2]> { + Some([code::ABS_Z, code::ABS_RZ].map(|c| axis(c, 0, max, 0, 0))) +} + +/// A DualSense on a cable, as hid-playstation makes it. +/// +/// One byte per stick and trigger, no fuzz, no flat, as the driver's +/// `ps_gamepad_create` sets them for every controller it drives. The version +/// is the driver's patch bit, which tells userspace the mapping is the +/// driver's and not hid-generic's, on the HID version; read from a +/// DualShock 4 v2 under the driver, as is the rest. +pub fn dualsense() -> Layout { + Layout { + name: "Sony Interactive Entertainment DualSense Wireless Controller".into(), + bus: BUS_USB, + vendor: SONY, + product: 0x0ce6, + version: 0x8111, + buttons: GAMEPAD_BUTTONS, + sticks: sticks(0, 255, 0, 0), + triggers: triggers(255), + driver: "hid-playstation", + } +} + +/// A wired Xbox 360 pad, as xpad makes it. The version is the device's own +/// release number, which xpad passes on. +pub fn xbox_360() -> Layout { + Layout { + name: "Microsoft X-Box 360 pad".into(), + bus: BUS_USB, + vendor: MICROSOFT, + product: 0x028e, + version: 0x0114, + buttons: XPAD_BUTTONS, + sticks: sticks(-32768, 32767, 16, 128), + triggers: triggers(255), + driver: "xpad", + } +} + +/// An Xbox One S pad on a cable, as xpad makes it: the 360's layout, with ten +/// bits of trigger. +pub fn xbox_one() -> Layout { + Layout { + name: "Microsoft X-Box One S pad".into(), + bus: BUS_USB, + vendor: MICROSOFT, + product: 0x02ea, + version: 0x0408, + buttons: XPAD_BUTTONS, + sticks: sticks(-32768, 32767, 16, 128), + triggers: triggers(1023), + driver: "xpad", + } +} + +/// A Pro Controller on a cable, as hid-nintendo makes it. The name is what +/// the device calls itself over USB, manufacturer first, which the driver +/// passes on; the version is the HID version, which it passes on too. +pub fn switch_pro() -> Layout { + Layout { + name: "Nintendo Co., Ltd. Pro Controller".into(), + bus: BUS_USB, + vendor: NINTENDO, + product: 0x2009, + version: 0x0111, + buttons: PRO_CONTROLLER_BUTTONS, + sticks: sticks(-32767, 32767, 250, 500), + triggers: None, + driver: "hid-nintendo", + } +} + +/// A controller the box cannot tell. +/// +/// The identity is dropped on purpose (see the module docs): vendor and +/// product zero on the virtual bus, so no mapping database can match it to a +/// real device with a different layout. The name stays, because that is what a +/// person sees in a game's settings. +pub fn generic(identity: &PadIdentity) -> Layout { + let name = if identity.name.trim().is_empty() { + "Gamepad".to_owned() + } else { + identity.name.clone() + }; + Layout { + name, + bus: BUS_VIRTUAL, + vendor: 0, + product: 0, + version: 0, + buttons: GAMEPAD_BUTTONS, + sticks: sticks(-32768, 32767, 16, 128), + triggers: triggers(1023), + driver: "generic", + } +} + +/// Map a full-range wire value onto an axis's own range. +fn scale(axis: &AbsAxis, from_min: i64, from_max: i64, value: i64) -> i32 { + let span = i64::from(axis.max) - i64::from(axis.min); + let offset = (value - from_min) * span / (from_max - from_min); + (i64::from(axis.min) + offset) as i32 +} + +impl Layout { + /// Every event that makes the device read as `state`, ending in a report. + /// + /// All of them every time: the kernel drops a value that did not change + /// before any reader sees it, so repeating one costs nothing downstream, + /// and a device built from a snapshot can never drift from it. + pub fn events(&self, state: &PadState) -> Vec { + let mut events = Vec::with_capacity(self.buttons.len() + 9); + for &(bit, key) in self.buttons { + events.push(InputEvent::key(key, state.buttons & bit != 0)); + } + let sticks = [state.left_x, state.left_y, state.right_x, state.right_y]; + for (axis, value) in self.sticks.iter().zip(sticks) { + let value = scale(axis, i16::MIN.into(), i16::MAX.into(), value.into()); + events.push(InputEvent::abs(axis.code, value)); + } + for (axis, value) in self + .triggers + .iter() + .flatten() + .zip([state.left_trigger, state.right_trigger]) + { + let value = scale(axis, 0, u16::MAX.into(), value.into()); + events.push(InputEvent::abs(axis.code, value)); + } + let held = |bit| i32::from(state.buttons & bit != 0); + events.push(InputEvent::abs( + code::ABS_HAT0X, + held(button::DPAD_RIGHT) - held(button::DPAD_LEFT), + )); + events.push(InputEvent::abs( + code::ABS_HAT0Y, + held(button::DPAD_DOWN) - held(button::DPAD_UP), + )); + events.push(InputEvent::report()); + events + } + + /// Every axis the device has, with its range. + pub fn axes(&self) -> Vec { + let hat = |code| AbsAxis { + code, + min: -1, + max: 1, + fuzz: 0, + flat: 0, + }; + let mut axes: Vec = self + .sticks + .iter() + .chain(self.triggers.iter().flatten()) + .copied() + .collect(); + axes.push(hat(code::ABS_HAT0X)); + axes.push(hat(code::ABS_HAT0Y)); + axes + } + + /// `ID_BUS` as udev writes it, where udev writes one at all. + pub fn udev_bus(&self) -> Option<&'static str> { + match self.bus { + BUS_USB => Some("usb"), + _ => None, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn value(events: &[InputEvent], code: u16) -> i32 { + events + .iter() + .find(|e| e.code == code && e.kind != code::EV_SYN) + .map(|e| e.value) + .unwrap() + } + + fn all() -> Vec { + vec![ + dualsense(), + xbox_360(), + xbox_one(), + switch_pro(), + generic(&PadIdentity { + vendor: 1, + product: 2, + name: String::new(), + }), + ] + } + + /// The key capability as sysfs prints its first word: bit n is key + /// `0x130 + n`. + fn key_bits(layout: &Layout) -> u64 { + layout + .buttons + .iter() + .fold(0, |bits, &(_, key)| bits | 1 << (key - 0x130)) + } + + #[test] + fn each_template_has_the_keys_its_driver_registers() { + // hid-playstation's read from a DualShock 4 under the driver, which + // registers the same for every controller it drives; the others as + // their drivers' tables add up. + assert_eq!(key_bits(&dualsense()), 0x7fdb); + assert_eq!(key_bits(&xbox_360()), 0x7cdb); + assert_eq!(key_bits(&xbox_one()), 0x7cdb); + assert_eq!(key_bits(&switch_pro()), 0x7ffb); + } + + #[test] + fn xpad_puts_the_left_face_button_on_btn_x() { + let events = xbox_360().events(&PadState { + buttons: button::WEST, + ..PadState::default() + }); + assert_eq!(value(&events, code::BTN_X), 1); + assert_eq!(value(&events, code::BTN_Y), 0); + } + + #[test] + fn a_key_the_wire_cannot_carry_is_never_pressed() { + let events = switch_pro().events(&PadState { + buttons: u32::MAX, + ..PadState::default() + }); + assert_eq!(value(&events, code::BTN_Z), 0); + assert_eq!(value(&events, code::BTN_TL2), 1); + } + + #[test] + fn a_controller_without_analog_triggers_has_no_trigger_axes() { + let codes: Vec = switch_pro().axes().iter().map(|a| a.code).collect(); + assert!(!codes.contains(&code::ABS_Z) && !codes.contains(&code::ABS_RZ)); + } + + #[test] + fn a_resting_controller_rests_on_every_layout() { + for layout in all() { + let events = layout.events(&PadState::default()); + for axis in &layout.sticks { + let v = value(&events, axis.code); + let mid = (axis.min + axis.max) / 2; + assert!( + (v - mid).abs() <= 1, + "{} stick {} at {v}", + layout.driver, + axis.code + ); + } + for axis in layout.triggers.iter().flatten() { + assert_eq!(value(&events, axis.code), axis.min); + } + assert_eq!(value(&events, code::ABS_HAT0X), 0); + assert!(events.last().unwrap().kind == code::EV_SYN); + } + } + + #[test] + fn full_deflection_reaches_both_ends_of_every_range() { + for layout in all() { + let pushed = PadState { + left_x: i16::MIN, + left_y: i16::MAX, + right_trigger: u16::MAX, + buttons: button::DPAD_UP | button::DPAD_LEFT | button::EAST, + ..PadState::default() + }; + let events = layout.events(&pushed); + let [x, y, ..] = layout.sticks; + assert!( + (value(&events, x.code) - x.min).abs() <= 1, + "{}", + layout.driver + ); + assert_eq!(value(&events, y.code), y.max, "{}", layout.driver); + if let Some([_, rz]) = layout.triggers { + assert_eq!(value(&events, rz.code), rz.max); + } + assert_eq!(value(&events, code::ABS_HAT0X), -1); + assert_eq!(value(&events, code::ABS_HAT0Y), -1); + assert_eq!(value(&events, code::BTN_EAST), 1); + assert_eq!(value(&events, code::BTN_SOUTH), 0); + } + } +} diff --git a/apps/nesgamepad/src/main.rs b/apps/nesgamepad/src/main.rs new file mode 100644 index 00000000..5e5aa7d4 --- /dev/null +++ b/apps/nesgamepad/src/main.rs @@ -0,0 +1,168 @@ +//! nesgamepad: the controllers plugged into a client, as devices in the box. +//! +//! The hub forwards what each client says about its controllers, tagged with +//! which client, and this makes virtual devices for each one that a game finds +//! the way it finds real hardware -- the controller itself where it can be +//! rebuilt, a gamepad otherwise -- announced through libudev. Rumble a game +//! asks for goes back the same way. +//! +//! Idle until the first controller arrives. A box with none connected has no +//! devices at all, which matters: some games stop listening to the keyboard +//! and mouse the moment a controller exists. +//! +//! Runs as root, for three things nothing else can grant: creating devices +//! through `/dev/uinput` and `/dev/uhid`, opening their nodes to the +//! workload, and sending the udev broadcast, whose receivers drop anything not +//! sent by uid 0. + +#[cfg(test)] +mod kernel_tests; +mod layout; +mod pads; +mod replica; +mod template; +mod udev; +mod uhid; +mod uinput; + +use std::path::PathBuf; +use std::time::Duration; + +use anyhow::Result; +use clap::Parser; +use nesprotocol::gamepad::{PadMessage, decode_ipc, encode_ipc}; +use tokio::io::{AsyncReadExt, AsyncWriteExt}; +use tokio::net::UnixStream; +use tracing::{debug, info, warn}; + +use crate::pads::{Feedback, HidNotice, Key, Pads}; + +#[derive(Parser, Debug)] +#[command(name = "nesgamepad")] +struct Args { + /// The hub's gamepad socket. The hub listens; this dials. + #[arg( + long, + env = "NESTRI_GAMEPAD_IPC", + default_value = "/tmp/nestri-gamepad.sock" + )] + ipc: PathBuf, +} + +/// How long to wait before dialling the hub again. +const REDIAL: Duration = Duration::from_secs(1); + +#[tokio::main(flavor = "current_thread")] +async fn main() -> Result<()> { + tracing_subscriber::fmt() + .with_env_filter( + tracing_subscriber::EnvFilter::try_from_default_env().unwrap_or_else(|_| "info".into()), + ) + .init(); + let args = Args::parse(); + + // Without it the devices still exist, and nothing that finds devices + // through libudev can see them -- which is almost everything. Said loudly + // and carried on with, because a box that at least makes the device is + // easier to diagnose than one that refused to start. + let udev = match udev::Udev::new() { + Ok(udev) => Some(udev), + Err(e) => { + warn!("cannot stand in for udev, so games will not find controllers: {e}"); + None + } + }; + + let (feedback_tx, mut feedback_rx) = tokio::sync::mpsc::unbounded_channel::(); + let (hid_tx, mut hid_rx) = tokio::sync::mpsc::unbounded_channel::<(Key, HidNotice)>(); + let mut pads = Pads::new(udev, feedback_tx, hid_tx); + info!("waiting for the hub on {}", args.ipc.display()); + + let mut failures = 0u32; + loop { + let stream = match UnixStream::connect(&args.ipc).await { + Ok(stream) => stream, + Err(e) => { + // Once at warn, and after that at debug: the hub starting a + // little later than this is normal, and a line a second while + // it does is noise. + if failures == 0 { + warn!("hub not reachable at {} yet: {e}", args.ipc.display()); + } else { + debug!("hub still not reachable: {e}"); + } + failures += 1; + tokio::time::sleep(REDIAL).await; + continue; + } + }; + failures = 0; + info!("connected to the hub"); + if let Err(e) = serve(stream, &mut pads, &mut feedback_rx, &mut hid_rx).await { + debug!("hub connection ended: {e}"); + } + // Every controller belonged to a client of that hub, and the hub has + // gone -- so have they. Leaving them would leave a game holding + // controllers that nobody is on the other end of. + pads.clear(); + // Feedback queued for clients that no longer exist, and notices from + // devices that no longer do. + while feedback_rx.try_recv().is_ok() {} + while hid_rx.try_recv().is_ok() {} + info!("lost the hub; every controller unplugged"); + tokio::time::sleep(REDIAL).await; + } +} + +async fn serve( + stream: UnixStream, + pads: &mut Pads, + feedback: &mut tokio::sync::mpsc::UnboundedReceiver, + hid: &mut tokio::sync::mpsc::UnboundedReceiver<(Key, HidNotice)>, +) -> Result<()> { + let (mut read, mut write) = stream.into_split(); + // Reading on a task of its own: a read of a frame is several awaits, and + // one cancelled halfway by rumble arriving would lose the framing for + // good. + let (tx, mut messages) = tokio::sync::mpsc::unbounded_channel::<(u32, PadMessage)>(); + let reader = tokio::spawn(async move { + let mut len_buf = [0u8; 2]; + loop { + read.read_exact(&mut len_buf).await?; + let mut body = vec![0u8; u16::from_le_bytes(len_buf) as usize]; + read.read_exact(&mut body).await?; + let Some((session, message)) = decode_ipc(&body) else { + debug!("short frame from the hub"); + continue; + }; + match PadMessage::decode(message) { + Some(message) => { + if tx.send((session, message)).is_err() { + return Ok::<_, std::io::Error>(()); + } + } + None => debug!(session, "a gamepad message this does not understand"), + } + } + }); + let result = loop { + tokio::select! { + message = messages.recv() => match message { + Some((session, message)) => pads.handle(session, message), + None => break Ok(()), + }, + Some((key, notice)) = hid.recv() => pads.hid_notice(key, notice), + Some((session, message)) = feedback.recv() => { + let mut body = Vec::with_capacity(16); + message.encode(&mut body); + let mut frame = Vec::with_capacity(6 + body.len()); + encode_ipc(&mut frame, session, &body); + if let Err(e) = write.write_all(&frame).await { + break Err(e.into()); + } + } + } + }; + reader.abort(); + result +} diff --git a/apps/nesgamepad/src/pads.rs b/apps/nesgamepad/src/pads.rs new file mode 100644 index 00000000..aa602ad1 --- /dev/null +++ b/apps/nesgamepad/src/pads.rs @@ -0,0 +1,920 @@ +//! Every controller plugged into the box, by the client and slot it came from. +//! +//! The client only ever describes a controller; what a game finds for it is +//! decided here, in one of two ways: +//! +//! - **A family the box can rebuild** (see `crate::replica`) becomes the device +//! itself, through uhid, for games that read the device and parse its +//! reports. Beside it goes a gamepad under a neutral identity, for games +//! that read only XInput: Proton gives XInput only to controllers it reads +//! through SDL, and drops such a controller when a raw device with the same +//! vendor and product exists, so the copy survives only without them -- and +//! without them, no game that recognises the family by those numbers +//! mistakes the copy for the device. Games that offer both ways of reading a +//! controller use one at a time, so a press is never answered twice. +//! - **Anything else** becomes one uinput gamepad, laid out the way the Linux +//! driver for its identity would lay it out (see `crate::layout`). + +use std::collections::{HashMap, HashSet}; +use std::os::fd::{AsRawFd, RawFd}; +use std::path::{Path, PathBuf}; +use std::sync::{Arc, Mutex}; +use std::time::Duration; + +use nesprotocol::gamepad::{PadFeedback, PadIdentity, PadMessage, PadState}; +use tokio::io::unix::AsyncFd; +use tokio::sync::mpsc::UnboundedSender; +use tracing::{debug, info, trace, warn}; + +use crate::layout::{self, Layout}; +use crate::replica::{self, Model, Reporter}; +use crate::template::{self, Template}; +use crate::udev::{Record, Udev}; +use crate::uhid; +use crate::uinput::{Device, Request, Spec, code}; + +/// Controllers across every client at once. +/// +/// Not a limit anyone should meet: well past what any game expects to see, and +/// there so that one that has misbehaved cannot fill the box with devices. +pub const MAX_PADS: usize = 16; + +/// A client's slot. The session is the hub's number for the client. +pub type Key = (u32, u8); + +/// Feedback for one client. +pub type Feedback = (u32, PadFeedback); + +/// Something a uhid device's own task has for the manager. +#[derive(Debug)] +pub enum HidNotice { + Kernel(uhid::Event), + /// Look for the device's nodes again. They appear after the kernel says + /// the device started, not with it. + Discover { + attempt: u32, + }, +} + +/// How often, and for how long, to look for a started device's nodes. +const DISCOVER_EVERY: Duration = Duration::from_millis(20); +const DISCOVER_ATTEMPTS: u32 = 50; + +/// A uinput device. +struct Gamepad { + device: Arc, + layout: Layout, + /// The devices announced for it, parents first. + records: Vec, + /// Reads the device for rumble. Stopped before the device goes. + task: tokio::task::JoinHandle<()>, +} + +/// A device rebuilt as itself. +struct Replica { + model: Model, + vendor: u16, + product: u16, + device: Arc, + address: [u8; 6], + reporter: Arc>, + /// The kernel's directory for it, once found. + syspath: Option, + records: Vec, + /// Hears the kernel side, and keeps the reports coming. Stopped before + /// the device goes. + tasks: [tokio::task::JoinHandle<()>; 2], + /// The rumble last passed on. Games write the report that carries it for + /// the lights as well, often, and only a change is worth sending. + rumble: (u16, u16), +} + +struct Pad { + identity: PadIdentity, + replica: Option, + gamepad: Option, + /// Whether any input has arrived for it yet. + /// + /// Said once, at info: from a log alone, a controller that is plugged in + /// and never moves is otherwise indistinguishable from one whose input + /// reaches a device no game is reading. + moved: bool, +} + +pub struct Pads { + udev: Option, + pads: HashMap, + /// Slots already asked to re-announce, so input arriving for one the box + /// does not have asks once rather than once per report. + asked: HashSet, + feedback: UnboundedSender, + hid: UnboundedSender<(Key, HidNotice)>, +} + +impl Pads { + pub fn new( + udev: Option, + feedback: UnboundedSender, + hid: UnboundedSender<(Key, HidNotice)>, + ) -> Self { + Self { + udev, + pads: HashMap::new(), + asked: HashSet::new(), + feedback, + hid, + } + } + + pub fn handle(&mut self, session: u32, message: PadMessage) { + match message { + PadMessage::Connect { slot, identity } => self.connect((session, slot), identity), + PadMessage::State { slot, state } => self.state((session, slot), state), + PadMessage::Disconnect { slot } => self.remove((session, slot)), + PadMessage::SessionEnd => self.end_session(session), + } + } + + fn state(&mut self, key: Key, state: PadState) { + let (session, slot) = key; + let Some(pad) = self.present(key) else { return }; + trace!(session, slot, ?state, "state"); + if let Some(replica) = &pad.replica { + // Now rather than at the next tick of its clock, which would add + // up to a tick of latency to every press. + let report = { + let mut reporter = replica.reporter.lock().unwrap_or_else(|e| e.into_inner()); + reporter.set(state); + reporter.next() + }; + if let Err(e) = replica.device.input(&report) { + // Per state, so debug: a device that stopped taking writes + // fails every one of them. + debug!(session, slot, "could not write a report: {e}"); + } + } + if let Some(gamepad) = &pad.gamepad + && let Err(e) = gamepad.device.write(&gamepad.layout.events(&state)) + { + debug!(session, slot, "could not write to the device: {e}"); + } + } + + /// The pad in `key`, noting its first input, or asking the client to + /// announce it when there is none. + fn present(&mut self, key: Key) -> Option<&mut Pad> { + let (session, slot) = key; + if !self.pads.contains_key(&key) { + if self.asked.insert(key) { + debug!( + session, + slot, "input for a controller not here; asking for it" + ); + let _ = self + .feedback + .send((session, PadFeedback::Announce { slot })); + } + return None; + } + let pad = self.pads.get_mut(&key)?; + if !pad.moved { + pad.moved = true; + info!(session, slot, "first input from the controller"); + } + Some(pad) + } + + fn connect(&mut self, key: Key, identity: PadIdentity) { + let (session, slot) = key; + if self + .pads + .get(&key) + .is_some_and(|pad| pad.identity == identity) + { + // A re-announce of what is already here, which a client does + // after asking and is harmless. + return; + } + self.asked.remove(&key); + self.remove(key); + if self.pads.len() >= MAX_PADS { + warn!( + session, + slot, + "{MAX_PADS} controllers are already plugged in; not adding \"{}\"", + identity.name + ); + return; + } + info!( + session, + slot, + name = identity.name, + id = format!("{:04x}:{:04x}", identity.vendor, identity.product), + "controller connected" + ); + let mut pad = Pad { + identity, + replica: None, + gamepad: None, + moved: false, + }; + let template = template::for_identity(&pad.identity); + debug!( + session, + slot, + template = template.describe(), + "template chosen" + ); + match template { + // Its gamepad follows once its own nodes exist, so that a game + // settling on the first controller it finds finds the device. + Template::Replica { model, product } => match self.rebuild(key, model, product) { + Ok(replica) => pad.replica = Some(replica), + Err(e) => { + warn!( + session, + slot, + "could not rebuild \"{}\" as itself, so it goes as a gamepad alone: {e}", + pad.identity.name + ); + pad.gamepad = self.plug(key, layout::generic(&pad.identity)); + } + }, + Template::Gamepad(layout) => pad.gamepad = self.plug(key, layout), + } + self.pads.insert(key, pad); + } + + fn rebuild(&self, key: Key, model: Model, product: u16) -> std::io::Result { + let (session, slot) = key; + let address = replica::address(session, slot); + let uniq = replica::uniq(address); + let spec = model.spec(product, &uniq); + let device = Arc::new(uhid::Device::create(&spec)?); + info!( + session, + slot, + model = ?model, + device = format!("{} {:04x}:{:04x}", spec.name, spec.vendor, spec.product), + "controller plugged in as itself" + ); + let reporter = Arc::new(Mutex::new(Reporter::new(model))); + let tasks = [ + spawn_hid(device.clone(), key, self.hid.clone()), + spawn_clock(device.clone(), reporter.clone(), model.report_every()), + ]; + Ok(Replica { + model, + vendor: spec.vendor, + product: spec.product, + device, + address, + reporter, + syspath: None, + records: Vec::new(), + tasks, + rumble: (0, 0), + }) + } + + /// A uinput device for `key`, or none when it cannot be made -- said, and + /// the controller left without it. + fn plug(&self, key: Key, layout: Layout) -> Option { + let (session, slot) = key; + match self.make_gamepad(key, &layout) { + Ok((device, records)) => { + info!( + session, + slot, + driver = layout.driver, + device = format!( + "{} {:04x}:{:04x}:{:04x}", + layout.name, layout.vendor, layout.product, layout.version + ), + node = records + .last() + .and_then(|r| r.properties.get("DEVNAME")) + .map(String::as_str) + .unwrap_or("none"), + "controller plugged in as a gamepad" + ); + let task = spawn_rumble(device.clone(), session, slot, self.feedback.clone()); + Some(Gamepad { + device, + layout, + records, + task, + }) + } + Err(e) => { + warn!( + session, + slot, "could not create a gamepad for \"{}\": {e:#}", layout.name + ); + None + } + } + } + + fn make_gamepad( + &self, + key: Key, + layout: &Layout, + ) -> anyhow::Result<(Arc, Vec)> { + let keys: Vec = layout.buttons.iter().map(|&(_, key)| key).collect(); + let device = Device::create(&Spec { + name: &layout.name, + bus: layout.bus, + vendor: layout.vendor, + product: layout.product, + version: layout.version, + keys: &keys, + axes: &layout.axes(), + })?; + let syspath = device.syspath(); + let event = event_node(&syspath)?; + open_up(&Path::new("/dev/input").join(event.file_name().unwrap())); + + let extra = classification(layout, key); + let records = vec![ + Record::read(&syspath, "input", &extra)?, + Record::read(&event, "input", &extra)?, + ]; + self.announce(&records)?; + Ok((Arc::new(device), records)) + } + + fn announce(&self, records: &[Record]) -> std::io::Result<()> { + match &self.udev { + Some(udev) => { + for record in records { + udev.add(record)?; + } + } + None => debug!("no udev stand-in; the device exists but nothing is told"), + } + Ok(()) + } + + /// Something a uhid device's task passed on. + pub fn hid_notice(&mut self, key: Key, notice: HidNotice) { + let (session, slot) = key; + let Some(replica) = self.pads.get(&key).and_then(|p| p.replica.as_ref()) else { + // Unplugged since the task said it; nothing is waiting on it. + return; + }; + let (model, address, device, reporter) = ( + replica.model, + replica.address, + replica.device.clone(), + replica.reporter.clone(), + ); + match notice { + HidNotice::Kernel(uhid::Event::Start) => { + debug!(session, slot, "a driver took the device"); + self.schedule_discovery(key, 0); + } + HidNotice::Discover { attempt } => self.discover_nodes(key, attempt), + HidNotice::Kernel(uhid::Event::Stop) => { + debug!(session, slot, "the driver let go of the device"); + } + HidNotice::Kernel(uhid::Event::Open | uhid::Event::Close) => {} + HidNotice::Kernel(uhid::Event::Output { kind, data }) => { + trace!(session, slot, kind, len = data.len(), "output report"); + self.rumble_from(key, &data); + } + HidNotice::Kernel(uhid::Event::GetReport { id, number, kind }) => { + let answer = match kind { + uhid::REPORT_FEATURE => model.feature(number, address), + uhid::REPORT_INPUT => { + let current = reporter.lock().unwrap_or_else(|e| e.into_inner()).current(); + (current.first() == Some(&number)).then_some(current) + } + _ => None, + }; + // Some software asks for every report a descriptor declares, + // and the real device answers only some: an unknown one is + // expected, not a fault. + debug!( + session, + slot, + number = format!("{number:#04x}"), + kind, + answered = answer.is_some(), + "a report was asked for" + ); + let result = match answer { + Some(data) => device.get_report_reply(id, 0, &data), + None => device.get_report_reply(id, libc::EIO as u16, &[]), + }; + if let Err(e) = result { + debug!(session, slot, id, "could not answer a request: {e}"); + } + } + HidNotice::Kernel(uhid::Event::SetReport { + id, + number, + kind, + data, + }) => { + trace!(session, slot, id, number, kind, "set report"); + if let Err(e) = device.set_report_reply(id, 0) { + debug!(session, slot, id, "could not answer a request: {e}"); + } + if kind == uhid::REPORT_OUTPUT { + self.rumble_from(key, &data); + } + } + } + } + + /// Pass on the rumble a report written to a replica asks for. + fn rumble_from(&mut self, key: Key, report: &[u8]) { + let Some(replica) = self.pads.get_mut(&key).and_then(|p| p.replica.as_mut()) else { + return; + }; + let Some(rumble) = replica.model.rumble(report) else { + return; + }; + if rumble == replica.rumble { + return; + } + replica.rumble = rumble; + let (strong, weak) = rumble; + let _ = self.feedback.send(( + key.0, + PadFeedback::Rumble { + slot: key.1, + strong, + weak, + duration_ms: 0, + }, + )); + } + + fn discover_nodes(&mut self, key: Key, attempt: u32) { + let (session, slot) = key; + let Some(pad) = self.pads.get(&key) else { + return; + }; + let Some(Replica { + syspath: None, + vendor, + product, + .. + }) = pad.replica + else { + return; + }; + let name = pad.identity.name.clone(); + let claimed: HashSet = self + .pads + .values() + .filter_map(|p| p.replica.as_ref()?.syspath.clone()) + .collect(); + match discover(code::BUS_USB, vendor, product, &claimed) { + Some((found, nodes)) => self.nodes_found(key, found, nodes), + None if attempt + 1 < DISCOVER_ATTEMPTS => self.schedule_discovery(key, attempt + 1), + None => { + warn!( + session, + slot, + "the kernel made no nodes for \"{name}\", so only its gamepad can be read" + ); + self.plug_copy(key); + } + } + } + + fn schedule_discovery(&self, key: Key, attempt: u32) { + let tx = self.hid.clone(); + tokio::spawn(async move { + if attempt > 0 { + tokio::time::sleep(DISCOVER_EVERY).await; + } + let _ = tx.send((key, HidNotice::Discover { attempt })); + }); + } + + fn nodes_found(&mut self, key: Key, found: PathBuf, nodes: Nodes) { + let (session, slot) = key; + for node in nodes.dev_nodes() { + open_up(&node); + } + let mut records = Vec::new(); + let joystick = [ + ("ID_INPUT", "1".to_owned()), + ("ID_INPUT_JOYSTICK", "1".to_owned()), + ]; + let mut read = |path: &Path, subsystem: &str, extra: &[(&str, String)]| match Record::read( + path, subsystem, extra, + ) { + Ok(record) => records.push(record), + Err(e) => debug!("could not describe {}: {e}", path.display()), + }; + for hidraw in &nodes.hidraw { + read(hidraw, "hidraw", &[]); + } + for (input, events) in &nodes.inputs { + read(input, "input", &joystick); + for event in events { + read(event, "input", &joystick); + } + } + if let Err(e) = self.announce(&records) { + warn!(session, slot, "could not announce the device: {e}"); + } + info!( + session, + slot, + nodes = nodes + .dev_nodes() + .iter() + .map(|p| p.display().to_string()) + .collect::>() + .join(" "), + "controller nodes ready" + ); + if let Some(replica) = self.pads.get_mut(&key).and_then(|p| p.replica.as_mut()) { + replica.records = records; + replica.syspath = Some(found); + } + self.plug_copy(key); + } + + /// The gamepad beside a replica, under a neutral identity (see the module + /// docs). + fn plug_copy(&mut self, key: Key) { + let Some(pad) = self.pads.get(&key) else { + return; + }; + if pad.gamepad.is_some() { + return; + } + let gamepad = self.plug(key, layout::generic(&pad.identity)); + if let Some(pad) = self.pads.get_mut(&key) { + pad.gamepad = gamepad; + } + } + + fn remove(&mut self, key: Key) { + let Some(pad) = self.pads.remove(&key) else { + return; + }; + let mut records = Vec::new(); + // Destroyed explicitly, before anyone is told it is gone. Dropping the + // handle would not do it: an aborted task holds another, and lets go + // of it only when the runtime next gets round to dropping the task. + if let Some(gamepad) = pad.gamepad { + gamepad.task.abort(); + gamepad.device.destroy(); + records.extend(gamepad.records.into_iter().rev()); + } + if let Some(replica) = pad.replica { + for task in &replica.tasks { + task.abort(); + } + replica.device.destroy(); + records.extend(replica.records.into_iter().rev()); + } + if let Some(udev) = &self.udev { + for record in &records { + if let Err(e) = udev.remove(record) { + debug!("could not announce {} gone: {e}", record.devpath); + } + } + } + info!( + session = key.0, + slot = key.1, + "controller unplugged: {}", + pad.identity.name + ); + } + + fn end_session(&mut self, session: u32) { + let keys: Vec = self + .pads + .keys() + .filter(|k| k.0 == session) + .copied() + .collect(); + for key in keys { + self.remove(key); + } + self.asked.retain(|k| k.0 != session); + } + + /// Unplug everything. The hub has gone, and with it every client. + pub fn clear(&mut self) { + let keys: Vec = self.pads.keys().copied().collect(); + for key in keys { + self.remove(key); + } + self.asked.clear(); + } +} + +/// Open a node to the workload. +/// +/// devtmpfs creates it root-only, and the workload is not root. `nesinit` +/// opens up the box's other device nodes the same way, for the same reason: +/// the virtual machine is the boundary. +/// +/// A failure is a warning rather than no controller: the device still exists, +/// a workload running as root can still use it, and the line says why one +/// that is not cannot. +fn open_up(node: &Path) { + if let Err(e) = + std::fs::set_permissions(node, std::os::unix::fs::PermissionsExt::from_mode(0o666)) + { + warn!( + "could not open up {} to the workload, so only root can read this controller: {e}", + node.display() + ); + } +} + +/// The `eventN` node the kernel attached to an input device. +fn event_node(syspath: &Path) -> anyhow::Result { + for entry in std::fs::read_dir(syspath)? { + let entry = entry?; + if entry.file_name().to_string_lossy().starts_with("event") { + return Ok(entry.path()); + } + } + anyhow::bail!("{} has no event node", syspath.display()) +} + +/// Where uhid devices appear in sysfs. +const UHID_SYSFS: &str = "/sys/devices/virtual/misc/uhid"; + +/// The nodes a driver made for one HID device, as sysfs paths. +#[derive(Debug, Default)] +pub(crate) struct Nodes { + pub(crate) hidraw: Vec, + /// Each input device, with its event nodes. + inputs: Vec<(PathBuf, Vec)>, +} + +impl Nodes { + fn dev_nodes(&self) -> Vec { + let dev = + |path: &PathBuf, dir: &str| Path::new(dir).join(path.file_name().unwrap_or_default()); + let mut nodes: Vec = self.hidraw.iter().map(|h| dev(h, "/dev")).collect(); + for (_, events) in &self.inputs { + nodes.extend(events.iter().map(|e| dev(e, "/dev/input"))); + } + nodes + } +} + +/// Find the kernel's directory for a uhid device with this identity, and what +/// was made under it -- `None` until the hidraw node exists, which is the node +/// that matters. +/// +/// The kernel names the directory `BUS:VENDOR:PRODUCT.INSTANCE`, and several +/// devices can share everything but the instance, so one already claimed by +/// another controller is skipped. Two identical devices started at the same +/// moment could be found the other way round, which costs nothing: the same +/// nodes are opened up and announced either way. +pub(crate) fn discover( + bus: u16, + vendor: u16, + product: u16, + claimed: &HashSet, +) -> Option<(PathBuf, Nodes)> { + let prefix = format!("{bus:04X}:{vendor:04X}:{product:04X}."); + let mut candidates: Vec = std::fs::read_dir(UHID_SYSFS) + .ok()? + .filter_map(Result::ok) + .filter(|e| e.file_name().to_string_lossy().starts_with(&prefix)) + .map(|e| e.path()) + .filter(|p| !claimed.contains(p)) + .collect(); + candidates.sort(); + // The newest first: instances count up. + let dir = candidates.pop()?; + let nodes = nodes_under(&dir); + if nodes.hidraw.is_empty() { + return None; + } + Some((dir, nodes)) +} + +fn nodes_under(dir: &Path) -> Nodes { + let children = |path: &Path, prefix: &str| -> Vec { + let mut found: Vec = std::fs::read_dir(path) + .into_iter() + .flatten() + .filter_map(Result::ok) + .filter(|e| e.file_name().to_string_lossy().starts_with(prefix)) + .map(|e| e.path()) + .collect(); + found.sort(); + found + }; + let mut nodes = Nodes { + hidraw: children(&dir.join("hidraw"), "hidraw"), + ..Nodes::default() + }; + for input in children(&dir.join("input"), "input") { + let events = children(&input, "event"); + nodes.inputs.push((input, events)); + } + nodes +} + +/// What udev's rules would have added: that this is a joystick, and, where +/// the device carries a real identity, which one. +fn classification(layout: &Layout, (session, slot): Key) -> Vec<(&'static str, String)> { + let mut extra = vec![ + ("ID_INPUT", "1".to_owned()), + ("ID_INPUT_JOYSTICK", "1".to_owned()), + ]; + if let Some(bus) = layout.udev_bus() { + let serial = layout.name.replace(' ', "_"); + extra.extend([ + ("ID_BUS", bus.to_owned()), + ("ID_VENDOR_ID", format!("{:04x}", layout.vendor)), + ("ID_MODEL_ID", format!("{:04x}", layout.product)), + // Unique per device, as real serials are, so two identical + // controllers are not taken for one. + ("ID_SERIAL", format!("{serial}_{session}_{slot}")), + ]); + } + extra +} + +/// A descriptor, for the reactor. +struct Readable(Arc); + +impl AsRawFd for Readable { + fn as_raw_fd(&self) -> RawFd { + self.0.as_raw_fd() + } +} + +/// Pass everything a uhid device's kernel side says to the manager. +fn spawn_hid( + device: Arc, + key: Key, + tx: UnboundedSender<(Key, HidNotice)>, +) -> tokio::task::JoinHandle<()> { + tokio::spawn(async move { + let fd = match AsyncFd::new(Readable(device)) { + Ok(fd) => fd, + Err(e) => { + warn!( + session = key.0, + slot = key.1, + "cannot hear the device's kernel side: {e}" + ); + return; + } + }; + loop { + let mut guard = match fd.readable().await { + Ok(guard) => guard, + Err(e) => { + debug!( + session = key.0, + slot = key.1, + "device stopped being readable: {e}" + ); + return; + } + }; + let events = match guard.get_inner().0.drain() { + Ok(events) => events, + Err(e) => { + debug!( + session = key.0, + slot = key.1, + "reading the device failed: {e}" + ); + return; + } + }; + guard.clear_ready(); + for event in events { + if tx.send((key, HidNotice::Kernel(event))).is_err() { + return; + } + } + } + }) +} + +/// Keep a replica's reports coming at the rate the real device sends them, +/// whether or not anything changed. +fn spawn_clock( + device: Arc, + reporter: Arc>, + every: Duration, +) -> tokio::task::JoinHandle<()> { + tokio::spawn(async move { + let mut tick = tokio::time::interval(every); + // A late tick is a report that never happened, not one owed. + tick.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip); + loop { + tick.tick().await; + let report = reporter.lock().unwrap_or_else(|e| e.into_inner()).next(); + if device.input(&report).is_err() { + // Only ever a device on its way out; its removal stops this. + continue; + } + } + }) +} + +/// Read a uinput device for what games ask of it, and turn that into rumble +/// for the client. +/// +/// A game uploads an effect once and then plays and stops it by id, so the +/// effects are kept here to know what "play 3" means. Only the most recently +/// started one is sent: a real controller has one pair of motors, and the +/// client has no way to mix several. +fn spawn_rumble( + device: Arc, + session: u32, + slot: u8, + tx: UnboundedSender, +) -> tokio::task::JoinHandle<()> { + tokio::spawn(async move { + let fd = match AsyncFd::new(Readable(device)) { + Ok(fd) => fd, + Err(e) => { + warn!(session, slot, "rumble unavailable: {e}"); + return; + } + }; + let mut effects: HashMap = HashMap::new(); + let mut playing: Option = None; + let send = |strong, weak, duration_ms| { + let _ = tx.send(( + session, + PadFeedback::Rumble { + slot, + strong, + weak, + duration_ms, + }, + )); + }; + loop { + let mut guard = match fd.readable().await { + Ok(guard) => guard, + Err(e) => { + debug!(session, slot, "device stopped being readable: {e}"); + return; + } + }; + let requests = match guard.get_inner().0.drain() { + Ok(requests) => requests, + Err(e) => { + debug!(session, slot, "reading the device failed: {e}"); + return; + } + }; + guard.clear_ready(); + for request in requests { + trace!(session, slot, ?request, "from a game"); + match request { + Request::Upload(effect) => { + let Some((strong, weak)) = effect.rumble() else { + continue; + }; + let length = effect.length_ms(); + effects.insert(effect.id, (strong, weak, length)); + // Replacing the effect that is playing changes what + // the motors are doing now, not only next time. + if playing == Some(effect.id) { + send(strong, weak, length); + } + } + Request::Erase(id) => { + effects.remove(&id); + if playing == Some(id) { + playing = None; + send(0, 0, 0); + } + } + Request::Play { id, on: true } => { + if let Some(&(strong, weak, length)) = effects.get(&id) { + playing = Some(id); + send(strong, weak, length); + } + } + Request::Play { id, on: false } => { + if playing == Some(id) { + playing = None; + send(0, 0, 0); + } + } + } + } + } + }) +} diff --git a/apps/nesgamepad/src/replica.rs b/apps/nesgamepad/src/replica.rs new file mode 100644 index 00000000..8848831d --- /dev/null +++ b/apps/nesgamepad/src/replica.rs @@ -0,0 +1,201 @@ +//! Controllers the box rebuilds as the device itself, through `/dev/uhid`. +//! +//! Some games read a controller as a HID device and parse its reports by hand, +//! to tell one family from another and show the right buttons; Proton hands +//! such a controller to Wine as the raw device when it has a hidraw node, and +//! only then. For the families here, the box builds that device from nothing +//! but the controller's identity and its state: the real one's report +//! descriptor, and its reports written from the positional snapshot the client +//! sends. What a game asks of the device -- calibration, firmware, pairing -- +//! is answered here the way the real one answers, and rumble it writes goes +//! back to the client. +//! +//! What the snapshot cannot say, the replica reports as a controller at rest: +//! motion sensors still, touchpad untouched. + +mod dualshock4; + +use std::time::{Duration, Instant}; + +use nesprotocol::gamepad::PadState; + +use crate::layout::SONY; +use crate::uhid; +use crate::uinput::code; + +/// The DualShock 4 a Sony controller with no template of its own is +/// presented as. +pub const DUALSHOCK4_V2: u16 = 0x09cc; + +/// A family the box can rebuild. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Model { + DualShock4, +} + +/// Whether `product` is one of `model`'s. +pub fn is(model: Model, product: u16) -> bool { + model.products().iter().any(|&(p, _)| p == product) +} + +impl Model { + fn products(self) -> &'static [(u16, &'static str)] { + match self { + Self::DualShock4 => dualshock4::PRODUCTS, + } + } + + fn vendor(self) -> u16 { + match self { + Self::DualShock4 => SONY, + } + } + + /// The name the device gives itself, which the client's platform may not + /// have passed on as it was. + pub fn name(self, product: u16) -> &'static str { + let products = self.products(); + products + .iter() + .find(|&&(p, _)| p == product) + .map_or(products[0].1, |&(_, name)| name) + } + + /// Everything the device is built from. `uniq` is its address as text + /// (see [`uniq`]). + pub fn spec(self, product: u16, uniq: &str) -> uhid::Spec<'_> { + let (version, descriptor) = match self { + Self::DualShock4 => (dualshock4::VERSION, dualshock4::DESCRIPTOR), + }; + uhid::Spec { + name: self.name(product), + uniq, + bus: code::BUS_USB, + vendor: self.vendor(), + product, + version, + country: 0, + descriptor, + } + } + + pub fn report_every(self) -> Duration { + match self { + Self::DualShock4 => dualshock4::REPORT_EVERY, + } + } + + /// The answer to a feature report read, `None` for one this does not + /// know. + pub fn feature(self, number: u8, address: [u8; 6]) -> Option> { + match self { + Self::DualShock4 => dualshock4::feature(number, address), + } + } + + /// Rumble a report written to the device asks for, as `(strong, weak)`. + pub fn rumble(self, report: &[u8]) -> Option<(u16, u16)> { + match self { + Self::DualShock4 => dualshock4::rumble(report), + } + } +} + +/// The input reports of one device: the latest state, and the counters a +/// real device moves on every report. +pub struct Reporter { + model: Model, + state: PadState, + counter: u8, + started: Instant, +} + +impl Reporter { + pub fn new(model: Model) -> Self { + Self { + model, + state: PadState::default(), + counter: 0, + started: Instant::now(), + } + } + + pub fn set(&mut self, state: PadState) { + self.state = state; + } + + /// The next report, for the state last set. + pub fn next(&mut self) -> Vec { + let counter = self.counter; + self.counter = self.counter.wrapping_add(1); + match self.model { + Model::DualShock4 => dualshock4::input_report( + &self.state, + counter, + dualshock4::sensor_ticks(self.started.elapsed()), + ) + .to_vec(), + } + } + + /// The report a read of the current input report gets, without moving + /// the counters. + pub fn current(&self) -> Vec { + match self.model { + Model::DualShock4 => dualshock4::input_report( + &self.state, + self.counter, + dualshock4::sensor_ticks(self.started.elapsed()), + ) + .to_vec(), + } + } +} + +/// A locally administered address, unique to one client's slot. Software +/// pairs a device's parts, and tells two identical devices apart, by it. +pub fn address(session: u32, slot: u8) -> [u8; 6] { + let s = session.to_be_bytes(); + [0x02, s[0], s[1], s[2], s[3], slot] +} + +/// An address as a device's unique string, the way Linux writes one. +pub fn uniq(address: [u8; 6]) -> String { + address + .iter() + .map(|b| format!("{b:02x}")) + .collect::>() + .join(":") +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn the_device_names_itself_as_the_real_one_does() { + let spec = Model::DualShock4.spec(0x09cc, ""); + assert_eq!( + spec.name, + "Sony Interactive Entertainment Wireless Controller" + ); + assert_eq!((spec.vendor, spec.product), (SONY, 0x09cc)); + assert!(is(Model::DualShock4, 0x05c4)); + assert!(!is(Model::DualShock4, 0x0ce6)); + } + + #[test] + fn every_slot_gets_its_own_address() { + assert_ne!(address(1, 0), address(1, 1)); + assert_ne!(address(1, 0), address(2, 0)); + assert_eq!(uniq(address(0x0102_0304, 5)), "02:01:02:03:04:05"); + } + + #[test] + fn the_counter_moves_with_every_report() { + let mut reporter = Reporter::new(Model::DualShock4); + let first = reporter.next(); + let second = reporter.next(); + assert_eq!(second[7] >> 2, (first[7] >> 2) + 1); + } +} diff --git a/apps/nesgamepad/src/replica/dualshock4.rs b/apps/nesgamepad/src/replica/dualshock4.rs new file mode 100644 index 00000000..76b61fcd --- /dev/null +++ b/apps/nesgamepad/src/replica/dualshock4.rs @@ -0,0 +1,309 @@ +//! The DualShock 4, over USB. +//! +//! Everything here was read from a DualShock 4 v2 (`054c:09cc`) on a cable: +//! its report descriptor byte for byte, its calibration and firmware reports, +//! and the layout of the input report it sends. The layout matches what +//! Linux's hid-playstation and SDL's HIDAPI driver parse, which between them +//! are what games reading the device by hand were written against. + +use nesprotocol::gamepad::{PadState, button}; + +/// Products that are this controller: the original, the v2, and Sony's +/// wireless adapter, which presents whichever DualShock 4 is paired to it as +/// the same device. +pub const PRODUCTS: &[(u16, &str)] = &[ + (0x05c4, "Sony Computer Entertainment Wireless Controller"), + (0x09cc, "Sony Interactive Entertainment Wireless Controller"), + ( + 0x0ba0, + "Sony Interactive Entertainment DUALSHOCK\u{ae}4 USB Wireless Adaptor", + ), +]; + +/// The HID version the device's descriptor gives, which Linux reports as the +/// device's version. +pub const VERSION: u16 = 0x0111; + +#[rustfmt::skip] +pub const DESCRIPTOR: &[u8] = &[ + 0x05, 0x01, 0x09, 0x05, 0xa1, 0x01, 0x85, 0x01, 0x09, 0x30, 0x09, 0x31, + 0x09, 0x32, 0x09, 0x35, 0x15, 0x00, 0x26, 0xff, 0x00, 0x75, 0x08, 0x95, + 0x04, 0x81, 0x02, 0x09, 0x39, 0x15, 0x00, 0x25, 0x07, 0x35, 0x00, 0x46, + 0x3b, 0x01, 0x65, 0x14, 0x75, 0x04, 0x95, 0x01, 0x81, 0x42, 0x65, 0x00, + 0x05, 0x09, 0x19, 0x01, 0x29, 0x0e, 0x15, 0x00, 0x25, 0x01, 0x75, 0x01, + 0x95, 0x0e, 0x81, 0x02, 0x06, 0x00, 0xff, 0x09, 0x20, 0x75, 0x06, 0x95, + 0x01, 0x15, 0x00, 0x25, 0x7f, 0x81, 0x02, 0x05, 0x01, 0x09, 0x33, 0x09, + 0x34, 0x15, 0x00, 0x26, 0xff, 0x00, 0x75, 0x08, 0x95, 0x02, 0x81, 0x02, + 0x06, 0x00, 0xff, 0x09, 0x21, 0x95, 0x36, 0x81, 0x02, 0x85, 0x05, 0x09, + 0x22, 0x95, 0x1f, 0x91, 0x02, 0x85, 0x04, 0x09, 0x23, 0x95, 0x24, 0xb1, + 0x02, 0x85, 0x02, 0x09, 0x24, 0x95, 0x24, 0xb1, 0x02, 0x85, 0x08, 0x09, + 0x25, 0x95, 0x03, 0xb1, 0x02, 0x85, 0x10, 0x09, 0x26, 0x95, 0x04, 0xb1, + 0x02, 0x85, 0x11, 0x09, 0x27, 0x95, 0x02, 0xb1, 0x02, 0x85, 0x12, 0x06, + 0x02, 0xff, 0x09, 0x21, 0x95, 0x0f, 0xb1, 0x02, 0x85, 0x13, 0x09, 0x22, + 0x95, 0x16, 0xb1, 0x02, 0x85, 0x14, 0x06, 0x05, 0xff, 0x09, 0x20, 0x95, + 0x10, 0xb1, 0x02, 0x85, 0x15, 0x09, 0x21, 0x95, 0x2c, 0xb1, 0x02, 0x06, + 0x80, 0xff, 0x85, 0x80, 0x09, 0x20, 0x95, 0x06, 0xb1, 0x02, 0x85, 0x81, + 0x09, 0x21, 0x95, 0x06, 0xb1, 0x02, 0x85, 0x82, 0x09, 0x22, 0x95, 0x05, + 0xb1, 0x02, 0x85, 0x83, 0x09, 0x23, 0x95, 0x01, 0xb1, 0x02, 0x85, 0x84, + 0x09, 0x24, 0x95, 0x04, 0xb1, 0x02, 0x85, 0x85, 0x09, 0x25, 0x95, 0x06, + 0xb1, 0x02, 0x85, 0x86, 0x09, 0x26, 0x95, 0x06, 0xb1, 0x02, 0x85, 0x87, + 0x09, 0x27, 0x95, 0x23, 0xb1, 0x02, 0x85, 0x88, 0x09, 0x28, 0x95, 0x3f, + 0xb1, 0x02, 0x85, 0x89, 0x09, 0x29, 0x95, 0x02, 0xb1, 0x02, 0x85, 0x90, + 0x09, 0x30, 0x95, 0x05, 0xb1, 0x02, 0x85, 0x91, 0x09, 0x31, 0x95, 0x03, + 0xb1, 0x02, 0x85, 0x92, 0x09, 0x32, 0x95, 0x03, 0xb1, 0x02, 0x85, 0x93, + 0x09, 0x33, 0x95, 0x0c, 0xb1, 0x02, 0x85, 0x94, 0x09, 0x34, 0x95, 0x3f, + 0xb1, 0x02, 0x85, 0xa0, 0x09, 0x40, 0x95, 0x06, 0xb1, 0x02, 0x85, 0xa1, + 0x09, 0x41, 0x95, 0x01, 0xb1, 0x02, 0x85, 0xa2, 0x09, 0x42, 0x95, 0x01, + 0xb1, 0x02, 0x85, 0xa3, 0x09, 0x43, 0x95, 0x30, 0xb1, 0x02, 0x85, 0xa4, + 0x09, 0x44, 0x95, 0x0d, 0xb1, 0x02, 0x85, 0xf0, 0x09, 0x47, 0x95, 0x3f, + 0xb1, 0x02, 0x85, 0xf1, 0x09, 0x48, 0x95, 0x3f, 0xb1, 0x02, 0x85, 0xf2, + 0x09, 0x49, 0x95, 0x0f, 0xb1, 0x02, 0x85, 0xa7, 0x09, 0x4a, 0x95, 0x01, + 0xb1, 0x02, 0x85, 0xa8, 0x09, 0x4b, 0x95, 0x01, 0xb1, 0x02, 0x85, 0xa9, + 0x09, 0x4c, 0x95, 0x08, 0xb1, 0x02, 0x85, 0xaa, 0x09, 0x4e, 0x95, 0x01, + 0xb1, 0x02, 0x85, 0xab, 0x09, 0x4f, 0x95, 0x39, 0xb1, 0x02, 0x85, 0xac, + 0x09, 0x50, 0x95, 0x39, 0xb1, 0x02, 0x85, 0xad, 0x09, 0x51, 0x95, 0x0b, + 0xb1, 0x02, 0x85, 0xae, 0x09, 0x52, 0x95, 0x01, 0xb1, 0x02, 0x85, 0xaf, + 0x09, 0x53, 0x95, 0x02, 0xb1, 0x02, 0x85, 0xb0, 0x09, 0x54, 0x95, 0x3f, + 0xb1, 0x02, 0x85, 0xe0, 0x09, 0x57, 0x95, 0x02, 0xb1, 0x02, 0x85, 0xb3, + 0x09, 0x55, 0x95, 0x3f, 0xb1, 0x02, 0x85, 0xb4, 0x09, 0x55, 0x95, 0x3f, + 0xb1, 0x02, 0x85, 0xb5, 0x09, 0x56, 0x95, 0x3f, 0xb1, 0x02, 0x85, 0xd0, + 0x09, 0x58, 0x95, 0x3f, 0xb1, 0x02, 0x85, 0xd4, 0x09, 0x59, 0x95, 0x3f, + 0xb1, 0x02, 0xc0, +]; + +/// Input report 0x01, the one the device sends continuously. +pub const INPUT_LEN: usize = 64; + +/// How often the device sends it. A real one never goes quiet while plugged +/// in, and software that reads it by hand counts on the counter and the +/// sensor clock moving. +pub const REPORT_EVERY: std::time::Duration = std::time::Duration::from_millis(4); + +/// The sensor clock ticks every 16/3 µs. +pub fn sensor_ticks(elapsed: std::time::Duration) -> u16 { + (elapsed.as_micros() * 3 / 16) as u16 +} + +/// A stick axis in the device's byte, from the wire's full range. Both have +/// down as positive. +fn stick(value: i16) -> u8 { + ((i32::from(value) + 32768) >> 8) as u8 +} + +/// The d-pad as the device's hat: 0 is up, counting clockwise in eighths, 8 +/// is released. Opposite directions held together cancel, as on the real +/// d-pad they cannot both be. +fn hat(buttons: u32) -> u8 { + let held = |bit| i8::from(buttons & bit != 0); + let x = held(button::DPAD_RIGHT) - held(button::DPAD_LEFT); + let y = held(button::DPAD_DOWN) - held(button::DPAD_UP); + match (x, y) { + (0, -1) => 0, + (1, -1) => 1, + (1, 0) => 2, + (1, 1) => 3, + (0, 1) => 4, + (-1, 1) => 5, + (-1, 0) => 6, + (-1, -1) => 7, + _ => 8, + } +} + +/// The input report for `state`. `counter` wraps at 64; `clock` is the +/// sensor clock (see [`sensor_ticks`]). +pub fn input_report(state: &PadState, counter: u8, clock: u16) -> [u8; INPUT_LEN] { + let b = state.buttons; + let bit = |mask, value| if b & mask != 0 { value } else { 0 }; + let left_trigger = (state.left_trigger >> 8) as u8; + let right_trigger = (state.right_trigger >> 8) as u8; + + let mut r = [0u8; INPUT_LEN]; + r[0] = 0x01; + r[1] = stick(state.left_x); + r[2] = stick(state.left_y); + r[3] = stick(state.right_x); + r[4] = stick(state.right_y); + r[5] = hat(b) + | bit(button::WEST, 0x10) + | bit(button::SOUTH, 0x20) + | bit(button::EAST, 0x40) + | bit(button::NORTH, 0x80); + // The trigger bits are set by any pull at all, as the device sets them. + let l2 = if b & button::LEFT_TRIGGER != 0 || left_trigger > 0 { + 0x04 + } else { + 0 + }; + let r2 = if b & button::RIGHT_TRIGGER != 0 || right_trigger > 0 { + 0x08 + } else { + 0 + }; + r[6] = bit(button::LEFT_SHOULDER, 0x01) + | bit(button::RIGHT_SHOULDER, 0x02) + | l2 + | r2 + | bit(button::SELECT, 0x10) + | bit(button::START, 0x20) + | bit(button::LEFT_STICK, 0x40) + | bit(button::RIGHT_STICK, 0x80); + r[7] = bit(button::MODE, 0x01) | ((counter & 0x3f) << 2); + r[8] = left_trigger; + r[9] = right_trigger; + r[10..12].copy_from_slice(&clock.to_le_bytes()); + // Gyro still, and the accelerometer reading one g on the axis gravity + // pulls along with the controller lying flat: a controller at rest, which + // is all the client can say about its motion. + r[21..23].copy_from_slice(&8192i16.to_le_bytes()); + // On the cable, battery full. + r[30] = 0x1b; + // One touch report, both fingers lifted: the high bit of a point's + // first byte marks it inactive. + r[33] = 1; + r[35] = 0x80; + r[39] = 0x80; + r +} + +/// Feature 0x02: the motion sensors' calibration. +#[rustfmt::skip] +const CALIBRATION: &[u8] = &[ + 0x02, 0x03, 0x00, 0x06, 0x00, 0xfe, 0xff, 0x12, 0x22, 0x34, 0xde, 0xc5, + 0x22, 0x51, 0xdd, 0x6d, 0x24, 0x48, 0xdb, 0x1c, 0x02, 0x1c, 0x02, 0xd6, + 0x1f, 0x29, 0xe0, 0x69, 0x20, 0x96, 0xdf, 0xd9, 0x1f, 0x27, 0xe0, 0x02, + 0x00, +]; + +/// Feature 0xa3: firmware build date and versions. +#[rustfmt::skip] +const FIRMWARE: &[u8] = &[ + 0xa3, 0x53, 0x65, 0x70, 0x20, 0x32, 0x31, 0x20, 0x32, 0x30, 0x31, 0x38, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x30, 0x34, 0x3a, 0x35, 0x30, 0x3a, 0x35, + 0x31, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01, 0x00, + 0xb4, 0x01, 0x00, 0x00, 0x00, 0x0a, 0xa0, 0x10, 0x20, 0x00, 0xa0, 0x02, + 0x00, +]; + +/// The answer to a feature report read, for the controller at `address`. +pub fn feature(number: u8, address: [u8; 6]) -> Option> { + // Addresses go over the wire lowest byte first. + let mut reversed = address; + reversed.reverse(); + match number { + 0x02 => Some(CALIBRATION.to_vec()), + 0xa3 => Some(FIRMWARE.to_vec()), + // Its own address. + 0x81 => Some([&[0x81][..], &reversed].concat()), + // Pairing: its own address, a fixed class, and the host it is paired + // with, which on a cable is none. + 0x12 => Some([&[0x12][..], &reversed, &[0x08, 0x25, 0x00], &[0; 6]].concat()), + _ => None, + } +} + +/// Rumble from output report 0x05: `(strong, weak)`, when the report sets it. +/// The left motor is the heavy one. +pub fn rumble(report: &[u8]) -> Option<(u16, u16)> { + const SETS_MOTORS: u8 = 0x01; + match report { + [0x05, flags, _, _, weak, strong, ..] if flags & SETS_MOTORS != 0 => { + Some((u16::from(*strong) * 257, u16::from(*weak) * 257)) + } + _ => None, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn the_descriptor_declares_what_the_reports_are() { + // Input 0x01 is 63 bytes after its id, output 0x05 31. + let d = DESCRIPTOR; + assert_eq!(d.len(), 507); + assert_eq!(&d[..6], &[0x05, 0x01, 0x09, 0x05, 0xa1, 0x01]); + assert!(d.windows(4).any(|w| w == [0x85, 0x05, 0x09, 0x22])); + } + + #[test] + fn a_controller_at_rest_reads_as_one() { + // Compared with a report from the real device lying still. + let r = input_report(&PadState::default(), 0, 0); + assert_eq!(&r[..10], &[0x01, 0x80, 0x80, 0x80, 0x80, 0x08, 0, 0, 0, 0]); + assert_eq!(r[30], 0x1b); + } + + #[test] + fn every_button_lands_on_its_bit() { + let all = PadState { + buttons: !(button::DPAD_UP + | button::DPAD_DOWN + | button::DPAD_LEFT + | button::DPAD_RIGHT), + ..PadState::default() + }; + let r = input_report(&all, 0, 0); + assert_eq!((r[5], r[6], r[7]), (0xf8, 0xff, 0x01)); + } + + #[test] + fn the_hat_goes_round_clockwise_from_up() { + use button::*; + for (held, value) in [ + (DPAD_UP, 0), + (DPAD_UP | DPAD_RIGHT, 1), + (DPAD_RIGHT, 2), + (DPAD_DOWN | DPAD_RIGHT, 3), + (DPAD_DOWN, 4), + (DPAD_DOWN | DPAD_LEFT, 5), + (DPAD_LEFT, 6), + (DPAD_UP | DPAD_LEFT, 7), + (DPAD_UP | DPAD_DOWN, 8), + ] { + assert_eq!(hat(held), value, "{held:#x}"); + } + } + + #[test] + fn sticks_and_triggers_reach_both_ends() { + let pushed = PadState { + left_x: i16::MIN, + left_y: i16::MAX, + right_trigger: u16::MAX, + ..PadState::default() + }; + let r = input_report(&pushed, 63, 0x1234); + assert_eq!((r[1], r[2], r[9]), (0, 255, 255)); + assert_eq!(r[6] & 0x08, 0x08); + assert_eq!(r[7] >> 2, 63); + assert_eq!(&r[10..12], &[0x34, 0x12]); + } + + #[test] + fn reports_carry_the_address_they_were_given() { + let address = [0xa0, 0xab, 0x51, 0x0b, 0xad, 0x42]; + assert_eq!( + feature(0x81, address).unwrap(), + [0x81, 0x42, 0xad, 0x0b, 0x51, 0xab, 0xa0] + ); + let pairing = feature(0x12, address).unwrap(); + assert_eq!(pairing.len(), 16); + assert_eq!(&pairing[1..7], &[0x42, 0xad, 0x0b, 0x51, 0xab, 0xa0]); + assert_eq!(feature(0x02, address).unwrap().len(), 37); + assert_eq!(feature(0xa3, address).unwrap().len(), 49); + assert_eq!(feature(0x04, address), None); + } + + #[test] + fn rumble_is_read_only_when_the_report_sets_it() { + assert_eq!( + rumble(&[0x05, 0x07, 0x04, 0, 0x40, 0xff, 0, 0, 0xff]), + Some((0xffff, 0x4040)) + ); + // Lights only. + assert_eq!(rumble(&[0x05, 0x02, 0x04, 0, 0x40, 0xff]), None); + assert_eq!(rumble(&[0x11, 0xff]), None); + } +} diff --git a/apps/nesgamepad/src/template.rs b/apps/nesgamepad/src/template.rs new file mode 100644 index 00000000..117d20dc --- /dev/null +++ b/apps/nesgamepad/src/template.rs @@ -0,0 +1,125 @@ +//! Which controller the box presents for the one a client described. +//! +//! A controller is matched to a template the box supports by vendor and +//! product. One that matches none, from a vendor the box knows, gets that +//! vendor's default -- the controller of theirs games know best -- so that it +//! still shows up as its vendor's, with that vendor's buttons in a game. Only a +//! controller the box cannot tell at all is presented as generic. +//! +//! A fallback presents the template's identity, not the client's: an identity +//! is only worth passing on with the layout that goes with it (see +//! `crate::layout`). + +use nesprotocol::gamepad::PadIdentity; + +use crate::layout::{self, Layout, MICROSOFT, NINTENDO, SONY}; +use crate::replica::{self, Model}; + +pub enum Template { + /// The device itself, as `product` (see `crate::replica`). + Replica { model: Model, product: u16 }, + /// One evdev device. + Gamepad(Layout), +} + +pub fn for_identity(identity: &PadIdentity) -> Template { + let product = identity.product; + match identity.vendor { + SONY => match product { + 0x0ce6 => Template::Gamepad(layout::dualsense()), + _ if replica::is(Model::DualShock4, product) => Template::Replica { + model: Model::DualShock4, + product, + }, + _ => Template::Replica { + model: Model::DualShock4, + product: replica::DUALSHOCK4_V2, + }, + }, + MICROSOFT => match product { + 0x02ea => Template::Gamepad(layout::xbox_one()), + _ => Template::Gamepad(layout::xbox_360()), + }, + NINTENDO => Template::Gamepad(layout::switch_pro()), + _ => Template::Gamepad(layout::generic(identity)), + } +} + +impl Template { + /// Which template, for the log. + pub fn describe(&self) -> String { + match self { + Self::Replica { model, product } => format!("{model:?} {product:04x}"), + Self::Gamepad(layout) => format!("{} {:04x}", layout.driver, layout.product), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn pick(vendor: u16, product: u16) -> Template { + for_identity(&PadIdentity { + vendor, + product, + name: "Pad".into(), + }) + } + + fn gamepad(template: Template) -> Layout { + match template { + Template::Gamepad(layout) => layout, + Template::Replica { .. } => panic!("a replica"), + } + } + + #[test] + fn a_dualshock_4_is_rebuilt_as_the_one_it_is() { + for product in [0x05c4, 0x09cc, 0x0ba0] { + assert!(matches!( + pick(SONY, product), + Template::Replica { model: Model::DualShock4, product: p } if p == product + )); + } + } + + #[test] + fn a_sony_controller_nothing_fits_is_a_dualshock_4() { + // A DualSense Edge, which has no template of its own. + assert!(matches!( + pick(SONY, 0x0df2), + Template::Replica { + model: Model::DualShock4, + product: 0x09cc + } + )); + } + + #[test] + fn a_dualsense_has_its_own() { + assert_eq!(gamepad(pick(SONY, 0x0ce6)).product, 0x0ce6); + } + + #[test] + fn an_xbox_controller_nothing_fits_is_a_360_pad() { + assert_eq!(gamepad(pick(MICROSOFT, 0x02ea)).product, 0x02ea); + assert_eq!(gamepad(pick(MICROSOFT, 0x028e)).product, 0x028e); + // A Series X|S pad. + let layout = gamepad(pick(MICROSOFT, 0x0b12)); + assert_eq!((layout.vendor, layout.product), (MICROSOFT, 0x028e)); + } + + #[test] + fn anything_from_nintendo_is_a_pro_controller() { + for product in [0x2009, 0x2006, 0x2007] { + assert_eq!(gamepad(pick(NINTENDO, product)).product, 0x2009); + } + } + + #[test] + fn a_controller_nobody_can_tell_is_generic() { + assert_eq!(gamepad(pick(0x2dc8, 0x3106)).driver, "generic"); + assert_eq!(gamepad(pick(0, 0)).driver, "generic"); + } +} diff --git a/apps/nesgamepad/src/udev.rs b/apps/nesgamepad/src/udev.rs new file mode 100644 index 00000000..4f356991 --- /dev/null +++ b/apps/nesgamepad/src/udev.rs @@ -0,0 +1,391 @@ +//! 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, +} + +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 { + 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 { + 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 { + 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 { + // 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::() 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 { + 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")); + } +} diff --git a/apps/nesgamepad/src/uhid.rs b/apps/nesgamepad/src/uhid.rs new file mode 100644 index 00000000..2f6b4800 --- /dev/null +++ b/apps/nesgamepad/src/uhid.rs @@ -0,0 +1,326 @@ +//! A HID device made in userspace, through `/dev/uhid`. +//! +//! The kernel treats it as real hardware: a driver binds to it -- hid-generic, +//! for anything not claimed more specifically -- and it gets a hidraw node, +//! which is what Proton reads a controller through when it wants the device +//! itself rather than a gamepad abstraction of it. Reports written here are +//! the device's; everything asked of it comes back out of here to be answered. +//! +//! The ABI is `linux/uhid.h`: every message in either direction is one packed +//! `struct uhid_event`, a type and a union. The layouts below are the kernel's, +//! checked by size at compile time. + +use std::fs::OpenOptions; +use std::io::{self, Read, Write}; +use std::os::fd::{AsRawFd, RawFd}; +use std::os::unix::fs::OpenOptionsExt; +use std::sync::Mutex; + +const UHID_DESTROY: u32 = 1; +const UHID_START: u32 = 2; +const UHID_STOP: u32 = 3; +const UHID_OPEN: u32 = 4; +const UHID_CLOSE: u32 = 5; +const UHID_OUTPUT: u32 = 6; +const UHID_GET_REPORT: u32 = 9; +const UHID_GET_REPORT_REPLY: u32 = 10; +const UHID_CREATE2: u32 = 11; +const UHID_INPUT2: u32 = 12; +const UHID_SET_REPORT: u32 = 13; +const UHID_SET_REPORT_REPLY: u32 = 14; + +/// `UHID_DATA_MAX`, and also `HID_MAX_DESCRIPTOR_SIZE`: the kernel uses the +/// same number for both. +pub const DATA_MAX: usize = 4096; + +/// The largest member of the event union is `uhid_create2_req`. +const CREATE2_LEN: usize = 128 + 64 + 64 + 2 + 2 + 4 * 4 + DATA_MAX; +/// `struct uhid_event`: a `u32` type, then the union. +const EVENT_LEN: usize = 4 + CREATE2_LEN; + +const _: () = assert!(EVENT_LEN == 4376); + +/// Report kinds, as `uhid` numbers them. +pub const REPORT_FEATURE: u8 = 0; +pub const REPORT_OUTPUT: u8 = 1; +pub const REPORT_INPUT: u8 = 2; + +/// What a device in the box was built from. +pub struct Spec<'a> { + pub name: &'a str, + pub uniq: &'a str, + pub bus: u16, + pub vendor: u16, + pub product: u16, + pub version: u16, + pub country: u8, + pub descriptor: &'a [u8], +} + +/// What the kernel said. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Event { + /// A driver bound. Its nodes follow shortly after, not with this. + Start, + Stop, + /// Something opened a node of it, or the last one closed. + Open, + Close, + /// A report written to the device. + Output { + kind: u8, + data: Vec, + }, + /// Something wants a report read from the device, answered by `id`. + GetReport { + id: u32, + number: u8, + kind: u8, + }, + /// Something wants a report written to the device, answered by `id`. + SetReport { + id: u32, + number: u8, + kind: u8, + data: Vec, + }, +} + +/// A device that exists for as long as this does. +pub struct Device { + /// Writes are whole events, and several threads may answer requests; one + /// at a time keeps each event in one piece. + file: Mutex, + fd: RawFd, +} + +fn put_str(buf: &mut [u8], value: &str) { + // One short of the field, so the kernel always finds a terminator. + let bytes = value.as_bytes(); + let len = bytes.len().min(buf.len() - 1); + buf[..len].copy_from_slice(&bytes[..len]); +} + +impl Device { + pub fn create(spec: &Spec<'_>) -> io::Result { + if spec.descriptor.is_empty() || spec.descriptor.len() > DATA_MAX { + return Err(io::Error::other(format!( + "a report descriptor of {} bytes", + spec.descriptor.len() + ))); + } + let file = OpenOptions::new() + .read(true) + .write(true) + .custom_flags(libc::O_NONBLOCK | libc::O_CLOEXEC) + .open("/dev/uhid")?; + let fd = file.as_raw_fd(); + let device = Self { + file: Mutex::new(file), + fd, + }; + + let mut event = vec![0u8; EVENT_LEN]; + event[..4].copy_from_slice(&UHID_CREATE2.to_ne_bytes()); + let body = &mut event[4..]; + put_str(&mut body[..128], spec.name); + // `phys` is where a device is attached. This one is attached nowhere + // real, and saying so is truer than inventing a USB path. + put_str(&mut body[128..192], "nesgamepad"); + put_str(&mut body[192..256], spec.uniq); + body[256..258].copy_from_slice(&(spec.descriptor.len() as u16).to_ne_bytes()); + body[258..260].copy_from_slice(&spec.bus.to_ne_bytes()); + body[260..264].copy_from_slice(&u32::from(spec.vendor).to_ne_bytes()); + body[264..268].copy_from_slice(&u32::from(spec.product).to_ne_bytes()); + body[268..272].copy_from_slice(&u32::from(spec.version).to_ne_bytes()); + body[272..276].copy_from_slice(&u32::from(spec.country).to_ne_bytes()); + body[276..276 + spec.descriptor.len()].copy_from_slice(spec.descriptor); + device.write_event(&event)?; + Ok(device) + } + + fn write_event(&self, event: &[u8]) -> io::Result<()> { + let mut file = self.file.lock().unwrap_or_else(|e| e.into_inner()); + file.write_all(event) + } + + /// One input report, as the device sent it. + pub fn input(&self, report: &[u8]) -> io::Result<()> { + let len = report.len().min(DATA_MAX); + let mut event = Vec::with_capacity(4 + 2 + len); + event.extend_from_slice(&UHID_INPUT2.to_ne_bytes()); + event.extend_from_slice(&(len as u16).to_ne_bytes()); + event.extend_from_slice(&report[..len]); + self.write_event(&event) + } + + /// Answer a [`Event::GetReport`]. A short event is extended with zeroes + /// by the kernel, so only what is used is written. + pub fn get_report_reply(&self, id: u32, err: u16, data: &[u8]) -> io::Result<()> { + let len = data.len().min(DATA_MAX); + let mut event = Vec::with_capacity(4 + 8 + len); + event.extend_from_slice(&UHID_GET_REPORT_REPLY.to_ne_bytes()); + event.extend_from_slice(&id.to_ne_bytes()); + event.extend_from_slice(&err.to_ne_bytes()); + event.extend_from_slice(&(len as u16).to_ne_bytes()); + event.extend_from_slice(&data[..len]); + self.write_event(&event) + } + + /// Answer a [`Event::SetReport`]. + pub fn set_report_reply(&self, id: u32, err: u16) -> io::Result<()> { + let mut event = Vec::with_capacity(10); + event.extend_from_slice(&UHID_SET_REPORT_REPLY.to_ne_bytes()); + event.extend_from_slice(&id.to_ne_bytes()); + event.extend_from_slice(&err.to_ne_bytes()); + self.write_event(&event) + } + + /// Everything the kernel has said since the last call. + pub fn drain(&self) -> io::Result> { + let mut out = Vec::new(); + let mut buf = vec![0u8; EVENT_LEN]; + loop { + let n = { + let mut file = self.file.lock().unwrap_or_else(|e| e.into_inner()); + match file.read(&mut buf) { + Ok(n) => n, + Err(e) if e.kind() == io::ErrorKind::WouldBlock => return Ok(out), + Err(e) => return Err(e), + } + }; + if n == 0 { + return Ok(out); + } + // The kernel may write short; the rest reads as zero. + buf[n..].fill(0); + if let Some(event) = parse(&buf) { + out.push(event); + } + } + } +} + +/// One event, from a buffer at least as long as `struct uhid_event`. +fn parse(buf: &[u8]) -> Option { + let kind = u32::from_ne_bytes(buf[..4].try_into().ok()?); + let body = &buf[4..]; + let u16_at = |i: usize| u16::from_ne_bytes([body[i], body[i + 1]]); + let u32_at = |i: usize| u32::from_ne_bytes(body[i..i + 4].try_into().unwrap()); + Some(match kind { + UHID_START => Event::Start, + UHID_STOP => Event::Stop, + UHID_OPEN => Event::Open, + UHID_CLOSE => Event::Close, + // uhid_output_req: data[4096], size u16, rtype u8 + UHID_OUTPUT => { + let size = (u16_at(DATA_MAX) as usize).min(DATA_MAX); + Event::Output { + kind: body[DATA_MAX + 2], + data: body[..size].to_vec(), + } + } + // uhid_get_report_req: id u32, rnum u8, rtype u8 + UHID_GET_REPORT => Event::GetReport { + id: u32_at(0), + number: body[4], + kind: body[5], + }, + // uhid_set_report_req: id u32, rnum u8, rtype u8, size u16, data + UHID_SET_REPORT => { + let size = (u16_at(6) as usize).min(DATA_MAX); + Event::SetReport { + id: u32_at(0), + number: body[4], + kind: body[5], + data: body[8..8 + size].to_vec(), + } + } + _ => return None, + }) +} + +impl AsRawFd for Device { + fn as_raw_fd(&self) -> RawFd { + self.fd + } +} + +impl Device { + /// Remove the device now. Closing the descriptor would too, but only when + /// the last holder of it closes. + pub fn destroy(&self) { + let _ = self.write_event(&UHID_DESTROY.to_ne_bytes()); + } +} + +impl Drop for Device { + fn drop(&mut self) { + self.destroy(); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn event(kind: u32, body: &[u8]) -> Vec { + let mut buf = vec![0u8; EVENT_LEN]; + buf[..4].copy_from_slice(&kind.to_ne_bytes()); + buf[4..4 + body.len()].copy_from_slice(body); + buf + } + + #[test] + fn an_output_report_is_read_from_the_end_of_its_buffer() { + // The size and kind come *after* the 4096-byte data field, which is + // the easiest part of this ABI to get wrong. + let mut body = vec![0u8; DATA_MAX + 3]; + body[..3].copy_from_slice(&[0x05, 0xff, 0x00]); + body[DATA_MAX..DATA_MAX + 2].copy_from_slice(&3u16.to_ne_bytes()); + body[DATA_MAX + 2] = 1; + assert_eq!( + parse(&event(UHID_OUTPUT, &body)), + Some(Event::Output { + kind: 1, + data: vec![0x05, 0xff, 0x00] + }) + ); + } + + #[test] + fn requests_carry_what_they_ask_for() { + let mut body = Vec::new(); + body.extend_from_slice(&42u32.to_ne_bytes()); + body.extend_from_slice(&[0x02, 0]); + assert_eq!( + parse(&event(UHID_GET_REPORT, &body)), + Some(Event::GetReport { + id: 42, + number: 0x02, + kind: 0 + }) + ); + body.extend_from_slice(&2u16.to_ne_bytes()); + body.extend_from_slice(&[0xaa, 0xbb]); + assert_eq!( + parse(&event(UHID_SET_REPORT, &body)), + Some(Event::SetReport { + id: 42, + number: 0x02, + kind: 0, + data: vec![0xaa, 0xbb] + }) + ); + } + + #[test] + fn a_size_larger_than_the_buffer_is_clamped() { + let mut body = vec![0u8; DATA_MAX + 3]; + body[DATA_MAX..DATA_MAX + 2].copy_from_slice(&u16::MAX.to_ne_bytes()); + let Some(Event::Output { data, .. }) = parse(&event(UHID_OUTPUT, &body)) else { + panic!("did not parse"); + }; + assert_eq!(data.len(), DATA_MAX); + } +} diff --git a/apps/nesgamepad/src/uinput.rs b/apps/nesgamepad/src/uinput.rs new file mode 100644 index 00000000..322a1e66 --- /dev/null +++ b/apps/nesgamepad/src/uinput.rs @@ -0,0 +1,434 @@ +//! A virtual input device, through `/dev/uinput`. +//! +//! Raw ioctls rather than a crate: the interface is a dozen calls and four +//! structs, and the part that matters -- force feedback, where the kernel +//! blocks a game's upload until this process answers it -- is the part a +//! wrapper is most likely to get subtly wrong. The struct layouts are checked +//! against the kernel ABI at compile time below. + +use std::ffi::CStr; +use std::fs::OpenOptions; +use std::io; +use std::mem::size_of; +use std::os::fd::{AsRawFd, OwnedFd, RawFd}; +use std::os::unix::fs::OpenOptionsExt; +use std::path::PathBuf; + +/// Event types and codes, from `linux/input-event-codes.h`. +pub mod code { + pub const EV_SYN: u16 = 0x00; + pub const EV_KEY: u16 = 0x01; + pub const EV_ABS: u16 = 0x03; + pub const EV_FF: u16 = 0x15; + pub const EV_UINPUT: u16 = 0x0101; + + pub const SYN_REPORT: u16 = 0; + + pub const BUS_USB: u16 = 0x03; + pub const BUS_VIRTUAL: u16 = 0x06; + + pub const BTN_SOUTH: u16 = 0x130; + pub const BTN_EAST: u16 = 0x131; + pub const BTN_NORTH: u16 = 0x133; + pub const BTN_WEST: u16 = 0x134; + /// Letter names for the same codes as north and west, which some drivers + /// use by letter rather than by position (see `crate::layout`). + pub const BTN_X: u16 = 0x133; + pub const BTN_Y: u16 = 0x134; + pub const BTN_Z: u16 = 0x135; + pub const BTN_TL: u16 = 0x136; + pub const BTN_TR: u16 = 0x137; + pub const BTN_TL2: u16 = 0x138; + pub const BTN_TR2: u16 = 0x139; + pub const BTN_SELECT: u16 = 0x13a; + pub const BTN_START: u16 = 0x13b; + pub const BTN_MODE: u16 = 0x13c; + pub const BTN_THUMBL: u16 = 0x13d; + pub const BTN_THUMBR: u16 = 0x13e; + + pub const ABS_X: u16 = 0x00; + pub const ABS_Y: u16 = 0x01; + pub const ABS_Z: u16 = 0x02; + pub const ABS_RX: u16 = 0x03; + pub const ABS_RY: u16 = 0x04; + pub const ABS_RZ: u16 = 0x05; + pub const ABS_HAT0X: u16 = 0x10; + pub const ABS_HAT0Y: u16 = 0x11; + + pub const FF_RUMBLE: u16 = 0x50; + + pub const UI_FF_UPLOAD: u16 = 1; + pub const UI_FF_ERASE: u16 = 2; +} + +/// One axis and its range. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct AbsAxis { + pub code: u16, + pub min: i32, + pub max: i32, + pub fuzz: i32, + pub flat: i32, +} + +/// `struct input_event` on a 64-bit kernel. +#[repr(C)] +#[derive(Debug, Clone, Copy, Default)] +pub struct InputEvent { + tv_sec: i64, + tv_usec: i64, + pub kind: u16, + pub code: u16, + pub value: i32, +} + +impl InputEvent { + pub fn key(code: u16, down: bool) -> Self { + Self::new(code::EV_KEY, code, i32::from(down)) + } + + pub fn abs(code: u16, value: i32) -> Self { + Self::new(code::EV_ABS, code, value) + } + + pub fn report() -> Self { + Self::new(code::EV_SYN, code::SYN_REPORT, 0) + } + + fn new(kind: u16, code: u16, value: i32) -> Self { + // The time is the kernel's to stamp; a writer's is ignored. + Self { + kind, + code, + value, + ..Self::default() + } + } +} + +#[repr(C)] +struct InputId { + bustype: u16, + vendor: u16, + product: u16, + version: u16, +} + +#[repr(C)] +struct UinputSetup { + id: InputId, + name: [u8; 80], + ff_effects_max: u32, +} + +#[repr(C)] +struct UinputAbsSetup { + code: u16, + // absinfo: value, minimum, maximum, fuzz, flat, resolution + absinfo: [i32; 6], +} + +/// `struct ff_effect`. The union is kept as raw bytes: only its rumble member +/// is ever read, and that is the first four of them. +#[repr(C)] +#[derive(Clone, Copy)] +pub struct FfEffect { + pub kind: u16, + pub id: i16, + direction: u16, + trigger: [u16; 2], + /// length (ms), delay (ms) + replay: [u16; 2], + union: [u64; 4], +} + +impl FfEffect { + /// Strong and weak magnitudes, if this is a rumble effect. + pub fn rumble(&self) -> Option<(u16, u16)> { + if self.kind != code::FF_RUMBLE { + return None; + } + let raw = self.union[0].to_ne_bytes(); + Some(( + u16::from_ne_bytes([raw[0], raw[1]]), + u16::from_ne_bytes([raw[2], raw[3]]), + )) + } + + pub fn length_ms(&self) -> u16 { + self.replay[0] + } +} + +#[repr(C)] +struct UinputFfUpload { + request_id: u32, + retval: i32, + effect: FfEffect, + old: FfEffect, +} + +#[repr(C)] +struct UinputFfErase { + request_id: u32, + retval: i32, + effect_id: u32, +} + +const _: () = { + assert!(size_of::() == 24); + assert!(size_of::() == 92); + assert!(size_of::() == 28); + assert!(size_of::() == 48); + assert!(size_of::() == 104); + assert!(size_of::() == 12); +}; + +// `_IOC` as `asm-generic/ioctl.h` builds it, which is what x86 uses. +const fn ioc(dir: u64, nr: u64, size: usize) -> u64 { + (dir << 30) | ((size as u64) << 16) | ((b'U' as u64) << 8) | nr +} +const NONE: u64 = 0; +const WRITE: u64 = 1; +const READ: u64 = 2; + +const UI_DEV_CREATE: u64 = ioc(NONE, 1, 0); +const UI_DEV_DESTROY: u64 = ioc(NONE, 2, 0); +const UI_DEV_SETUP: u64 = ioc(WRITE, 3, size_of::()); +const UI_ABS_SETUP: u64 = ioc(WRITE, 4, size_of::()); +const UI_SET_EVBIT: u64 = ioc(WRITE, 100, size_of::()); +const UI_SET_KEYBIT: u64 = ioc(WRITE, 101, size_of::()); +const UI_SET_ABSBIT: u64 = ioc(WRITE, 103, size_of::()); +const UI_SET_FFBIT: u64 = ioc(WRITE, 107, size_of::()); +const UI_BEGIN_FF_UPLOAD: u64 = ioc(READ | WRITE, 200, size_of::()); +const UI_END_FF_UPLOAD: u64 = ioc(WRITE, 201, size_of::()); +const UI_BEGIN_FF_ERASE: u64 = ioc(READ | WRITE, 202, size_of::()); +const UI_END_FF_ERASE: u64 = ioc(WRITE, 203, size_of::()); +const SYSNAME_LEN: usize = 64; +const UI_GET_SYSNAME: u64 = ioc(READ, 44, SYSNAME_LEN); + +/// How many effects a game may have uploaded at once. The same as ff-memless, +/// which is what real controller drivers are built on. +const FF_EFFECTS_MAX: u32 = 16; + +fn ioctl(fd: RawFd, request: u64, arg: *mut T) -> io::Result<()> { + // SAFETY: every request above is paired with the struct its size encodes. + if unsafe { libc::ioctl(fd, request as _, arg) } < 0 { + return Err(io::Error::last_os_error()); + } + Ok(()) +} + +fn ioctl_int(fd: RawFd, request: u64, value: u16) -> io::Result<()> { + // SAFETY: the `UI_SET_*BIT` requests take their argument by value. + if unsafe { libc::ioctl(fd, request as _, libc::c_int::from(value)) } < 0 { + return Err(io::Error::last_os_error()); + } + Ok(()) +} + +/// What a device was built with. +pub struct Spec<'a> { + pub name: &'a str, + pub bus: u16, + pub vendor: u16, + pub product: u16, + pub version: u16, + pub keys: &'a [u16], + pub axes: &'a [AbsAxis], +} + +/// A device that exists for as long as this does. +pub struct Device { + fd: OwnedFd, + /// `inputN`, the kernel's name for it. + pub sysname: String, +} + +/// What reading the device produced. +#[derive(Debug, Clone, Copy)] +pub enum Request { + /// A game uploaded or replaced an effect. It has already been accepted. + Upload(FfEffect), + Erase(i16), + /// A game started (`true`) or stopped an effect. + Play { + id: i16, + on: bool, + }, +} + +impl std::fmt::Debug for FfEffect { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("FfEffect") + .field("kind", &self.kind) + .field("id", &self.id) + .field("rumble", &self.rumble()) + .field("length_ms", &self.length_ms()) + .finish() + } +} + +impl Device { + pub fn create(spec: &Spec<'_>) -> io::Result { + let file = OpenOptions::new() + .read(true) + .write(true) + .custom_flags(libc::O_NONBLOCK | libc::O_CLOEXEC) + .open("/dev/uinput")?; + let fd: OwnedFd = file.into(); + let raw = fd.as_raw_fd(); + + ioctl_int(raw, UI_SET_EVBIT, code::EV_KEY)?; + for &key in spec.keys { + ioctl_int(raw, UI_SET_KEYBIT, key)?; + } + ioctl_int(raw, UI_SET_EVBIT, code::EV_ABS)?; + for axis in spec.axes { + ioctl_int(raw, UI_SET_ABSBIT, axis.code)?; + let mut setup = UinputAbsSetup { + code: axis.code, + absinfo: [ + (axis.min + axis.max) / 2, + axis.min, + axis.max, + axis.fuzz, + axis.flat, + 0, + ], + }; + ioctl(raw, UI_ABS_SETUP, &mut setup)?; + } + ioctl_int(raw, UI_SET_EVBIT, code::EV_FF)?; + ioctl_int(raw, UI_SET_FFBIT, code::FF_RUMBLE)?; + + let mut setup = UinputSetup { + id: InputId { + bustype: spec.bus, + vendor: spec.vendor, + product: spec.product, + version: spec.version, + }, + name: [0; 80], + ff_effects_max: FF_EFFECTS_MAX, + }; + // One short of the buffer, so the kernel always finds a terminator. + let name = spec.name.as_bytes(); + let len = name.len().min(setup.name.len() - 1); + setup.name[..len].copy_from_slice(&name[..len]); + ioctl(raw, UI_DEV_SETUP, &mut setup)?; + ioctl(raw, UI_DEV_CREATE, std::ptr::null_mut::())?; + + let mut sysname = [0u8; SYSNAME_LEN]; + ioctl(raw, UI_GET_SYSNAME, sysname.as_mut_ptr())?; + let sysname = CStr::from_bytes_until_nul(&sysname) + .map_err(|_| io::Error::other("unterminated sysname"))? + .to_string_lossy() + .into_owned(); + Ok(Self { fd, sysname }) + } + + /// Where the kernel put it in sysfs. + pub fn syspath(&self) -> PathBuf { + PathBuf::from("/sys/devices/virtual/input").join(&self.sysname) + } + + pub fn write(&self, events: &[InputEvent]) -> io::Result<()> { + let bytes = std::mem::size_of_val(events); + // SAFETY: `InputEvent` is `repr(C)` plain data. + let n = unsafe { libc::write(self.fd.as_raw_fd(), events.as_ptr().cast(), bytes) }; + if n < 0 { + return Err(io::Error::last_os_error()); + } + Ok(()) + } + + /// Everything waiting on the device, answering any upload or erase as it + /// goes. Returns once the device has nothing more to say. + /// + /// The answering is not optional: a game's upload ioctl blocks in the + /// kernel until this process ends it, so an unread request is a game + /// frozen mid-call. + pub fn drain(&self) -> io::Result> { + let raw = self.fd.as_raw_fd(); + let mut out = Vec::new(); + loop { + let mut event = InputEvent::default(); + // SAFETY: reading one plain-data event into a buffer its size. + let n = unsafe { + libc::read( + raw, + (&mut event as *mut InputEvent).cast(), + size_of::(), + ) + }; + if n < 0 { + let error = io::Error::last_os_error(); + if error.kind() == io::ErrorKind::WouldBlock { + return Ok(out); + } + return Err(error); + } + if n as usize != size_of::() { + return Ok(out); + } + match (event.kind, event.code) { + (code::EV_UINPUT, code::UI_FF_UPLOAD) => { + // SAFETY: plain data, filled in by the kernel. + let mut upload: UinputFfUpload = unsafe { std::mem::zeroed() }; + upload.request_id = event.value as u32; + ioctl(raw, UI_BEGIN_FF_UPLOAD, &mut upload)?; + // Rumble is the only effect the device advertises, so the + // kernel refuses anything else before it gets here. + upload.retval = 0; + let effect = upload.effect; + ioctl(raw, UI_END_FF_UPLOAD, &mut upload)?; + out.push(Request::Upload(effect)); + } + (code::EV_UINPUT, code::UI_FF_ERASE) => { + let mut erase = UinputFfErase { + request_id: event.value as u32, + retval: 0, + effect_id: 0, + }; + ioctl(raw, UI_BEGIN_FF_ERASE, &mut erase)?; + erase.retval = 0; + let id = erase.effect_id as i16; + ioctl(raw, UI_END_FF_ERASE, &mut erase)?; + out.push(Request::Erase(id)); + } + (code::EV_FF, id) => out.push(Request::Play { + id: id as i16, + on: event.value > 0, + }), + _ => {} + } + } + } +} + +impl AsRawFd for Device { + fn as_raw_fd(&self) -> RawFd { + self.fd.as_raw_fd() + } +} + +impl Device { + /// Remove the device now, whoever else still holds this. + /// + /// Closing the descriptor would do it too, but only when the last holder + /// closes it. Destroying twice is harmless: the second is refused. + pub fn destroy(&self) { + let _ = ioctl( + self.fd.as_raw_fd(), + UI_DEV_DESTROY, + std::ptr::null_mut::(), + ); + } +} + +impl Drop for Device { + fn drop(&mut self) { + self.destroy(); + } +} diff --git a/apps/neshub/src/ipc_listener.rs b/apps/neshub/src/ipc_listener.rs index c3f977cb..6b5d738a 100644 --- a/apps/neshub/src/ipc_listener.rs +++ b/apps/neshub/src/ipc_listener.rs @@ -249,6 +249,94 @@ pub async fn run_input_ipc_listener( tracing::info!("input IPC listener exited"); } +/// The box's gamepad side, which dials in here. +/// +/// Every client's gamepad messages go out already tagged with which client +/// (see `nesprotocol::gamepad`), and feedback comes back tagged the same way +/// and is routed to that client. Nothing here reads a message: what a +/// controller is belongs to the other end of this socket. +/// +/// One peer at a time, like the input socket. Messages sent while none is +/// connected are dropped, which costs nothing: the gamepad side asks for any +/// controller it has not heard of the next time that controller's state +/// arrives. +pub async fn run_gamepad_ipc_listener(socket_path: PathBuf, mgr: Arc) { + if socket_path.exists() { + let _ = std::fs::remove_file(&socket_path); + } + let listener = match UnixListener::bind(&socket_path) { + Ok(l) => l, + Err(e) => { + tracing::error!( + "Failed to bind gamepad IPC socket {}: {e}", + socket_path.display() + ); + return; + } + }; + tracing::info!("Gamepad IPC listening on {}", socket_path.display()); + + loop { + let stream = match listener.accept().await { + Ok((stream, _)) => stream, + Err(e) => { + tracing::error!("gamepad IPC accept error: {e}"); + break; + } + }; + tracing::info!("gamepad service connected"); + let (mut read_half, mut write_half) = stream.into_split(); + let mut messages = mgr.gamepad_messages(); + + let write_handle = tokio::spawn(async move { + loop { + match messages.recv().await { + Ok(frame) => { + if write_half.write_all(&frame).await.is_err() { + tracing::debug!("gamepad IPC write failed"); + break; + } + } + // A controller's state is a snapshot, so a lost one is + // superseded by the next; a lost connect is asked for + // again. Lagging is survivable, and worth knowing about. + Err(RecvError::Lagged(n)) => { + tracing::warn!("gamepad messages lagged by {n}"); + } + Err(RecvError::Closed) => break, + } + } + }); + + let read_mgr = mgr.clone(); + let read_handle = tokio::spawn(async move { + let mut len_buf = [0u8; 2]; + loop { + if read_half.read_exact(&mut len_buf).await.is_err() { + break; + } + let mut body = vec![0u8; u16::from_le_bytes(len_buf) as usize]; + if read_half.read_exact(&mut body).await.is_err() { + break; + } + let Some((session, message)) = nesprotocol::gamepad::decode_ipc(&body) else { + tracing::debug!("short gamepad feedback frame"); + continue; + }; + read_mgr + .send_gamepad_feedback(session, message.to_vec()) + .await; + } + }); + + tokio::select! { + _ = write_handle => {} + _ = read_handle => {} + } + tracing::info!("gamepad service disconnected"); + } +} + pub async fn run_stats_ipc_listener( socket_path: PathBuf, stats_tx: tokio::sync::mpsc::UnboundedSender>, diff --git a/apps/neshub/src/main.rs b/apps/neshub/src/main.rs index b5fd2e36..c8825076 100644 --- a/apps/neshub/src/main.rs +++ b/apps/neshub/src/main.rs @@ -84,6 +84,15 @@ struct Args { #[arg(long, env = "NESTRI_MAX_BITRATE")] max_bitrate_kbps: Option, + /// Path for the gamepad IPC socket (neshub ↔ nesgamepad). neshub + /// listens; the gamepad service dials in. + #[arg( + long, + env = "NESTRI_GAMEPAD_IPC", + default_value = "/tmp/nestri-gamepad.sock" + )] + gamepad_ipc: PathBuf, + /// Socket nescope sends screenshots on. neshub listens; nescope dials out. #[arg( long, @@ -362,6 +371,12 @@ async fn main() -> Result<()> { async move { ipc_listener::run_stats_ipc_listener(stats_ipc, stx).await } }); + tokio::spawn({ + let mgr = session_manager.clone(); + let gamepad_ipc = args.gamepad_ipc.clone(); + async move { ipc_listener::run_gamepad_ipc_listener(gamepad_ipc, mgr).await } + }); + let ticket_ipc = args.ticket_ipc.clone(); tokio::spawn({ // The endpoint rather than a ticket made from it: the addresses it can @@ -449,6 +464,7 @@ async fn main() -> Result<()> { let _ = std::fs::remove_file(&args.audio_ipc); let _ = std::fs::remove_file(&args.input_ipc); let _ = std::fs::remove_file(&args.stats_ipc); + let _ = std::fs::remove_file(&args.gamepad_ipc); let _ = std::fs::remove_file("/tmp/nescapture-cmd.sock"); let _ = std::fs::remove_file(&args.ticket_ipc); Ok(()) diff --git a/apps/neshub/src/session.rs b/apps/neshub/src/session.rs index 90d8d5ff..532ffd27 100644 --- a/apps/neshub/src/session.rs +++ b/apps/neshub/src/session.rs @@ -11,8 +11,8 @@ use nesprotocol::{BIDI_CONTROL, BIDI_INPUT, Carrier, STREAM_CURSOR, STREAM_STATS use nesprotocol::{ControlMode, ReceiverReport, decode_control_mode, decode_receiver_report}; use nesprotocol::{FRAME_HDR_LEN, STREAM_VERSION, encode_frame}; use nesprotocol::{ - MSG_CLIENT_CAPS, MSG_CONTROL_MODE, MSG_ENCODE_SETTINGS, MSG_IDR_REQUEST, MSG_INPUT_BATCH, - MSG_RECEIVER_REPORT, + MSG_CLIENT_CAPS, MSG_CONTROL_MODE, MSG_ENCODE_SETTINGS, MSG_GAMEPAD, MSG_GAMEPAD_FEEDBACK, + MSG_IDR_REQUEST, MSG_INPUT_BATCH, MSG_RECEIVER_REPORT, }; use crate::control::{Controller, PathView}; @@ -60,6 +60,14 @@ pub struct ClientSession { latest_report: Arc>>, relay_ms: Arc, input_broadcast: tokio::sync::broadcast::Sender>, + /// This client's number on the gamepad socket, so the box's side can tell + /// two clients' controllers apart. Assigned by the manager, never reused + /// within one hub's lifetime. + gamepad_session: u32, + gamepad_tx: tokio::sync::broadcast::Sender>, + /// Rumble and the like, for this client's input stream to carry back. + send_gamepad_feedback: tokio::sync::mpsc::UnboundedSender>, + pending_gamepad_feedback: Option>>, idr_cmd_tx: tokio::sync::mpsc::UnboundedSender>, controller: Arc>, send_video: tokio::sync::mpsc::UnboundedSender>, @@ -92,10 +100,13 @@ impl ClientSession { /// A client with no connections yet. They attach as they are accepted. pub fn new( input_broadcast: tokio::sync::broadcast::Sender>, + gamepad_session: u32, + gamepad_tx: tokio::sync::broadcast::Sender>, relay_ms: Arc, idr_cmd_tx: tokio::sync::mpsc::UnboundedSender>, controller: Arc>, ) -> Self { + let (gamepad_feedback_tx, gamepad_feedback_rx) = tokio::sync::mpsc::unbounded_channel(); let (video_tx, video_rx) = tokio::sync::mpsc::unbounded_channel::>(); let (audio_tx, audio_rx) = tokio::sync::mpsc::unbounded_channel::>(); let (cursor_tx, cursor_rx) = tokio::sync::mpsc::unbounded_channel::>(); @@ -108,6 +119,10 @@ impl ClientSession { latest_report: Arc::new(std::sync::Mutex::new(None)), relay_ms, input_broadcast, + gamepad_session, + gamepad_tx, + send_gamepad_feedback: gamepad_feedback_tx, + pending_gamepad_feedback: Some(gamepad_feedback_rx), idr_cmd_tx, controller, send_video: video_tx, @@ -177,10 +192,18 @@ impl ClientSession { })); } Carrier::Input => { + let Some(feedback) = self.pending_gamepad_feedback.take() else { + debug!("input carrier attached twice; ignoring the second"); + return; + }; self.other_conns.push(conn.clone()); let broadcast = self.input_broadcast.clone(); + let gamepad = Gamepads { + session: self.gamepad_session, + to_box: self.gamepad_tx.clone(), + }; self.tasks.push(tokio::spawn(async move { - run_input_reader(conn, broadcast).await + run_input_reader(conn, broadcast, gamepad, feedback).await })); } Carrier::Control => { @@ -279,6 +302,13 @@ impl ClientSession { } } + /// Hand one gamepad feedback message to this client's input stream. + pub fn send_gamepad_feedback(&self, message: Vec) { + if let Err(e) = self.send_gamepad_feedback.send(message) { + debug!("failed to send gamepad feedback: {e}"); + } + } + pub fn send_stats_data(&self, data: Vec) { if let Err(e) = self.send_stats.send(data) { debug!("failed to send stats data: {e}"); @@ -293,10 +323,14 @@ impl ClientSession { /// Shared because input and control differ only in which messages they expect: /// the framing, the announcement and the reconnect behaviour are the same, and /// two copies of that would drift. +/// +/// With `outbound`, the stream's other half stays open and carries those +/// frames back to the client; without, it is finished once announced. async fn run_framed_reader( conn: Connection, stream_type: u8, label: &'static str, + mut outbound: Option<(u8, tokio::sync::mpsc::UnboundedReceiver>)>, mut handle: F, ) where F: FnMut(u8, Vec) -> Fut, @@ -314,24 +348,60 @@ async fn run_framed_reader( debug!("{label} type byte write failed"); break; } - let _ = send.finish(); - debug!("{label} bidi stream ready, reading framed messages"); - loop { - // [4B len][1B type][2B seq][payload] - let mut len_buf = [0u8; 4]; - if recv.read_exact(&mut len_buf).await.is_err() { - break; - } - let frame_len = u32::from_le_bytes(len_buf) as usize; - if frame_len < 3 || frame_len > 65536 { - break; - } - let mut frame = vec![0u8; frame_len]; - if recv.read_exact(&mut frame).await.is_err() { - break; - } - handle(frame[0], frame[3..].to_vec()).await; + if outbound.is_none() { + let _ = send.finish(); } + debug!("{label} bidi stream ready, reading framed messages"); + // Frames are read on a task of their own. Reading one is + // several awaits, and a read cancelled halfway -- which is what + // waiting on outbound frames beside it would do -- loses the + // framing for the rest of the stream. + let (frames_tx, mut frames) = tokio::sync::mpsc::unbounded_channel::>(); + let reader = tokio::spawn(async move { + loop { + // [4B len][1B type][2B seq][payload] + let mut len_buf = [0u8; 4]; + if recv.read_exact(&mut len_buf).await.is_err() { + break; + } + let frame_len = u32::from_le_bytes(len_buf) as usize; + if frame_len < 3 || frame_len > 65536 { + break; + } + let mut frame = vec![0u8; frame_len]; + if recv.read_exact(&mut frame).await.is_err() { + break; + } + if frames_tx.send(frame).is_err() { + break; + } + } + }); + let mut seq: u16 = 0; + loop { + let outgoing = async { + match outbound.as_mut() { + Some((_, rx)) => rx.recv().await, + None => std::future::pending().await, + } + }; + tokio::select! { + frame = frames.recv() => match frame { + Some(frame) => handle(frame[0], frame[3..].to_vec()).await, + None => break, + }, + Some(payload) = outgoing => { + let msg_type = outbound.as_ref().map_or(0, |(t, _)| *t); + let mut buf = Vec::with_capacity(FRAME_HDR_LEN + payload.len()); + encode_frame(&mut buf, msg_type, seq, &payload); + seq = seq.wrapping_add(1); + if send.write_all(&buf).await.is_err() { + break; + } + } + } + } + reader.abort(); } Err(e) => { debug!("{label} open_bi failed: {e}"); @@ -383,22 +453,46 @@ fn split_input_events(payload: &[u8]) -> Vec> { out } -/// Input events, and nothing else. +/// Where one client's gamepad messages go. +struct Gamepads { + session: u32, + to_box: tokio::sync::broadcast::Sender>, +} + +/// Input events and gamepad messages, and nothing else. /// /// On its own connection so a keypress never waits behind a video keyframe. +/// Gamepad messages are forwarded unread, tagged with the client: what a +/// controller is and what to make of it is the box's side to decide, not the +/// hub's. The stream's return half carries that side's feedback -- rumble -- +/// back. async fn run_input_reader( conn: Connection, input_broadcast: tokio::sync::broadcast::Sender>, + gamepads: Gamepads, + feedback: tokio::sync::mpsc::UnboundedReceiver>, ) { - run_framed_reader(conn, BIDI_INPUT, "input", |msg_type, payload| { + let outbound = Some((MSG_GAMEPAD_FEEDBACK, feedback)); + run_framed_reader(conn, BIDI_INPUT, "input", outbound, |msg_type, payload| { let input_broadcast = input_broadcast.clone(); + let to_box = gamepads.to_box.clone(); + let session = gamepads.session; async move { - if msg_type != MSG_INPUT_BATCH { - debug!("unknown input msg type: {msg_type}"); - return; - } - for event in split_input_events(&payload) { - let _ = input_broadcast.send(event); + match msg_type { + MSG_INPUT_BATCH => { + for event in split_input_events(&payload) { + let _ = input_broadcast.send(event); + } + } + MSG_GAMEPAD => { + let mut frame = Vec::with_capacity(6 + payload.len()); + nesprotocol::gamepad::encode_ipc(&mut frame, session, &payload); + // An error is nobody listening: the box's gamepad side is + // not up, and a controller it never heard of will be asked + // for again once it is. + let _ = to_box.send(frame); + } + other => debug!("unknown input msg type: {other}"), } } }) @@ -413,7 +507,7 @@ async fn run_control_reader( controller: Arc>, awaiting_keyframe: Arc, ) { - run_framed_reader(conn, BIDI_CONTROL, "control", |msg_type, payload| { + run_framed_reader(conn, BIDI_CONTROL, "control", None, |msg_type, payload| { let idr_cmd_tx = idr_cmd_tx.clone(); let latest_report = latest_report.clone(); let controller = controller.clone(); @@ -658,6 +752,9 @@ fn is_keyframe(payload: &[u8]) -> bool { pub struct SessionManager { sessions: Arc>>, + /// Every client's gamepad messages, already framed for the gamepad socket. + gamepad_tx: tokio::sync::broadcast::Sender>, + next_gamepad_session: AtomicU32, /// Video bytes, split by what they were. /// /// One counter could not tell an encoder ignoring its bitrate target from a @@ -685,8 +782,11 @@ pub struct SessionManager { impl SessionManager { pub fn new() -> Self { + let (gamepad_tx, _) = tokio::sync::broadcast::channel(256); Self { sessions: Arc::new(Mutex::new(HashMap::new())), + gamepad_tx, + next_gamepad_session: AtomicU32::new(1), video_key_bytes: AtomicU64::new(0), video_delta_bytes: AtomicU64::new(0), keyframes: AtomicU64::new(0), @@ -720,6 +820,8 @@ impl SessionManager { let session = sessions.entry(id).or_insert_with(|| { ClientSession::new( input_broadcast, + self.next_gamepad_session.fetch_add(1, Ordering::Relaxed), + self.gamepad_tx.clone(), self.relay_ms.clone(), idr_cmd_tx, controller, @@ -740,8 +842,35 @@ impl SessionManager { // session as removed four times over -- three of them describing a // session that had already gone, which reads like four clients // leaving. - if sessions.remove(id).is_some() { + if let Some(session) = sessions.remove(id) { info!(remote = %id.fmt_short(), "client session removed ({} remaining)", sessions.len()); + // Its controllers go with it. The client cannot say so itself -- + // it is the thing that went -- and a controller left plugged in + // would still be there for the game, held by nobody. + let mut message = Vec::with_capacity(1); + nesprotocol::gamepad::PadMessage::SessionEnd.encode(&mut message); + let mut frame = Vec::with_capacity(7); + nesprotocol::gamepad::encode_ipc(&mut frame, session.gamepad_session, &message); + let _ = self.gamepad_tx.send(frame); + } + } + + /// A receiver for every client's gamepad messages, for the gamepad socket. + pub fn gamepad_messages(&self) -> tokio::sync::broadcast::Receiver> { + self.gamepad_tx.subscribe() + } + + /// Route gamepad feedback to the client it names. + /// + /// A client that has gone is not an error: rumble a game started a moment + /// before its player left has nowhere to go, and that is fine. + pub async fn send_gamepad_feedback(&self, gamepad_session: u32, message: Vec) { + let sessions = self.sessions.lock().await; + if let Some(session) = sessions + .values() + .find(|s| s.gamepad_session == gamepad_session) + { + session.send_gamepad_feedback(message); } } diff --git a/apps/nesinit/src/services.rs b/apps/nesinit/src/services.rs index 9244a599..59ae5a87 100644 --- a/apps/nesinit/src/services.rs +++ b/apps/nesinit/src/services.rs @@ -221,7 +221,8 @@ const WRITABLE: &[(&str, &str)] = &[ /// /// Ported from the nine init scripts this replaces, and the ordering is theirs: /// the bus before anything that speaks on it, audio before whatever plays into -/// it, and the hub last because it binds the sockets the rest connect to. +/// it, and the hub after them because it binds the sockets the rest connect to. +/// Only something that dials the hub, and waits for it, comes after. pub const STACK: &[Service] = &[ Service { name: "dbus-system", @@ -337,6 +338,23 @@ pub const STACK: &[Service] = &[ umask: None, ready: None, }, + Service { + name: "nesgamepad", + argv: &["/usr/bin/nesgamepad"], + env: &[], + // Root, and it has to be: it creates devices through /dev/uinput, + // opens their nodes to the workload, and announces them the way udev + // would -- and libudev ignores an announcement from anyone but root. + user: None, + // Optional: a session without controllers is still played with a + // keyboard and mouse. + cost: "controllers plugged into the client do not reach the game", + required: false, + umask: None, + // It dials the hub rather than the other way round, and redials until + // the hub is there, so nothing waits on it. + ready: None, + }, ]; /// The stack as running processes. diff --git a/apps/nesinit/src/system.rs b/apps/nesinit/src/system.rs index 12313df0..db6a5511 100644 --- a/apps/nesinit/src/system.rs +++ b/apps/nesinit/src/system.rs @@ -13,6 +13,10 @@ // compositor handles input through Wayland and opens nothing udev provides, so // dropping it costs a box nothing and saves it a daemon and a settle. // +// The one thing in a box that does need udev is a game looking for +// controllers, and those are devices `nesgamepad` creates itself -- so it +// stands in for udev for exactly those, and nothing else has to. +// // # Best effort, one line per failure, each naming a cost // // Same discipline as the early filesystems: refusing to boot over any one of diff --git a/build/Containerfile b/build/Containerfile index b77723b2..a5bd6bc3 100644 --- a/build/Containerfile +++ b/build/Containerfile @@ -2,8 +2,8 @@ # nestri guest rootfs — the open half # # Builds a bootable Arch image containing Mesa (virtio-gpu native context) -# and the five open guest components: nesinit, nescope, neshub, neswire, -# nescapture. Two leaf targets, selected with `--target`: +# and the six open guest components: nesinit, nescope, neshub, neswire, +# nescapture, nesgamepad. Two leaf targets, selected with `--target`: # # runtime_prod stripped, root locked (default: `make build`) # runtime_debug debug tools, autologin root (`make build-debug`) @@ -141,18 +141,20 @@ COPY apps/nescope apps/nescope COPY apps/neshub apps/neshub COPY apps/neswire apps/neswire COPY apps/nescapture apps/nescapture +COPY apps/nesgamepad apps/nesgamepad FROM nestri-src AS nestri-build RUN --mount=type=cache,target=/root/.cargo/registry \ --mount=type=cache,target=/build/nestri/target \ cargo build --release \ - -p nesinit -p nescope -p neshub -p neswire -p nescapture && \ + -p nesinit -p nescope -p neshub -p neswire -p nescapture -p nesgamepad && \ mkdir -p /artifacts/nestri/usr/bin /artifacts/nestri/usr/lib \ /artifacts/nestri/usr/share/vulkan/implicit_layer.d && \ install -Dm755 target/release/nesinit /artifacts/nestri/usr/bin/nesinit && \ install -Dm755 target/release/nescope /artifacts/nestri/usr/bin/nescope && \ install -Dm755 target/release/neshub /artifacts/nestri/usr/bin/neshub && \ install -Dm755 target/release/neswire /artifacts/nestri/usr/bin/neswire && \ + install -Dm755 target/release/nesgamepad /artifacts/nestri/usr/bin/nesgamepad && \ install -Dm755 target/release/libnescapture_layer.so \ /artifacts/nestri/usr/lib/libnescapture_layer.so && \ install -Dm644 apps/nescapture/manifest/VK_LAYER_nescapture.json \ @@ -264,7 +266,7 @@ RUN pacman -Syu --noconfirm --needed \ expat zlib llvm-libs lm_sensors elfutils libva shaderc vulkan-icd-loader \ pixman libxkbcommon xcb-util-keysyms xorg-xwayland \ pipewire pipewire-audio pipewire-pulse libpulse wireplumber opus \ - python libunwind \ + python libunwind sdl2-compat \ && rm -f /usr/share/libalpm/hooks/dbus-reload.hook \ && pacman -Rdd --noconfirm systemd systemd-sysvcompat \ && pacman -Scc --noconfirm @@ -291,6 +293,16 @@ RUN pacman -Syu --noconfirm --needed \ # reads like an ordinary finish. Found 2026-09-12, on the first session that # got as far as launching one. +# `sdl2-compat` is Wine's controller support, and the only one there is for a +# controller that is not a Steam Input one. Proton's device bus reads evdev +# controllers through SDL2 and nothing else -- its udev backend defers every +# evdev device to the SDL one on purpose -- and it loads the library with +# `dlopen`, so its absence fails nothing at build time or at launch. A game +# simply sees no controller, while `nesgamepad` reports it plugged in. Found +# 2026-09-27, on the first session with one. Real Steam brings SDL in its +# runtime, which is why this is never missed anywhere else. On Arch the +# library is SDL2's API over SDL3, which arrives as its dependency. + # `dbus-reload.hook` is deleted above, before the removal rather than after, # and it is the whole reason that line is there: the hook runs # `/usr/share/libalpm/scripts/systemd-hook`, which systemd owns, so the @@ -494,7 +506,7 @@ RUN for intruder in /usr/lib/systemd/systemd /sbin/openrc-init /usr/bin/openrc-i RUN failed=0; \ : > /tmp/missing-libs; \ for f in /usr/bin/nesinit /usr/bin/nescope /usr/bin/neshub /usr/bin/neswire \ - /usr/lib/libnescapture_layer.so \ + /usr/bin/nesgamepad /usr/lib/libnescapture_layer.so /usr/lib/libSDL2-2.0.so.0 \ /usr/bin/dbus-daemon /usr/bin/pipewire /usr/bin/pipewire-pulse \ /usr/bin/wireplumber /usr/bin/ip \ /usr/lib/libgallium-*.so /usr/lib/libEGL_mesa.so.0 \ @@ -517,6 +529,7 @@ RUN failed=0; \ fi RUN for required in /usr/bin/nesinit /usr/bin/nescope /usr/bin/neshub /usr/bin/neswire \ + /usr/bin/nesgamepad /usr/lib/libSDL2-2.0.so.0 \ /usr/bin/dbus-daemon /usr/bin/pipewire /usr/bin/pipewire-pulse \ /usr/bin/wireplumber /usr/bin/ip /usr/bin/python3 \ /usr/share/steam/compatibilitytools.d/proton-cachyos/proton; do \ diff --git a/build/README.md b/build/README.md index d569a1b0..6cd1781a 100644 --- a/build/README.md +++ b/build/README.md @@ -1,10 +1,10 @@ # build/ — the guest rootfs Builds a bootable Arch image for the box's virtio-blk root: Mesa (virtio-gpu -native context) plus the five open guest components — +native context) plus the six open guest components — [`nesinit`](../apps/nesinit), [`nescope`](../apps/nescope), [`neshub`](../apps/neshub), [`neswire`](../apps/neswire), -[`nescapture`](../apps/nescapture) — laid out +[`nescapture`](../apps/nescapture), [`nesgamepad`](../apps/nesgamepad) — laid out the way [borealis](https://chromium.googlesource.com/chromiumos/overlays/board-overlays/+/main/project-borealis) lays out its `build/`: one big multi-stage `Containerfile`, `--target` picks the flavor, `etc/` holds the files that get overlaid onto the image verbatim. @@ -49,7 +49,7 @@ Three things worth knowing about how this is put together: explicit sanity check for doesn't exist here to check for. 3. **One `cargo build --release --workspace`, not one stage per binary.** - `nescope`, `neshub`, `neswire` and `nescapture` share one Cargo workspace + `nescope`, `neshub`, `neswire`, `nescapture` and `nesgamepad` share one Cargo workspace and one `Cargo.lock` — a BuildKit cache mount on `target/` gives cargo's own incremental compiler per-crate isolation without needing a separate Docker stage (and a separate full rebuild of `nesprotocol`) per binary. @@ -254,9 +254,9 @@ needs — was paid for an init system that is no longer here. | was | now | |---|---| -| `devfs`, `dmesg`, `udev`, `udev-trigger` | `devtmpfs` makes the nodes; init sets the two modes that matter. The compositor takes input through Wayland and opens nothing `udev` provides | +| `devfs`, `dmesg`, `udev`, `udev-trigger` | `devtmpfs` makes the nodes; init sets the two modes that matter. The compositor takes input through Wayland and opens nothing `udev` provides. Controllers are the one thing a game finds through `udev`, and `nesgamepad` announces the ones it creates itself | | `guest-net`, `hostname`, `xdg-runtime`, `cgroups` | init, before it dials out | -| `dbus`, `dbus-session`, `pipewire`, `wireplumber`, `neshub`, `neswire` | a table compiled into `nesinit` | +| `dbus`, `dbus-session`, `pipewire`, `wireplumber`, `neshub`, `neswire`, `nesgamepad` | a table compiled into `nesinit` | | `nescope` in the `default` runlevel | **not a service.** It wraps the workload and is started by a launch, with that launch's geometry, and dies with it | | `agetty` on `hvc0` | nothing. See below | | `/etc/fstab` | init's own mounts, and shares named in the boot descriptor | diff --git a/build/kernel/nestri.fragment b/build/kernel/nestri.fragment index 5b6b573a..c9508073 100644 --- a/build/kernel/nestri.fragment +++ b/build/kernel/nestri.fragment @@ -14,6 +14,20 @@ # NTSYNC is a great improvement over fsync and esync approaches. CONFIG_NTSYNC=y +# ── Controllers ────────────────────────────────────────── +# A controller of a family the box can rebuild is made here as the device +# itself, from the real one's descriptor, through uhid; hid-generic binds to +# it and gives it a hidraw node. That node is what Proton reads a controller through +# when it wants the device rather than a gamepad abstraction of it, and it is +# the only way a game that parses a controller's own reports -- to tell a +# DualShock from an Xbox pad, say, and show the right buttons -- can work. +# +# Two generic switches and no vendor drivers, on purpose: games read the +# hidraw node, not whatever a vendor driver would have made of the device, so +# no controller family needs a switch of its own. HID_GENERIC is already on. +CONFIG_UHID=y +CONFIG_HIDRAW=y + # ── Timers ─────────────────────────────────────────────── # The one that cost a day of silent audio. The guest has no sound hardware, so # PipeWire drives its whole graph off a timerfd at a 2.67ms cycle, which a diff --git a/crates/nesprotocol/src/gamepad.rs b/crates/nesprotocol/src/gamepad.rs new file mode 100644 index 00000000..8722fe26 --- /dev/null +++ b/crates/nesprotocol/src/gamepad.rs @@ -0,0 +1,392 @@ +// Gamepads: what the client says about the controllers plugged into it, and +// what the box says back. +// +// Client → box travels as `MSG_GAMEPAD` frames on the input stream, one +// message per frame. Box → client travels as `MSG_GAMEPAD_FEEDBACK` frames on +// the same stream's other half. The hub forwards both without reading them, +// tagged with which client they belong to (see `encode_ipc`). +// +// # The client describes the controller, it does not choose one +// +// A `Connect` carries the real device's identity as the client saw it, and the +// box decides what a game inside it should see. Nothing here names a +// controller family, and there is no default one. The identity is only what +// every platform's gamepad API can tell -- vendor, product, name -- so a +// client needs nothing beyond such an API, and all the knowledge of what a +// controller looks like to a game lives in the box, in one place. +// +// # State, not edges +// +// A `State` is the whole controller every time. A lost or reordered edge would +// leave a button held forever; a snapshot can only ever be stale until the next +// one. Button names are positional (south, east, ...), after the kernel's own +// gamepad layout, so no face-button lettering from any one vendor is implied. + +/// A controller appeared on the client. Payload: +/// `[slot][vendor u16][product u16][name_len u8][name]`. +pub const PAD_CONNECT: u8 = 0x00; +/// The whole of a controller's state. Payload: +/// `[slot][buttons u32][lx i16][ly i16][rx i16][ry i16][lt u16][rt u16]`. +pub const PAD_STATE: u8 = 0x01; +/// A controller went away. Payload: `[slot]`. +pub const PAD_DISCONNECT: u8 = 0x02; + +/// Rumble to play on a client's controller. Payload: +/// `[slot][strong u16][weak u16][duration_ms u16]`. Both magnitudes zero is a +/// stop; a duration of zero plays until the next message for that slot. +pub const PAD_RUMBLE: u8 = 0x80; +/// The box has no controller in this slot: send its `Connect` again. Payload: +/// `[slot]`. +/// +/// Asked when state arrives for a slot the box does not know, which is what a +/// client sees after the box's side restarted under it. Re-announcing is +/// cheaper than either end trying to remember the other's view. +pub const PAD_ANNOUNCE: u8 = 0x81; + +/// Everything a client had plugged in is gone, because the client is. +/// +/// Only ever said by the hub, on the IPC socket: a client that disconnects +/// cannot say it itself, and a controller left behind would still be plugged +/// into the game. +pub const PAD_SESSION_END: u8 = 0x40; + +/// The longest name carried. A `Connect` with a longer one is cut at a +/// character boundary. +pub const NAME_MAX: usize = 255; + +/// Buttons, as bits of [`PadState::buttons`]. +pub mod button { + pub const SOUTH: u32 = 1 << 0; + pub const EAST: u32 = 1 << 1; + pub const NORTH: u32 = 1 << 2; + pub const WEST: u32 = 1 << 3; + pub const LEFT_SHOULDER: u32 = 1 << 4; + pub const RIGHT_SHOULDER: u32 = 1 << 5; + /// Digital trigger clicks. Sent alongside the analog value, because some + /// controllers report both and a game may read either. + pub const LEFT_TRIGGER: u32 = 1 << 6; + pub const RIGHT_TRIGGER: u32 = 1 << 7; + pub const SELECT: u32 = 1 << 8; + pub const START: u32 = 1 << 9; + pub const MODE: u32 = 1 << 10; + pub const LEFT_STICK: u32 = 1 << 11; + pub const RIGHT_STICK: u32 = 1 << 12; + pub const DPAD_UP: u32 = 1 << 13; + pub const DPAD_DOWN: u32 = 1 << 14; + pub const DPAD_LEFT: u32 = 1 << 15; + pub const DPAD_RIGHT: u32 = 1 << 16; +} + +/// Who a controller is, as the client saw it: what every platform's gamepad +/// API can say. Zero in `vendor` or `product` is one that would not. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct PadIdentity { + pub vendor: u16, + pub product: u16, + pub name: String, +} + +/// One controller's whole state. +/// +/// Sticks are full-range `i16` with Linux's orientation: positive x is right, +/// positive y is *down*. Triggers are `0..=u16::MAX`, released to fully +/// pulled. The default is a controller nobody is touching. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +pub struct PadState { + pub buttons: u32, + pub left_x: i16, + pub left_y: i16, + pub right_x: i16, + pub right_y: i16, + pub left_trigger: u16, + pub right_trigger: u16, +} + +/// Client → box. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum PadMessage { + Connect { + slot: u8, + identity: PadIdentity, + }, + State { + slot: u8, + state: PadState, + }, + Disconnect { + slot: u8, + }, + /// See [`PAD_SESSION_END`]. Never on the wire from a client. + SessionEnd, +} + +/// Box → client. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum PadFeedback { + Rumble { + slot: u8, + strong: u16, + weak: u16, + duration_ms: u16, + }, + Announce { + slot: u8, + }, +} + +fn cut_name(name: &str) -> &str { + if name.len() <= NAME_MAX { + return name; + } + let mut end = NAME_MAX; + while !name.is_char_boundary(end) { + end -= 1; + } + &name[..end] +} + +impl PadMessage { + pub fn encode(&self, buf: &mut Vec) { + match self { + Self::Connect { slot, identity } => { + let name = cut_name(&identity.name); + buf.reserve(7 + name.len()); + buf.push(PAD_CONNECT); + buf.push(*slot); + buf.extend_from_slice(&identity.vendor.to_le_bytes()); + buf.extend_from_slice(&identity.product.to_le_bytes()); + buf.push(name.len() as u8); + buf.extend_from_slice(name.as_bytes()); + } + Self::State { slot, state } => { + buf.reserve(18); + buf.push(PAD_STATE); + buf.push(*slot); + buf.extend_from_slice(&state.buttons.to_le_bytes()); + for axis in [state.left_x, state.left_y, state.right_x, state.right_y] { + buf.extend_from_slice(&axis.to_le_bytes()); + } + buf.extend_from_slice(&state.left_trigger.to_le_bytes()); + buf.extend_from_slice(&state.right_trigger.to_le_bytes()); + } + Self::Disconnect { slot } => { + buf.push(PAD_DISCONNECT); + buf.push(*slot); + } + Self::SessionEnd => buf.push(PAD_SESSION_END), + } + } + + /// `None` for anything short, unknown, or with a name that is not UTF-8. + pub fn decode(data: &[u8]) -> Option { + let (&kind, rest) = data.split_first()?; + match kind { + PAD_CONNECT => { + let fixed = rest.get(..6)?; + let name_len = fixed[5] as usize; + let name = rest.get(6..6 + name_len)?; + Some(Self::Connect { + slot: fixed[0], + identity: PadIdentity { + vendor: u16::from_le_bytes([fixed[1], fixed[2]]), + product: u16::from_le_bytes([fixed[3], fixed[4]]), + name: std::str::from_utf8(name).ok()?.to_owned(), + }, + }) + } + PAD_STATE => { + let b = rest.get(..17)?; + let i16_at = |i: usize| i16::from_le_bytes([b[i], b[i + 1]]); + let u16_at = |i: usize| u16::from_le_bytes([b[i], b[i + 1]]); + Some(Self::State { + slot: b[0], + state: PadState { + buttons: u32::from_le_bytes([b[1], b[2], b[3], b[4]]), + left_x: i16_at(5), + left_y: i16_at(7), + right_x: i16_at(9), + right_y: i16_at(11), + left_trigger: u16_at(13), + right_trigger: u16_at(15), + }, + }) + } + PAD_DISCONNECT => Some(Self::Disconnect { + slot: *rest.first()?, + }), + PAD_SESSION_END => Some(Self::SessionEnd), + _ => None, + } + } +} + +impl PadFeedback { + pub fn encode(&self, buf: &mut Vec) { + match *self { + Self::Rumble { + slot, + strong, + weak, + duration_ms, + } => { + buf.reserve(8); + buf.push(PAD_RUMBLE); + buf.push(slot); + buf.extend_from_slice(&strong.to_le_bytes()); + buf.extend_from_slice(&weak.to_le_bytes()); + buf.extend_from_slice(&duration_ms.to_le_bytes()); + } + Self::Announce { slot } => { + buf.push(PAD_ANNOUNCE); + buf.push(slot); + } + } + } + + pub fn decode(data: &[u8]) -> Option { + let (&kind, rest) = data.split_first()?; + match kind { + PAD_RUMBLE => { + let b = rest.get(..7)?; + Some(Self::Rumble { + slot: b[0], + strong: u16::from_le_bytes([b[1], b[2]]), + weak: u16::from_le_bytes([b[3], b[4]]), + duration_ms: u16::from_le_bytes([b[5], b[6]]), + }) + } + PAD_ANNOUNCE => Some(Self::Announce { + slot: *rest.first()?, + }), + _ => None, + } + } +} + +// ── Hub ↔ box-side IPC ────────────────────────────────────────── +// +// `[u16 LE len][u32 LE session][message]`, where `len` counts the session and +// the message. The session number is the hub's name for one client, so two +// clients can both have a slot 0 without meeting. It means nothing outside the +// hub's own lifetime. + +/// Frame one message for the IPC socket, in either direction. +pub fn encode_ipc(buf: &mut Vec, session: u32, message: &[u8]) { + let len = 4 + message.len(); + buf.reserve(2 + len); + buf.extend_from_slice(&(len as u16).to_le_bytes()); + buf.extend_from_slice(&session.to_le_bytes()); + buf.extend_from_slice(message); +} + +/// Split one IPC frame's body (after the length) into session and message. +pub fn decode_ipc(body: &[u8]) -> Option<(u32, &[u8])> { + let session = u32::from_le_bytes(body.get(..4)?.try_into().ok()?); + Some((session, &body[4..])) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn round_trip(message: PadMessage) { + let mut buf = Vec::new(); + message.encode(&mut buf); + assert_eq!(PadMessage::decode(&buf), Some(message)); + } + + #[test] + fn every_message_survives_the_wire() { + round_trip(PadMessage::Connect { + slot: 3, + identity: PadIdentity { + vendor: 0x054c, + product: 0x09cc, + name: "Wireless Controller".into(), + }, + }); + round_trip(PadMessage::State { + slot: 15, + state: PadState { + buttons: button::SOUTH | button::DPAD_RIGHT, + left_x: i16::MIN, + left_y: i16::MAX, + right_x: -1, + right_y: 1, + left_trigger: u16::MAX, + right_trigger: 7, + }, + }); + round_trip(PadMessage::Disconnect { slot: 0 }); + round_trip(PadMessage::SessionEnd); + } + + #[test] + fn feedback_survives_the_wire() { + for feedback in [ + PadFeedback::Rumble { + slot: 2, + strong: 0xffff, + weak: 0x1234, + duration_ms: 250, + }, + PadFeedback::Announce { slot: 9 }, + ] { + let mut buf = Vec::new(); + feedback.encode(&mut buf); + assert_eq!(PadFeedback::decode(&buf), Some(feedback)); + } + } + + #[test] + fn a_long_name_is_cut_on_a_character_boundary() { + // Three bytes per character, so 255 falls exactly on one and 256 would + // not: a byte cut would produce a name that no longer decodes. + let name = "コ".repeat(100); + let mut buf = Vec::new(); + PadMessage::Connect { + slot: 0, + identity: PadIdentity { + vendor: 0, + product: 0, + name: format!("x{name}"), + }, + } + .encode(&mut buf); + let Some(PadMessage::Connect { identity, .. }) = PadMessage::decode(&buf) else { + panic!("did not decode"); + }; + assert!(identity.name.len() <= NAME_MAX); + assert!(identity.name.starts_with('x')); + } + + #[test] + fn anything_short_is_refused_rather_than_read_past() { + let mut buf = Vec::new(); + PadMessage::State { + slot: 1, + state: PadState::default(), + } + .encode(&mut buf); + for len in 0..buf.len() { + assert_eq!(PadMessage::decode(&buf[..len]), None, "length {len}"); + } + assert_eq!(PadMessage::decode(&[0x7f, 0]), None); + } + + #[test] + fn ipc_frames_carry_their_session() { + let mut message = Vec::new(); + PadMessage::Disconnect { slot: 4 }.encode(&mut message); + let mut buf = Vec::new(); + encode_ipc(&mut buf, 0xdead_beef, &message); + let len = u16::from_le_bytes([buf[0], buf[1]]) as usize; + assert_eq!(len, buf.len() - 2); + let (session, body) = decode_ipc(&buf[2..]).unwrap(); + assert_eq!(session, 0xdead_beef); + assert_eq!( + PadMessage::decode(body), + Some(PadMessage::Disconnect { slot: 4 }) + ); + } +} diff --git a/crates/nesprotocol/src/lib.rs b/crates/nesprotocol/src/lib.rs index b1eaaa1c..ed3a3597 100644 --- a/crates/nesprotocol/src/lib.rs +++ b/crates/nesprotocol/src/lib.rs @@ -4,6 +4,7 @@ pub mod datagram; pub mod delay; +pub mod gamepad; pub mod input; #[cfg(feature = "lifecycle")] pub mod lifecycle; @@ -105,6 +106,16 @@ pub const MSG_RECEIVER_REPORT: u8 = 0x13; /// Who decides the bitrate, and the ceiling to decide within (desktop → hub). pub const MSG_CONTROL_MODE: u8 = 0x14; pub const MSG_INPUT_BATCH: u8 = 0xFE; // batched input events (desktop → hub) +/// One gamepad message (desktop → hub → the box's gamepad service), on the input +/// stream. See the [`gamepad`] module. +/// +/// A frame of its own rather than an event inside an input batch: a batch is a +/// run of fixed-width events the hub splits by type, and a controller's name +/// has no fixed width. +pub const MSG_GAMEPAD: u8 = 0xFD; +/// Rumble and re-announce requests (box → hub → desktop), on the input +/// stream's return half. +pub const MSG_GAMEPAD_FEEDBACK: u8 = 0xFC; /// Build a frame body: `[u8 type] [u16 LE seq] [payload]`. ///