mirror of
https://github.com/nestriness/nestri.git
synced 2026-09-29 14:12:24 +03:00
feat: controller/gamepad support (#350)
Adds `nesgamepad`, basically "diet-coke vimputti" for Nestri's microVM needs here. Comes with direct mapping for: - Sony - DualShock 4 (v2), DualSense - Microsoft - Xbox 360 pad, Xbox One S pad - Nintendo - Pro Controller - Generic pad for unknown controllers --------- Co-authored-by: DatCaptainHorse <DatCaptainHorse@users.noreply.github.com> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
DatCaptainHorse
Claude Opus 5.5
parent
291fabd145
commit
451b495de6
Generated
+13
@@ -2361,6 +2361,19 @@ dependencies = [
|
|||||||
"ureq",
|
"ureq",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "nesgamepad"
|
||||||
|
version = "0.1.0"
|
||||||
|
dependencies = [
|
||||||
|
"anyhow",
|
||||||
|
"clap",
|
||||||
|
"libc",
|
||||||
|
"nesprotocol",
|
||||||
|
"tokio",
|
||||||
|
"tracing",
|
||||||
|
"tracing-subscriber",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "neshub"
|
name = "neshub"
|
||||||
version = "0.2.0"
|
version = "0.2.0"
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ members = [
|
|||||||
"apps/nescapture",
|
"apps/nescapture",
|
||||||
"apps/nescope",
|
"apps/nescope",
|
||||||
"apps/nesdoctor",
|
"apps/nesdoctor",
|
||||||
|
"apps/nesgamepad",
|
||||||
"apps/neshub",
|
"apps/neshub",
|
||||||
"apps/nesinit",
|
"apps/nesinit",
|
||||||
"apps/neswire",
|
"apps/neswire",
|
||||||
|
|||||||
@@ -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" }
|
||||||
@@ -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.
|
||||||
@@ -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<u16> = 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<Path>) -> 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::<Rumble>() == 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<crate::uhid::Device>, 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[..]);
|
||||||
|
}
|
||||||
@@ -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<InputEvent> {
|
||||||
|
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<AbsAxis> {
|
||||||
|
let hat = |code| AbsAxis {
|
||||||
|
code,
|
||||||
|
min: -1,
|
||||||
|
max: 1,
|
||||||
|
fuzz: 0,
|
||||||
|
flat: 0,
|
||||||
|
};
|
||||||
|
let mut axes: Vec<AbsAxis> = 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<Layout> {
|
||||||
|
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<u16> = 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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::<Feedback>();
|
||||||
|
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<Feedback>,
|
||||||
|
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
|
||||||
|
}
|
||||||
@@ -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<Device>,
|
||||||
|
layout: Layout,
|
||||||
|
/// The devices announced for it, parents first.
|
||||||
|
records: Vec<Record>,
|
||||||
|
/// 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<uhid::Device>,
|
||||||
|
address: [u8; 6],
|
||||||
|
reporter: Arc<Mutex<Reporter>>,
|
||||||
|
/// The kernel's directory for it, once found.
|
||||||
|
syspath: Option<PathBuf>,
|
||||||
|
records: Vec<Record>,
|
||||||
|
/// 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<Replica>,
|
||||||
|
gamepad: Option<Gamepad>,
|
||||||
|
/// 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<Udev>,
|
||||||
|
pads: HashMap<Key, Pad>,
|
||||||
|
/// 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<Key>,
|
||||||
|
feedback: UnboundedSender<Feedback>,
|
||||||
|
hid: UnboundedSender<(Key, HidNotice)>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Pads {
|
||||||
|
pub fn new(
|
||||||
|
udev: Option<Udev>,
|
||||||
|
feedback: UnboundedSender<Feedback>,
|
||||||
|
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<Replica> {
|
||||||
|
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<Gamepad> {
|
||||||
|
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<Device>, Vec<Record>)> {
|
||||||
|
let keys: Vec<u16> = 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<PathBuf> = 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::<Vec<_>>()
|
||||||
|
.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<Key> = 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<Key> = 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<PathBuf> {
|
||||||
|
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<PathBuf>,
|
||||||
|
/// Each input device, with its event nodes.
|
||||||
|
inputs: Vec<(PathBuf, Vec<PathBuf>)>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Nodes {
|
||||||
|
fn dev_nodes(&self) -> Vec<PathBuf> {
|
||||||
|
let dev =
|
||||||
|
|path: &PathBuf, dir: &str| Path::new(dir).join(path.file_name().unwrap_or_default());
|
||||||
|
let mut nodes: Vec<PathBuf> = 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<PathBuf>,
|
||||||
|
) -> Option<(PathBuf, Nodes)> {
|
||||||
|
let prefix = format!("{bus:04X}:{vendor:04X}:{product:04X}.");
|
||||||
|
let mut candidates: Vec<PathBuf> = 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<PathBuf> {
|
||||||
|
let mut found: Vec<PathBuf> = 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<T>(Arc<T>);
|
||||||
|
|
||||||
|
impl<T: AsRawFd> AsRawFd for Readable<T> {
|
||||||
|
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<uhid::Device>,
|
||||||
|
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<uhid::Device>,
|
||||||
|
reporter: Arc<Mutex<Reporter>>,
|
||||||
|
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<Device>,
|
||||||
|
session: u32,
|
||||||
|
slot: u8,
|
||||||
|
tx: UnboundedSender<Feedback>,
|
||||||
|
) -> 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<i16, (u16, u16, u16)> = HashMap::new();
|
||||||
|
let mut playing: Option<i16> = 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -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<Vec<u8>> {
|
||||||
|
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<u8> {
|
||||||
|
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<u8> {
|
||||||
|
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::<Vec<_>>()
|
||||||
|
.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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<Vec<u8>> {
|
||||||
|
// 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<String, String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Record {
|
||||||
|
/// Describe a device from what the kernel says about it, plus what udev's
|
||||||
|
/// rules would have added.
|
||||||
|
///
|
||||||
|
/// The kernel's own `uevent` file is the base, because that is exactly
|
||||||
|
/// what udevd starts from; `extra` is the classification on top.
|
||||||
|
pub fn read(syspath: &Path, subsystem: &str, extra: &[(&str, String)]) -> io::Result<Self> {
|
||||||
|
let mut properties = BTreeMap::new();
|
||||||
|
for line in std::fs::read_to_string(syspath.join("uevent"))?.lines() {
|
||||||
|
if let Some((key, value)) = line.split_once('=') {
|
||||||
|
properties.insert(key.to_owned(), value.to_owned());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let devpath = devpath(syspath)?;
|
||||||
|
let sysname = syspath
|
||||||
|
.file_name()
|
||||||
|
.map(|n| n.to_string_lossy().into_owned())
|
||||||
|
.unwrap_or_default();
|
||||||
|
|
||||||
|
let db_name = match (properties.get("MAJOR"), properties.get("MINOR")) {
|
||||||
|
(Some(major), Some(minor)) => format!("c{major}:{minor}"),
|
||||||
|
_ => format!("+{subsystem}:{sysname}"),
|
||||||
|
};
|
||||||
|
// The kernel names the node relative to /dev; udev names it in full.
|
||||||
|
if let Some(devname) = properties.get_mut("DEVNAME")
|
||||||
|
&& !devname.starts_with('/')
|
||||||
|
{
|
||||||
|
*devname = format!("/dev/{devname}");
|
||||||
|
}
|
||||||
|
properties.insert("DEVPATH".into(), devpath.clone());
|
||||||
|
properties.insert("SUBSYSTEM".into(), subsystem.into());
|
||||||
|
properties.insert("USEC_INITIALIZED".into(), monotonic_usec().to_string());
|
||||||
|
let tags = format!(":{}:", TAGS.join(":"));
|
||||||
|
properties.insert("TAGS".into(), tags.clone());
|
||||||
|
properties.insert("CURRENT_TAGS".into(), tags);
|
||||||
|
for (key, value) in extra {
|
||||||
|
properties.insert((*key).to_owned(), value.clone());
|
||||||
|
}
|
||||||
|
Ok(Self {
|
||||||
|
devpath,
|
||||||
|
db_name,
|
||||||
|
properties,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The database entry, in the format udevd writes.
|
||||||
|
fn db_entry(&self) -> String {
|
||||||
|
let mut entry = format!("I:{}\n", self.properties["USEC_INITIALIZED"]);
|
||||||
|
// Only what a rule added goes in the database; the rest a reader gets
|
||||||
|
// from sysfs itself.
|
||||||
|
for (key, value) in &self.properties {
|
||||||
|
if key.starts_with("ID_") {
|
||||||
|
entry.push_str(&format!("E:{key}={value}\n"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for tag in TAGS {
|
||||||
|
entry.push_str(&format!("G:{tag}\nQ:{tag}\n"));
|
||||||
|
}
|
||||||
|
entry.push_str("V:1\n");
|
||||||
|
entry
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// udev's devpath: the syspath without `/sys`, keeping the leading slash.
|
||||||
|
///
|
||||||
|
/// `Path::strip_prefix` drops that slash, and libudev joins `/sys` straight
|
||||||
|
/// onto whatever it is given -- so without it every device lands at
|
||||||
|
/// `/sysdevices/...`, and every broadcast is rejected with nothing logged.
|
||||||
|
fn devpath(syspath: &Path) -> io::Result<String> {
|
||||||
|
let rest = syspath
|
||||||
|
.strip_prefix("/sys")
|
||||||
|
.map_err(|_| io::Error::other("not under /sys"))?;
|
||||||
|
Ok(format!("/{}", rest.to_string_lossy()))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn monotonic_usec() -> u64 {
|
||||||
|
let mut ts = libc::timespec {
|
||||||
|
tv_sec: 0,
|
||||||
|
tv_nsec: 0,
|
||||||
|
};
|
||||||
|
// SAFETY: a valid clock and a timespec to fill.
|
||||||
|
unsafe { libc::clock_gettime(libc::CLOCK_MONOTONIC, &mut ts) };
|
||||||
|
ts.tv_sec as u64 * 1_000_000 + ts.tv_nsec as u64 / 1_000
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Stands in for udevd's database and broadcasts.
|
||||||
|
pub struct Udev {
|
||||||
|
socket: std::os::fd::OwnedFd,
|
||||||
|
seqnum: AtomicU64,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Udev {
|
||||||
|
pub fn new() -> io::Result<Self> {
|
||||||
|
let udev = Self::broadcaster()?;
|
||||||
|
for dir in [PathBuf::from(DATA_DIR)]
|
||||||
|
.into_iter()
|
||||||
|
.chain(TAGS.iter().map(|t| Path::new(TAGS_DIR).join(t)))
|
||||||
|
{
|
||||||
|
std::fs::create_dir_all(&dir)?;
|
||||||
|
}
|
||||||
|
Ok(udev)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The broadcasting half alone, which touches nothing on disk.
|
||||||
|
fn broadcaster() -> io::Result<Self> {
|
||||||
|
// SAFETY: plain socket creation; the result is checked.
|
||||||
|
let fd = unsafe {
|
||||||
|
libc::socket(
|
||||||
|
libc::AF_NETLINK,
|
||||||
|
libc::SOCK_RAW | libc::SOCK_CLOEXEC,
|
||||||
|
libc::NETLINK_KOBJECT_UEVENT,
|
||||||
|
)
|
||||||
|
};
|
||||||
|
if fd < 0 {
|
||||||
|
return Err(io::Error::last_os_error());
|
||||||
|
}
|
||||||
|
// SAFETY: a descriptor just returned to us and owned by nothing else.
|
||||||
|
let socket = unsafe { std::os::fd::FromRawFd::from_raw_fd(fd) };
|
||||||
|
Ok(Self {
|
||||||
|
socket,
|
||||||
|
seqnum: AtomicU64::new(1),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Record a device and announce it.
|
||||||
|
pub fn add(&self, record: &Record) -> io::Result<()> {
|
||||||
|
std::fs::write(Path::new(DATA_DIR).join(&record.db_name), record.db_entry())?;
|
||||||
|
for tag in TAGS {
|
||||||
|
std::fs::write(Path::new(TAGS_DIR).join(tag).join(&record.db_name), "")?;
|
||||||
|
}
|
||||||
|
self.broadcast("add", record)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Announce a device gone and forget it. Every step is attempted even if
|
||||||
|
/// an earlier one fails: a stale database entry is harmless next to a
|
||||||
|
/// removal nobody heard.
|
||||||
|
pub fn remove(&self, record: &Record) -> io::Result<()> {
|
||||||
|
let _ = std::fs::remove_file(Path::new(DATA_DIR).join(&record.db_name));
|
||||||
|
for tag in TAGS {
|
||||||
|
let _ = std::fs::remove_file(Path::new(TAGS_DIR).join(tag).join(&record.db_name));
|
||||||
|
}
|
||||||
|
self.broadcast("remove", record)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn broadcast(&self, action: &str, record: &Record) -> io::Result<()> {
|
||||||
|
let seqnum = self.seqnum.fetch_add(1, Ordering::Relaxed);
|
||||||
|
let message = encode(action, seqnum, record);
|
||||||
|
let mut addr: libc::sockaddr_nl = unsafe { std::mem::zeroed() };
|
||||||
|
addr.nl_family = libc::AF_NETLINK as u16;
|
||||||
|
addr.nl_groups = GROUP_UDEV;
|
||||||
|
// SAFETY: the address and buffer outlive the call.
|
||||||
|
let n = unsafe {
|
||||||
|
libc::sendto(
|
||||||
|
std::os::fd::AsRawFd::as_raw_fd(&self.socket),
|
||||||
|
message.as_ptr().cast(),
|
||||||
|
message.len(),
|
||||||
|
0,
|
||||||
|
(&addr as *const libc::sockaddr_nl).cast(),
|
||||||
|
std::mem::size_of::<libc::sockaddr_nl>() as u32,
|
||||||
|
)
|
||||||
|
};
|
||||||
|
if n < 0 {
|
||||||
|
let error = io::Error::last_os_error();
|
||||||
|
// What a multicast send reports when nobody is listening yet,
|
||||||
|
// which before a game has started is the normal case.
|
||||||
|
if error.raw_os_error() == Some(libc::ECONNREFUSED) {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
return Err(error);
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A libudev monitor message: the `monitor_netlink_header`, then the
|
||||||
|
/// properties as NUL-terminated `KEY=value` strings.
|
||||||
|
fn encode(action: &str, seqnum: u64, record: &Record) -> Vec<u8> {
|
||||||
|
let mut properties = Vec::new();
|
||||||
|
let mut push = |key: &str, value: &str| {
|
||||||
|
properties.extend_from_slice(key.as_bytes());
|
||||||
|
properties.push(b'=');
|
||||||
|
properties.extend_from_slice(value.as_bytes());
|
||||||
|
properties.push(0);
|
||||||
|
};
|
||||||
|
push("ACTION", action);
|
||||||
|
push("SEQNUM", &seqnum.to_string());
|
||||||
|
for (key, value) in &record.properties {
|
||||||
|
push(key, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
const HEADER_LEN: u32 = 40;
|
||||||
|
let tags = TAGS.iter().fold(0u64, |bits, tag| bits | bloom(tag));
|
||||||
|
let mut message = Vec::with_capacity(HEADER_LEN as usize + properties.len());
|
||||||
|
message.extend_from_slice(b"libudev\0");
|
||||||
|
// The magic, hashes and bloom are in network order; the lengths are not.
|
||||||
|
message.extend_from_slice(&UDEV_MONITOR_MAGIC.to_be_bytes());
|
||||||
|
message.extend_from_slice(&HEADER_LEN.to_ne_bytes());
|
||||||
|
message.extend_from_slice(&HEADER_LEN.to_ne_bytes());
|
||||||
|
message.extend_from_slice(&(properties.len() as u32).to_ne_bytes());
|
||||||
|
let subsystem = record
|
||||||
|
.properties
|
||||||
|
.get("SUBSYSTEM")
|
||||||
|
.map_or("", String::as_str);
|
||||||
|
message.extend_from_slice(&murmur2(subsystem.as_bytes(), 0).to_be_bytes());
|
||||||
|
// Neither input nor hidraw devices have a devtype, and zero is what udevd
|
||||||
|
// sends for none.
|
||||||
|
message.extend_from_slice(&0u32.to_be_bytes());
|
||||||
|
message.extend_from_slice(&((tags >> 32) as u32).to_be_bytes());
|
||||||
|
message.extend_from_slice(&(tags as u32).to_be_bytes());
|
||||||
|
debug_assert_eq!(message.len(), HEADER_LEN as usize);
|
||||||
|
message.extend_from_slice(&properties);
|
||||||
|
message
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_devpath_starts_where_sys_ends() {
|
||||||
|
assert_eq!(
|
||||||
|
devpath(Path::new("/sys/devices/virtual/input/input5/event3")).unwrap(),
|
||||||
|
"/devices/virtual/input/input5/event3"
|
||||||
|
);
|
||||||
|
assert!(devpath(Path::new("/dev/input/event3")).is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn hashes_agree_with_systemd() {
|
||||||
|
// Computed by compiling systemd's own MurmurHash2.c.
|
||||||
|
for (input, expected) in [
|
||||||
|
("input", 0xc1a2_8470),
|
||||||
|
("seat", 0x435b_3e40),
|
||||||
|
("uaccess", 0xe88e_d0cc),
|
||||||
|
("hidraw", 0xc2ca_f397),
|
||||||
|
("a", 0x9268_5f5e),
|
||||||
|
("ab", 0x1aa1_4063),
|
||||||
|
("abc", 0x1357_7c9b),
|
||||||
|
] {
|
||||||
|
assert_eq!(murmur2(input.as_bytes(), 0), expected, "{input}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn record() -> Record {
|
||||||
|
let mut properties = BTreeMap::new();
|
||||||
|
properties.insert(
|
||||||
|
"DEVPATH".into(),
|
||||||
|
"/devices/virtual/input/input5/event3".into(),
|
||||||
|
);
|
||||||
|
properties.insert("SUBSYSTEM".into(), "input".into());
|
||||||
|
properties.insert("USEC_INITIALIZED".into(), "42".into());
|
||||||
|
properties.insert("ID_INPUT_JOYSTICK".into(), "1".into());
|
||||||
|
properties.insert("NAME".into(), "\"x\"".into());
|
||||||
|
properties.insert("TAGS".into(), ":seat:uaccess:".into());
|
||||||
|
Record {
|
||||||
|
devpath: "/devices/virtual/input/input5/event3".into(),
|
||||||
|
db_name: "c13:67".into(),
|
||||||
|
properties,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_broadcast_has_the_header_libudev_checks() {
|
||||||
|
let message = encode("add", 7, &record());
|
||||||
|
assert_eq!(&message[..8], b"libudev\0");
|
||||||
|
assert_eq!(&message[8..12], &[0xfe, 0xed, 0xca, 0xfe]);
|
||||||
|
let off = u32::from_ne_bytes(message[16..20].try_into().unwrap()) as usize;
|
||||||
|
let len = u32::from_ne_bytes(message[20..24].try_into().unwrap()) as usize;
|
||||||
|
assert_eq!(off + len, message.len());
|
||||||
|
assert_eq!(&message[24..28], &0xc1a2_8470u32.to_be_bytes());
|
||||||
|
let props: Vec<&[u8]> = message[off..].split(|&b| b == 0).collect();
|
||||||
|
for needed in [&b"ACTION=add"[..], b"SEQNUM=7", b"SUBSYSTEM=input"] {
|
||||||
|
assert!(
|
||||||
|
props.contains(&needed),
|
||||||
|
"{}",
|
||||||
|
String::from_utf8_lossy(needed)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The broadcast as real libudev receives it. Needs uid 0 and its own
|
||||||
|
/// network namespace, both of which `unshare -rn` gives without root, and
|
||||||
|
/// a libudev monitor listening in that namespace to report what it got.
|
||||||
|
#[test]
|
||||||
|
#[ignore = "sends a real udev broadcast; run inside `unshare -rn` beside a monitor"]
|
||||||
|
fn a_broadcast_for_a_monitor() {
|
||||||
|
Udev::broadcaster()
|
||||||
|
.unwrap()
|
||||||
|
.broadcast("add", &record())
|
||||||
|
.unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_database_holds_the_classification_and_the_tags() {
|
||||||
|
let entry = record().db_entry();
|
||||||
|
assert!(entry.starts_with("I:42\n"));
|
||||||
|
assert!(entry.contains("E:ID_INPUT_JOYSTICK=1\n"));
|
||||||
|
assert!(!entry.contains("NAME"));
|
||||||
|
assert!(entry.contains("G:seat\n") && entry.contains("Q:uaccess\n"));
|
||||||
|
assert!(entry.ends_with("V:1\n"));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<u8>,
|
||||||
|
},
|
||||||
|
/// 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<u8>,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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<std::fs::File>,
|
||||||
|
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<Self> {
|
||||||
|
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<Vec<Event>> {
|
||||||
|
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<Event> {
|
||||||
|
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<u8> {
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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::<InputEvent>() == 24);
|
||||||
|
assert!(size_of::<UinputSetup>() == 92);
|
||||||
|
assert!(size_of::<UinputAbsSetup>() == 28);
|
||||||
|
assert!(size_of::<FfEffect>() == 48);
|
||||||
|
assert!(size_of::<UinputFfUpload>() == 104);
|
||||||
|
assert!(size_of::<UinputFfErase>() == 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::<UinputSetup>());
|
||||||
|
const UI_ABS_SETUP: u64 = ioc(WRITE, 4, size_of::<UinputAbsSetup>());
|
||||||
|
const UI_SET_EVBIT: u64 = ioc(WRITE, 100, size_of::<libc::c_int>());
|
||||||
|
const UI_SET_KEYBIT: u64 = ioc(WRITE, 101, size_of::<libc::c_int>());
|
||||||
|
const UI_SET_ABSBIT: u64 = ioc(WRITE, 103, size_of::<libc::c_int>());
|
||||||
|
const UI_SET_FFBIT: u64 = ioc(WRITE, 107, size_of::<libc::c_int>());
|
||||||
|
const UI_BEGIN_FF_UPLOAD: u64 = ioc(READ | WRITE, 200, size_of::<UinputFfUpload>());
|
||||||
|
const UI_END_FF_UPLOAD: u64 = ioc(WRITE, 201, size_of::<UinputFfUpload>());
|
||||||
|
const UI_BEGIN_FF_ERASE: u64 = ioc(READ | WRITE, 202, size_of::<UinputFfErase>());
|
||||||
|
const UI_END_FF_ERASE: u64 = ioc(WRITE, 203, size_of::<UinputFfErase>());
|
||||||
|
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<T>(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<Self> {
|
||||||
|
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::<u8>())?;
|
||||||
|
|
||||||
|
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<Vec<Request>> {
|
||||||
|
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::<InputEvent>(),
|
||||||
|
)
|
||||||
|
};
|
||||||
|
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::<InputEvent>() {
|
||||||
|
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::<u8>(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Drop for Device {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
self.destroy();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -249,6 +249,94 @@ pub async fn run_input_ipc_listener(
|
|||||||
tracing::info!("input IPC listener exited");
|
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<SessionManager>) {
|
||||||
|
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(
|
pub async fn run_stats_ipc_listener(
|
||||||
socket_path: PathBuf,
|
socket_path: PathBuf,
|
||||||
stats_tx: tokio::sync::mpsc::UnboundedSender<Vec<u8>>,
|
stats_tx: tokio::sync::mpsc::UnboundedSender<Vec<u8>>,
|
||||||
|
|||||||
@@ -84,6 +84,15 @@ struct Args {
|
|||||||
#[arg(long, env = "NESTRI_MAX_BITRATE")]
|
#[arg(long, env = "NESTRI_MAX_BITRATE")]
|
||||||
max_bitrate_kbps: Option<u32>,
|
max_bitrate_kbps: Option<u32>,
|
||||||
|
|
||||||
|
/// 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.
|
/// Socket nescope sends screenshots on. neshub listens; nescope dials out.
|
||||||
#[arg(
|
#[arg(
|
||||||
long,
|
long,
|
||||||
@@ -362,6 +371,12 @@ async fn main() -> Result<()> {
|
|||||||
async move { ipc_listener::run_stats_ipc_listener(stats_ipc, stx).await }
|
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();
|
let ticket_ipc = args.ticket_ipc.clone();
|
||||||
tokio::spawn({
|
tokio::spawn({
|
||||||
// The endpoint rather than a ticket made from it: the addresses it can
|
// 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.audio_ipc);
|
||||||
let _ = std::fs::remove_file(&args.input_ipc);
|
let _ = std::fs::remove_file(&args.input_ipc);
|
||||||
let _ = std::fs::remove_file(&args.stats_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("/tmp/nescapture-cmd.sock");
|
||||||
let _ = std::fs::remove_file(&args.ticket_ipc);
|
let _ = std::fs::remove_file(&args.ticket_ipc);
|
||||||
Ok(())
|
Ok(())
|
||||||
|
|||||||
+159
-30
@@ -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::{ControlMode, ReceiverReport, decode_control_mode, decode_receiver_report};
|
||||||
use nesprotocol::{FRAME_HDR_LEN, STREAM_VERSION, encode_frame};
|
use nesprotocol::{FRAME_HDR_LEN, STREAM_VERSION, encode_frame};
|
||||||
use nesprotocol::{
|
use nesprotocol::{
|
||||||
MSG_CLIENT_CAPS, MSG_CONTROL_MODE, MSG_ENCODE_SETTINGS, MSG_IDR_REQUEST, MSG_INPUT_BATCH,
|
MSG_CLIENT_CAPS, MSG_CONTROL_MODE, MSG_ENCODE_SETTINGS, MSG_GAMEPAD, MSG_GAMEPAD_FEEDBACK,
|
||||||
MSG_RECEIVER_REPORT,
|
MSG_IDR_REQUEST, MSG_INPUT_BATCH, MSG_RECEIVER_REPORT,
|
||||||
};
|
};
|
||||||
|
|
||||||
use crate::control::{Controller, PathView};
|
use crate::control::{Controller, PathView};
|
||||||
@@ -60,6 +60,14 @@ pub struct ClientSession {
|
|||||||
latest_report: Arc<std::sync::Mutex<Option<ReceiverReport>>>,
|
latest_report: Arc<std::sync::Mutex<Option<ReceiverReport>>>,
|
||||||
relay_ms: Arc<AtomicU32>,
|
relay_ms: Arc<AtomicU32>,
|
||||||
input_broadcast: tokio::sync::broadcast::Sender<Vec<u8>>,
|
input_broadcast: tokio::sync::broadcast::Sender<Vec<u8>>,
|
||||||
|
/// 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<Vec<u8>>,
|
||||||
|
/// Rumble and the like, for this client's input stream to carry back.
|
||||||
|
send_gamepad_feedback: tokio::sync::mpsc::UnboundedSender<Vec<u8>>,
|
||||||
|
pending_gamepad_feedback: Option<tokio::sync::mpsc::UnboundedReceiver<Vec<u8>>>,
|
||||||
idr_cmd_tx: tokio::sync::mpsc::UnboundedSender<Vec<u8>>,
|
idr_cmd_tx: tokio::sync::mpsc::UnboundedSender<Vec<u8>>,
|
||||||
controller: Arc<Mutex<Controller>>,
|
controller: Arc<Mutex<Controller>>,
|
||||||
send_video: tokio::sync::mpsc::UnboundedSender<Vec<u8>>,
|
send_video: tokio::sync::mpsc::UnboundedSender<Vec<u8>>,
|
||||||
@@ -92,10 +100,13 @@ impl ClientSession {
|
|||||||
/// A client with no connections yet. They attach as they are accepted.
|
/// A client with no connections yet. They attach as they are accepted.
|
||||||
pub fn new(
|
pub fn new(
|
||||||
input_broadcast: tokio::sync::broadcast::Sender<Vec<u8>>,
|
input_broadcast: tokio::sync::broadcast::Sender<Vec<u8>>,
|
||||||
|
gamepad_session: u32,
|
||||||
|
gamepad_tx: tokio::sync::broadcast::Sender<Vec<u8>>,
|
||||||
relay_ms: Arc<AtomicU32>,
|
relay_ms: Arc<AtomicU32>,
|
||||||
idr_cmd_tx: tokio::sync::mpsc::UnboundedSender<Vec<u8>>,
|
idr_cmd_tx: tokio::sync::mpsc::UnboundedSender<Vec<u8>>,
|
||||||
controller: Arc<Mutex<Controller>>,
|
controller: Arc<Mutex<Controller>>,
|
||||||
) -> Self {
|
) -> Self {
|
||||||
|
let (gamepad_feedback_tx, gamepad_feedback_rx) = tokio::sync::mpsc::unbounded_channel();
|
||||||
let (video_tx, video_rx) = tokio::sync::mpsc::unbounded_channel::<Vec<u8>>();
|
let (video_tx, video_rx) = tokio::sync::mpsc::unbounded_channel::<Vec<u8>>();
|
||||||
let (audio_tx, audio_rx) = tokio::sync::mpsc::unbounded_channel::<Vec<u8>>();
|
let (audio_tx, audio_rx) = tokio::sync::mpsc::unbounded_channel::<Vec<u8>>();
|
||||||
let (cursor_tx, cursor_rx) = tokio::sync::mpsc::unbounded_channel::<Vec<u8>>();
|
let (cursor_tx, cursor_rx) = tokio::sync::mpsc::unbounded_channel::<Vec<u8>>();
|
||||||
@@ -108,6 +119,10 @@ impl ClientSession {
|
|||||||
latest_report: Arc::new(std::sync::Mutex::new(None)),
|
latest_report: Arc::new(std::sync::Mutex::new(None)),
|
||||||
relay_ms,
|
relay_ms,
|
||||||
input_broadcast,
|
input_broadcast,
|
||||||
|
gamepad_session,
|
||||||
|
gamepad_tx,
|
||||||
|
send_gamepad_feedback: gamepad_feedback_tx,
|
||||||
|
pending_gamepad_feedback: Some(gamepad_feedback_rx),
|
||||||
idr_cmd_tx,
|
idr_cmd_tx,
|
||||||
controller,
|
controller,
|
||||||
send_video: video_tx,
|
send_video: video_tx,
|
||||||
@@ -177,10 +192,18 @@ impl ClientSession {
|
|||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
Carrier::Input => {
|
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());
|
self.other_conns.push(conn.clone());
|
||||||
let broadcast = self.input_broadcast.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 {
|
self.tasks.push(tokio::spawn(async move {
|
||||||
run_input_reader(conn, broadcast).await
|
run_input_reader(conn, broadcast, gamepad, feedback).await
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
Carrier::Control => {
|
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<u8>) {
|
||||||
|
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<u8>) {
|
pub fn send_stats_data(&self, data: Vec<u8>) {
|
||||||
if let Err(e) = self.send_stats.send(data) {
|
if let Err(e) = self.send_stats.send(data) {
|
||||||
debug!("failed to send stats data: {e}");
|
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:
|
/// Shared because input and control differ only in which messages they expect:
|
||||||
/// the framing, the announcement and the reconnect behaviour are the same, and
|
/// the framing, the announcement and the reconnect behaviour are the same, and
|
||||||
/// two copies of that would drift.
|
/// 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<F, Fut>(
|
async fn run_framed_reader<F, Fut>(
|
||||||
conn: Connection,
|
conn: Connection,
|
||||||
stream_type: u8,
|
stream_type: u8,
|
||||||
label: &'static str,
|
label: &'static str,
|
||||||
|
mut outbound: Option<(u8, tokio::sync::mpsc::UnboundedReceiver<Vec<u8>>)>,
|
||||||
mut handle: F,
|
mut handle: F,
|
||||||
) where
|
) where
|
||||||
F: FnMut(u8, Vec<u8>) -> Fut,
|
F: FnMut(u8, Vec<u8>) -> Fut,
|
||||||
@@ -314,24 +348,60 @@ async fn run_framed_reader<F, Fut>(
|
|||||||
debug!("{label} type byte write failed");
|
debug!("{label} type byte write failed");
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
let _ = send.finish();
|
if outbound.is_none() {
|
||||||
debug!("{label} bidi stream ready, reading framed messages");
|
let _ = send.finish();
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
|
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::<Vec<u8>>();
|
||||||
|
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) => {
|
Err(e) => {
|
||||||
debug!("{label} open_bi failed: {e}");
|
debug!("{label} open_bi failed: {e}");
|
||||||
@@ -383,22 +453,46 @@ fn split_input_events(payload: &[u8]) -> Vec<Vec<u8>> {
|
|||||||
out
|
out
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Input events, and nothing else.
|
/// Where one client's gamepad messages go.
|
||||||
|
struct Gamepads {
|
||||||
|
session: u32,
|
||||||
|
to_box: tokio::sync::broadcast::Sender<Vec<u8>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Input events and gamepad messages, and nothing else.
|
||||||
///
|
///
|
||||||
/// On its own connection so a keypress never waits behind a video keyframe.
|
/// 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(
|
async fn run_input_reader(
|
||||||
conn: Connection,
|
conn: Connection,
|
||||||
input_broadcast: tokio::sync::broadcast::Sender<Vec<u8>>,
|
input_broadcast: tokio::sync::broadcast::Sender<Vec<u8>>,
|
||||||
|
gamepads: Gamepads,
|
||||||
|
feedback: tokio::sync::mpsc::UnboundedReceiver<Vec<u8>>,
|
||||||
) {
|
) {
|
||||||
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 input_broadcast = input_broadcast.clone();
|
||||||
|
let to_box = gamepads.to_box.clone();
|
||||||
|
let session = gamepads.session;
|
||||||
async move {
|
async move {
|
||||||
if msg_type != MSG_INPUT_BATCH {
|
match msg_type {
|
||||||
debug!("unknown input msg type: {msg_type}");
|
MSG_INPUT_BATCH => {
|
||||||
return;
|
for event in split_input_events(&payload) {
|
||||||
}
|
let _ = input_broadcast.send(event);
|
||||||
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<Mutex<Controller>>,
|
controller: Arc<Mutex<Controller>>,
|
||||||
awaiting_keyframe: Arc<AtomicBool>,
|
awaiting_keyframe: Arc<AtomicBool>,
|
||||||
) {
|
) {
|
||||||
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 idr_cmd_tx = idr_cmd_tx.clone();
|
||||||
let latest_report = latest_report.clone();
|
let latest_report = latest_report.clone();
|
||||||
let controller = controller.clone();
|
let controller = controller.clone();
|
||||||
@@ -658,6 +752,9 @@ fn is_keyframe(payload: &[u8]) -> bool {
|
|||||||
|
|
||||||
pub struct SessionManager {
|
pub struct SessionManager {
|
||||||
sessions: Arc<Mutex<HashMap<iroh::EndpointId, ClientSession>>>,
|
sessions: Arc<Mutex<HashMap<iroh::EndpointId, ClientSession>>>,
|
||||||
|
/// Every client's gamepad messages, already framed for the gamepad socket.
|
||||||
|
gamepad_tx: tokio::sync::broadcast::Sender<Vec<u8>>,
|
||||||
|
next_gamepad_session: AtomicU32,
|
||||||
/// Video bytes, split by what they were.
|
/// Video bytes, split by what they were.
|
||||||
///
|
///
|
||||||
/// One counter could not tell an encoder ignoring its bitrate target from a
|
/// One counter could not tell an encoder ignoring its bitrate target from a
|
||||||
@@ -685,8 +782,11 @@ pub struct SessionManager {
|
|||||||
|
|
||||||
impl SessionManager {
|
impl SessionManager {
|
||||||
pub fn new() -> Self {
|
pub fn new() -> Self {
|
||||||
|
let (gamepad_tx, _) = tokio::sync::broadcast::channel(256);
|
||||||
Self {
|
Self {
|
||||||
sessions: Arc::new(Mutex::new(HashMap::new())),
|
sessions: Arc::new(Mutex::new(HashMap::new())),
|
||||||
|
gamepad_tx,
|
||||||
|
next_gamepad_session: AtomicU32::new(1),
|
||||||
video_key_bytes: AtomicU64::new(0),
|
video_key_bytes: AtomicU64::new(0),
|
||||||
video_delta_bytes: AtomicU64::new(0),
|
video_delta_bytes: AtomicU64::new(0),
|
||||||
keyframes: AtomicU64::new(0),
|
keyframes: AtomicU64::new(0),
|
||||||
@@ -720,6 +820,8 @@ impl SessionManager {
|
|||||||
let session = sessions.entry(id).or_insert_with(|| {
|
let session = sessions.entry(id).or_insert_with(|| {
|
||||||
ClientSession::new(
|
ClientSession::new(
|
||||||
input_broadcast,
|
input_broadcast,
|
||||||
|
self.next_gamepad_session.fetch_add(1, Ordering::Relaxed),
|
||||||
|
self.gamepad_tx.clone(),
|
||||||
self.relay_ms.clone(),
|
self.relay_ms.clone(),
|
||||||
idr_cmd_tx,
|
idr_cmd_tx,
|
||||||
controller,
|
controller,
|
||||||
@@ -740,8 +842,35 @@ impl SessionManager {
|
|||||||
// session as removed four times over -- three of them describing a
|
// session as removed four times over -- three of them describing a
|
||||||
// session that had already gone, which reads like four clients
|
// session that had already gone, which reads like four clients
|
||||||
// leaving.
|
// 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());
|
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<Vec<u8>> {
|
||||||
|
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<u8>) {
|
||||||
|
let sessions = self.sessions.lock().await;
|
||||||
|
if let Some(session) = sessions
|
||||||
|
.values()
|
||||||
|
.find(|s| s.gamepad_session == gamepad_session)
|
||||||
|
{
|
||||||
|
session.send_gamepad_feedback(message);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -221,7 +221,8 @@ const WRITABLE: &[(&str, &str)] = &[
|
|||||||
///
|
///
|
||||||
/// Ported from the nine init scripts this replaces, and the ordering is theirs:
|
/// 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
|
/// 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] = &[
|
pub const STACK: &[Service] = &[
|
||||||
Service {
|
Service {
|
||||||
name: "dbus-system",
|
name: "dbus-system",
|
||||||
@@ -337,6 +338,23 @@ pub const STACK: &[Service] = &[
|
|||||||
umask: None,
|
umask: None,
|
||||||
ready: 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.
|
/// The stack as running processes.
|
||||||
|
|||||||
@@ -13,6 +13,10 @@
|
|||||||
// compositor handles input through Wayland and opens nothing udev provides, so
|
// 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.
|
// 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
|
// # Best effort, one line per failure, each naming a cost
|
||||||
//
|
//
|
||||||
// Same discipline as the early filesystems: refusing to boot over any one of
|
// Same discipline as the early filesystems: refusing to boot over any one of
|
||||||
|
|||||||
+18
-5
@@ -2,8 +2,8 @@
|
|||||||
# nestri guest rootfs — the open half
|
# nestri guest rootfs — the open half
|
||||||
#
|
#
|
||||||
# Builds a bootable Arch image containing Mesa (virtio-gpu native context)
|
# Builds a bootable Arch image containing Mesa (virtio-gpu native context)
|
||||||
# and the five open guest components: nesinit, nescope, neshub, neswire,
|
# and the six open guest components: nesinit, nescope, neshub, neswire,
|
||||||
# nescapture. Two leaf targets, selected with `--target`:
|
# nescapture, nesgamepad. Two leaf targets, selected with `--target`:
|
||||||
#
|
#
|
||||||
# runtime_prod stripped, root locked (default: `make build`)
|
# runtime_prod stripped, root locked (default: `make build`)
|
||||||
# runtime_debug debug tools, autologin root (`make build-debug`)
|
# 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/neshub apps/neshub
|
||||||
COPY apps/neswire apps/neswire
|
COPY apps/neswire apps/neswire
|
||||||
COPY apps/nescapture apps/nescapture
|
COPY apps/nescapture apps/nescapture
|
||||||
|
COPY apps/nesgamepad apps/nesgamepad
|
||||||
|
|
||||||
FROM nestri-src AS nestri-build
|
FROM nestri-src AS nestri-build
|
||||||
RUN --mount=type=cache,target=/root/.cargo/registry \
|
RUN --mount=type=cache,target=/root/.cargo/registry \
|
||||||
--mount=type=cache,target=/build/nestri/target \
|
--mount=type=cache,target=/build/nestri/target \
|
||||||
cargo build --release \
|
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 \
|
mkdir -p /artifacts/nestri/usr/bin /artifacts/nestri/usr/lib \
|
||||||
/artifacts/nestri/usr/share/vulkan/implicit_layer.d && \
|
/artifacts/nestri/usr/share/vulkan/implicit_layer.d && \
|
||||||
install -Dm755 target/release/nesinit /artifacts/nestri/usr/bin/nesinit && \
|
install -Dm755 target/release/nesinit /artifacts/nestri/usr/bin/nesinit && \
|
||||||
install -Dm755 target/release/nescope /artifacts/nestri/usr/bin/nescope && \
|
install -Dm755 target/release/nescope /artifacts/nestri/usr/bin/nescope && \
|
||||||
install -Dm755 target/release/neshub /artifacts/nestri/usr/bin/neshub && \
|
install -Dm755 target/release/neshub /artifacts/nestri/usr/bin/neshub && \
|
||||||
install -Dm755 target/release/neswire /artifacts/nestri/usr/bin/neswire && \
|
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 \
|
install -Dm755 target/release/libnescapture_layer.so \
|
||||||
/artifacts/nestri/usr/lib/libnescapture_layer.so && \
|
/artifacts/nestri/usr/lib/libnescapture_layer.so && \
|
||||||
install -Dm644 apps/nescapture/manifest/VK_LAYER_nescapture.json \
|
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 \
|
expat zlib llvm-libs lm_sensors elfutils libva shaderc vulkan-icd-loader \
|
||||||
pixman libxkbcommon xcb-util-keysyms xorg-xwayland \
|
pixman libxkbcommon xcb-util-keysyms xorg-xwayland \
|
||||||
pipewire pipewire-audio pipewire-pulse libpulse wireplumber opus \
|
pipewire pipewire-audio pipewire-pulse libpulse wireplumber opus \
|
||||||
python libunwind \
|
python libunwind sdl2-compat \
|
||||||
&& rm -f /usr/share/libalpm/hooks/dbus-reload.hook \
|
&& rm -f /usr/share/libalpm/hooks/dbus-reload.hook \
|
||||||
&& pacman -Rdd --noconfirm systemd systemd-sysvcompat \
|
&& pacman -Rdd --noconfirm systemd systemd-sysvcompat \
|
||||||
&& pacman -Scc --noconfirm
|
&& 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
|
# reads like an ordinary finish. Found 2026-09-12, on the first session that
|
||||||
# got as far as launching one.
|
# 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,
|
# `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
|
# and it is the whole reason that line is there: the hook runs
|
||||||
# `/usr/share/libalpm/scripts/systemd-hook`, which systemd owns, so the
|
# `/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; \
|
RUN failed=0; \
|
||||||
: > /tmp/missing-libs; \
|
: > /tmp/missing-libs; \
|
||||||
for f in /usr/bin/nesinit /usr/bin/nescope /usr/bin/neshub /usr/bin/neswire \
|
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/dbus-daemon /usr/bin/pipewire /usr/bin/pipewire-pulse \
|
||||||
/usr/bin/wireplumber /usr/bin/ip \
|
/usr/bin/wireplumber /usr/bin/ip \
|
||||||
/usr/lib/libgallium-*.so /usr/lib/libEGL_mesa.so.0 \
|
/usr/lib/libgallium-*.so /usr/lib/libEGL_mesa.so.0 \
|
||||||
@@ -517,6 +529,7 @@ RUN failed=0; \
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
RUN for required in /usr/bin/nesinit /usr/bin/nescope /usr/bin/neshub /usr/bin/neswire \
|
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/dbus-daemon /usr/bin/pipewire /usr/bin/pipewire-pulse \
|
||||||
/usr/bin/wireplumber /usr/bin/ip /usr/bin/python3 \
|
/usr/bin/wireplumber /usr/bin/ip /usr/bin/python3 \
|
||||||
/usr/share/steam/compatibilitytools.d/proton-cachyos/proton; do \
|
/usr/share/steam/compatibilitytools.d/proton-cachyos/proton; do \
|
||||||
|
|||||||
+5
-5
@@ -1,10 +1,10 @@
|
|||||||
# build/ — the guest rootfs
|
# build/ — the guest rootfs
|
||||||
|
|
||||||
Builds a bootable Arch image for the box's virtio-blk root: Mesa (virtio-gpu
|
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),
|
[`nesinit`](../apps/nesinit), [`nescope`](../apps/nescope),
|
||||||
[`neshub`](../apps/neshub), [`neswire`](../apps/neswire),
|
[`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)
|
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
|
lays out its `build/`: one big multi-stage `Containerfile`, `--target` picks the
|
||||||
flavor, `etc/` holds the files that get overlaid onto the image verbatim.
|
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.
|
explicit sanity check for doesn't exist here to check for.
|
||||||
|
|
||||||
3. **One `cargo build --release --workspace`, not one stage per binary.**
|
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
|
and one `Cargo.lock` — a BuildKit cache mount on `target/` gives cargo's
|
||||||
own incremental compiler per-crate isolation without needing a separate
|
own incremental compiler per-crate isolation without needing a separate
|
||||||
Docker stage (and a separate full rebuild of `nesprotocol`) per binary.
|
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 |
|
| 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 |
|
| `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 |
|
| `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 |
|
| `agetty` on `hvc0` | nothing. See below |
|
||||||
| `/etc/fstab` | init's own mounts, and shares named in the boot descriptor |
|
| `/etc/fstab` | init's own mounts, and shares named in the boot descriptor |
|
||||||
|
|||||||
@@ -14,6 +14,20 @@
|
|||||||
# NTSYNC is a great improvement over fsync and esync approaches.
|
# NTSYNC is a great improvement over fsync and esync approaches.
|
||||||
CONFIG_NTSYNC=y
|
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 ───────────────────────────────────────────────
|
# ── Timers ───────────────────────────────────────────────
|
||||||
# The one that cost a day of silent audio. The guest has no sound hardware, so
|
# 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
|
# PipeWire drives its whole graph off a timerfd at a 2.67ms cycle, which a
|
||||||
|
|||||||
@@ -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<u8>) {
|
||||||
|
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<Self> {
|
||||||
|
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<u8>) {
|
||||||
|
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<Self> {
|
||||||
|
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<u8>, 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 })
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@
|
|||||||
|
|
||||||
pub mod datagram;
|
pub mod datagram;
|
||||||
pub mod delay;
|
pub mod delay;
|
||||||
|
pub mod gamepad;
|
||||||
pub mod input;
|
pub mod input;
|
||||||
#[cfg(feature = "lifecycle")]
|
#[cfg(feature = "lifecycle")]
|
||||||
pub mod 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).
|
/// Who decides the bitrate, and the ceiling to decide within (desktop → hub).
|
||||||
pub const MSG_CONTROL_MODE: u8 = 0x14;
|
pub const MSG_CONTROL_MODE: u8 = 0x14;
|
||||||
pub const MSG_INPUT_BATCH: u8 = 0xFE; // batched input events (desktop → hub)
|
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]`.
|
/// Build a frame body: `[u8 type] [u16 LE seq] [payload]`.
|
||||||
///
|
///
|
||||||
|
|||||||
Reference in New Issue
Block a user