From 70a74b0d7c265e3dd5094816bbbe6aa69139a20a Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Tue, 14 Jul 2026 11:05:28 +0200 Subject: [PATCH] =?UTF-8?q?feat(gamepad):=20Switch=20Pro=20backend=20?= =?UTF-8?q?=E2=80=94=20Linux=20UHID=20via=20hid-nintendo=20(N2)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A virtual Pro Controller (057E:2009, BUS_USB, verbatim 203-byte USB descriptor triple-cross-checked from real-device captures) bound by hid-nintendo (>= 5.16): Nintendo-family client pads get correct glyphs + POSITIONAL layout (wire south/east/west/north -> Switch B/A/Y/X, so the physical-position <-> glyph relationship survives), live gyro/accel, and HD-rumble feedback — instead of folding to Xbox360 (mirrored A/B + X/Y, no motion). - switch_proto: report-0x30/0x21/0x81 codec + the entire canned probe conversation, pinned line-by-line against hid-nintendo.c: 0x80-family USB acks, device info (type 0x03 + per-pad MAC), SPI-flash calibration blobs (user magics ABSENT -> factory path; sticks 2048 +/- 1400 with the left/right byte-order difference; IMU offsets 0 + the driver's own default scales so raw units pass 1:1), rumble amplitude decode through the driver's inverted joycon_rumble_amplitudes table, player lights -> 0xCD PlayerLeds. 11 new pin tests. - switch_pro: UHID backend answering the probe from the manager's service pass; SwitchProManager = UhidManager (the 8 ms heartbeat doubles as the steady 0x30 stream the driver's post-probe rate limiter wants). switchpro-test CLI smoke. - Router/fold: SwitchPro arms; pick_gamepad SwitchPro -> itself on Linux; degrade_if_no_uhid covers it. SDL picker: NintendoSwitchPro + JoyconPair declare SwitchPro. Headless-validated on .21 (hid-nintendo 7.1): probe completes ('using factory cal' for sticks + IMU, player-1 LED round-trips to the 0xCD plane), gamepad + IMU input devices created, and an evdev capture pins the positional swap (wire A/B -> BTN_SOUTH/BTN_EAST) + full-range stick scaling. .21 clippy -D warnings + 303/0 tests; .133 clippy -D warnings. Co-Authored-By: Claude Fable 5 --- crates/pf-client-core/src/gamepad.rs | 4 + crates/punktfunk-host/src/inject.rs | 9 + .../src/inject/linux/switch_pro.rs | 304 ++++++++ .../src/inject/proto/switch_proto.rs | 654 ++++++++++++++++++ crates/punktfunk-host/src/main.rs | 65 ++ crates/punktfunk-host/src/punktfunk1.rs | 34 +- docs-site/content/docs/configuration.md | 2 +- 7 files changed, 1067 insertions(+), 5 deletions(-) create mode 100644 crates/punktfunk-host/src/inject/linux/switch_pro.rs create mode 100644 crates/punktfunk-host/src/inject/proto/switch_proto.rs diff --git a/crates/pf-client-core/src/gamepad.rs b/crates/pf-client-core/src/gamepad.rs index f3fbf629..d49a9222 100644 --- a/crates/pf-client-core/src/gamepad.rs +++ b/crates/pf-client-core/src/gamepad.rs @@ -268,6 +268,7 @@ impl PadInfo { GamepadPref::DualShock4 => "DualShock 4", GamepadPref::XboxOne => "Xbox One", GamepadPref::SteamDeck => "Steam Deck", + GamepadPref::SwitchPro => "Switch Pro", _ => "", } } @@ -298,6 +299,9 @@ fn pref_for_type(t: sdl3::gamepad::GamepadType) -> GamepadPref { T::PS5 => GamepadPref::DualSense, T::PS4 => GamepadPref::DualShock4, T::XboxOne => GamepadPref::XboxOne, + // A paired Joy-Con set exposes the full Pro button surface through SDL, so it rides + // the same virtual pad; single Joy-Cons stay on the Xbox 360 fallback (half a pad). + T::NintendoSwitchPro | T::NintendoSwitchJoyconPair => GamepadPref::SwitchPro, _ => GamepadPref::Xbox360, } } diff --git a/crates/punktfunk-host/src/inject.rs b/crates/punktfunk-host/src/inject.rs index d32b5046..17f98ff4 100644 --- a/crates/punktfunk-host/src/inject.rs +++ b/crates/punktfunk-host/src/inject.rs @@ -527,6 +527,15 @@ pub mod pad_slots; #[cfg(target_os = "linux")] #[path = "inject/linux/steam_controller.rs"] pub mod steam_controller; +/// Linux: virtual Nintendo Switch Pro Controller via UHID (kernel `hid-nintendo`). +#[cfg(target_os = "linux")] +#[path = "inject/linux/switch_pro.rs"] +pub mod switch_pro; +/// Transport-independent Switch Pro Controller codec + the canned `hid-nintendo` handshake +/// replies, used by the Linux UHID backend ([`switch_pro`]). +#[cfg(target_os = "linux")] +#[path = "inject/proto/switch_proto.rs"] +pub mod switch_proto; /// Linux: virtual Steam Deck via the USB gadget subsystem (`raw_gadget` + `dummy_hcd`) — the only /// virtual-Deck transport Steam Input promotes (presents the controller on USB interface 2). /// SteamOS-host only (needs `dummy_hcd` + `raw_gadget`). diff --git a/crates/punktfunk-host/src/inject/linux/switch_pro.rs b/crates/punktfunk-host/src/inject/linux/switch_pro.rs new file mode 100644 index 00000000..0ecdbae0 --- /dev/null +++ b/crates/punktfunk-host/src/inject/linux/switch_pro.rs @@ -0,0 +1,304 @@ +//! Virtual Nintendo Switch Pro Controller via UHID — bound by the kernel's `hid-nintendo` +//! (≥ 5.16), so a Nintendo-family client pad gets correct glyphs + positional layout, live +//! gyro/accel, and HD-rumble feedback, instead of folding to the Xbox 360 pad (mirrored A/B +//! + X/Y, no motion). +//! +//! Unlike `hid-playstation` (whose init is three GET_REPORTs), `hid-nintendo` runs a real +//! PROBE CONVERSATION against the device: the `0x80`-family USB commands, then ~a dozen +//! subcommands (device info, SPI-flash calibration reads, IMU/vibration enable, input mode, +//! player lights) — each a blocking send that must see its reply (input report `0x81`/`0x21`) +//! within 1–2 s or probe aborts and NO input devices appear. The whole codec + the canned +//! replies live in [`super::switch_proto`]; this module is the `/dev/uhid` plumbing that +//! answers them from the [`UhidManager`]'s frequent `service` pass (the same cadence that +//! already completes the DualSense handshake). +//! +//! Post-probe, the driver stalls every LED/rumble write for up to 250 ms unless input reports +//! are flowing — the shared manager's 8 ms silence heartbeat provides exactly that steady +//! `0x30` stream. On host suspend/resume the driver re-runs the whole init; the service pass +//! answers it identically (nothing probe-specific is latched). + +use super::switch_proto::{ + build_subcmd_reply, build_usb_ack, device_info_payload, parse_output, player_leds_bits, + serialize_report_0x30, spi_flash_read, switch_mac, SwitchOutput, SwitchState, PROCON_RDESC, + SWITCH_PRODUCT, SWITCH_REPORT_LEN, SWITCH_VENDOR, +}; +use crate::inject::uhid_manager::{PadFeedback, PadProto, UhidManager}; +use anyhow::{Context, Result}; +use punktfunk_core::quic::{HidOutput, RichInput}; +use std::fs::{File, OpenOptions}; +use std::io::{Read, Write}; +use std::os::unix::fs::OpenOptionsExt; + +// /dev/uhid event ABI (linux/uhid.h) — identical to the DualSense backend's; see `super::dualsense`. +const UHID_PATH: &str = "/dev/uhid"; +const UHID_DESTROY: u32 = 1; +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 HID_MAX_DESCRIPTOR_SIZE: usize = 4096; +const UHID_EVENT_SIZE: usize = 4 + 4372; // type + union (create2) +const BUS_USB: u16 = 0x03; + +/// Copy a NUL-padded C string field into the event buffer. +fn put_cstr(ev: &mut [u8], off: usize, cap: usize, s: &str) { + let n = s.len().min(cap - 1); + ev[off..off + n].copy_from_slice(&s.as_bytes()[..n]); // rest already zero (NUL-terminated) +} + +/// A virtual Pro Controller backed by `/dev/uhid`. Dropping it destroys the device (the kernel +/// tears down the bound `hid-nintendo` interface). +pub struct SwitchProPad { + fd: File, + index: u8, + /// Rolling report timer (byte 1 of every input report). + timer: u8, + /// The last written state — subcommand replies embed the current input-state header, so the + /// probe conversation always reports coherent (neutral, at first) controller state. + state: SwitchState, +} + +impl SwitchProPad { + /// Create the UHID Pro Controller for pad `index` (used for the name/uniq + the virtual MAC). + pub fn open(index: u8) -> Result { + let fd = OpenOptions::new() + .read(true) + .write(true) + .custom_flags(libc::O_NONBLOCK) + .open(UHID_PATH) + .with_context(|| { + format!("open {UHID_PATH} (is the 60-punktfunk.rules uhid rule installed + are you in 'input'?)") + })?; + let mut pad = SwitchProPad { + fd, + index, + timer: 0, + state: SwitchState::neutral(), + }; + pad.send_create2(index).context("UHID_CREATE2 Switch Pro")?; + Ok(pad) + } + + fn send_create2(&mut self, index: u8) -> Result<()> { + let mut ev = [0u8; UHID_EVENT_SIZE]; + ev[0..4].copy_from_slice(&UHID_CREATE2.to_ne_bytes()); + // union (uhid_create2_req) starts at byte 4. + put_cstr(&mut ev, 4, 128, &format!("Punktfunk Switch Pro Controller {index}")); // name[128] + put_cstr(&mut ev, 132, 64, &format!("punktfunk/switchpro/{index}")); // phys[64] + put_cstr(&mut ev, 196, 64, &format!("punktfunk-swpro-{index}")); // uniq[64] + ev[260..262].copy_from_slice(&(PROCON_RDESC.len() as u16).to_ne_bytes()); // rd_size + ev[262..264].copy_from_slice(&BUS_USB.to_ne_bytes()); // bus (selects the driver's USB init path) + ev[264..268].copy_from_slice(&SWITCH_VENDOR.to_ne_bytes()); + ev[268..272].copy_from_slice(&SWITCH_PRODUCT.to_ne_bytes()); + ev[272..276].copy_from_slice(&0x0200u32.to_ne_bytes()); // version (bcdDevice 2.00) + ev[276..280].copy_from_slice(&0u32.to_ne_bytes()); // country + ev[280..280 + PROCON_RDESC.len()].copy_from_slice(PROCON_RDESC); // rd_data + self.fd.write_all(&ev).context("write UHID_CREATE2")?; + Ok(()) + } + + /// Write one full input report to the kernel (UHID_INPUT2). + fn write_report(&mut self, r: &[u8; SWITCH_REPORT_LEN]) -> Result<()> { + let mut ev = [0u8; UHID_EVENT_SIZE]; + ev[0..4].copy_from_slice(&UHID_INPUT2.to_ne_bytes()); + ev[4..6].copy_from_slice(&(r.len() as u16).to_ne_bytes()); // input2.size + ev[6..6 + r.len()].copy_from_slice(r); // input2.data + self.fd.write_all(&ev).context("write UHID_INPUT2")?; + Ok(()) + } + + /// Serialize the state into the standard `0x30` report and stream it. + pub fn write_state(&mut self, st: &SwitchState) -> Result<()> { + self.state = *st; + self.timer = self.timer.wrapping_add(1); + let r = serialize_report_0x30(st, self.timer); + self.write_report(&r) + } + + /// Answer one subcommand from the driver with its canned `0x21` reply. + fn answer_subcmd(&mut self, id: u8, args: &[u8]) { + self.timer = self.timer.wrapping_add(1); + let st = self.state; + let reply = match id { + // Device info — the fatal one (probe aborts without it): type = Pro Controller + + // this pad's virtual MAC. Real hardware acks it with 0x82. + 0x02 => build_subcmd_reply(&st, self.timer, 0x82, id, &device_info_payload(&switch_mac(self.index))), + // SPI flash read: echoed addr + len + the canned calibration bytes. An unmapped + // range answers zeroes (echoed header, zero data) — the driver then warns and uses + // its defaults instead of stalling through 2 × 1 s timeouts. + 0x10 => { + let addr = args + .get(..4) + .map(|a| u32::from_le_bytes([a[0], a[1], a[2], a[3]])) + .unwrap_or(0); + let len = args.get(4).copied().unwrap_or(0); + let payload = spi_flash_read(addr, len).unwrap_or_else(|| { + tracing::debug!(addr = format!("{addr:#x}"), len, "unmapped SPI read — zero fill"); + let mut p = Vec::with_capacity(5 + len as usize); + p.extend_from_slice(&addr.to_le_bytes()); + p.push(len); + p.extend(std::iter::repeat_n(0u8, len as usize)); + p + }); + build_subcmd_reply(&st, self.timer, 0x90, id, &payload) + } + // Everything else the driver sends (input mode 0x03, IMU 0x40, vibration 0x48, + // player lights 0x30, home light 0x38, …) just needs the ack + echoed id. + _ => build_subcmd_reply(&st, self.timer, 0x80, id, &[]), + }; + let _ = self.write_report(&reply); + } + + /// Service the device, non-blocking: answer the driver's probe conversation (USB commands + + /// subcommands) and surface a game's rumble / player-lights feedback for pad `pad`. Call + /// frequently — each probe step blocks the driver until answered. + pub fn service(&mut self, pad: u8) -> PadFeedback { + let mut fb = PadFeedback::default(); + let mut ev = [0u8; UHID_EVENT_SIZE]; + while let Ok(n) = self.fd.read(&mut ev) { + if n < UHID_EVENT_SIZE { + break; + } + match u32::from_ne_bytes([ev[0], ev[1], ev[2], ev[3]]) { + UHID_OUTPUT => { + // uhid_output_req: data[4096] at [4..4100], size u16 at [4100..4102]. + let size = u16::from_ne_bytes([ev[4100], ev[4101]]) as usize; + let end = 4 + size.min(HID_MAX_DESCRIPTOR_SIZE); + match parse_output(&ev[4..end]) { + Some(SwitchOutput::UsbCmd(cmd)) => { + // Ack every 0x80 command, incl. no-timeout (0x04) — the driver + // ignores that ack but replying skips its 2 × 100 ms wait. + let _ = self.write_report(&build_usb_ack(cmd)); + } + Some(SwitchOutput::Subcmd { id, args, rumble }) => { + fb.rumble = Some(rumble); + if id == 0x30 { + // Player lights ride the subcommand itself; still ack it. + if let Some(&arg) = args.first() { + fb.hidout.push(HidOutput::PlayerLeds { + pad, + bits: player_leds_bits(arg), + }); + } + } + self.answer_subcmd(id, &args); + } + Some(SwitchOutput::Rumble(r)) => fb.rumble = Some(r), + None => {} + } + } + UHID_GET_REPORT => { + // hid-nintendo never GET_REPORTs; answer EIO so nothing ever blocks on us. + let req_id = u32::from_ne_bytes([ev[4], ev[5], ev[6], ev[7]]); + let _ = self.reply_get_report_err(req_id); + } + _ => {} // Start/Stop/Open/Close/SetReport — ignore + } + } + fb + } + + fn reply_get_report_err(&mut self, id: u32) -> Result<()> { + let mut ev = [0u8; UHID_EVENT_SIZE]; + ev[0..4].copy_from_slice(&UHID_GET_REPORT_REPLY.to_ne_bytes()); + // uhid_get_report_reply_req: id u32 [4..8], err u16 [8..10], size u16 [10..12]. + ev[4..8].copy_from_slice(&id.to_ne_bytes()); + ev[8..10].copy_from_slice(&5u16.to_ne_bytes()); // EIO + self.fd + .write_all(&ev) + .context("write UHID_GET_REPORT_REPLY")?; + Ok(()) + } +} + +impl Drop for SwitchProPad { + fn drop(&mut self) { + let mut ev = [0u8; UHID_EVENT_SIZE]; + ev[0..4].copy_from_slice(&UHID_DESTROY.to_ne_bytes()); + let _ = self.fd.write_all(&ev); + } +} + +/// The Switch-Pro-specific half of the shared stateful manager (see [`PadProto`]): UHID +/// transport open, the [`SwitchState`] mappers, and the probe-conversation service pass. +/// Lifecycle (slot table, unplug sweep, heartbeat, dedup) lives in [`UhidManager`]. +pub struct SwitchProProto { + /// Fallback policy for the Steam back grips a client may send (a Pro Controller has no + /// back-button slot). `PUNKTFUNK_STEAM_REMAP=paddles=…`; default drop. + remap: crate::inject::steam_remap::RemapConfig, +} + +impl Default for SwitchProProto { + fn default() -> SwitchProProto { + SwitchProProto { + remap: crate::inject::steam_remap::RemapConfig::from_env(), + } + } +} + +impl PadProto for SwitchProProto { + type Pad = SwitchProPad; + type State = SwitchState; + const LABEL: &'static str = "Switch Pro"; + const DEVICE: &'static str = "Switch Pro Controller"; + const CREATE_HINT: &'static str = ""; + + fn open(&mut self, idx: u8) -> Result { + let p = SwitchProPad::open(idx)?; + tracing::info!( + index = idx, + "virtual Switch Pro Controller created (UHID hid-nintendo)" + ); + Ok(p) + } + + fn neutral(&self) -> SwitchState { + SwitchState::neutral() + } + + /// Merge buttons/sticks/triggers from the frame, preserving motion (it arrives on the rich + /// plane and must survive a button-only frame). Paddles fold via the configured policy. + fn merge_frame( + &self, + prev: &SwitchState, + f: &crate::gamestream::gamepad::GamepadFrame, + ) -> SwitchState { + let buttons = crate::inject::steam_remap::fold_paddles(f.buttons, self.remap.paddles); + let mut s = SwitchState::from_gamepad( + buttons, + f.ls_x, + f.ls_y, + f.rs_x, + f.rs_y, + f.left_trigger, + f.right_trigger, + ); + s.gyro = prev.gyro; + s.accel = prev.accel; + s + } + + /// Motion lands on the IMU sample frames; a Pro Controller has no touchpad, so touch events + /// are dropped (the client folds trackpads into stick/mouse modes itself). + fn apply_rich(&self, st: &mut SwitchState, rich: RichInput) { + if let RichInput::Motion { gyro, accel, .. } = rich { + st.apply_motion(gyro, accel); + } + } + + fn write_state(&self, pad: &mut SwitchProPad, st: &SwitchState) { + let _ = pad.write_state(st); + } + + /// Answer the driver's probe conversation (it blocks `hid-nintendo` init until every step is + /// answered — call frequently) and surface a game's feedback: HD-rumble amplitude on the + /// universal 0xCA plane, player lights on the 0xCD plane. + fn service(&self, pad: &mut SwitchProPad, idx: u8) -> PadFeedback { + pad.service(idx) + } +} + +/// All virtual Switch Pro Controllers of a session — `PUNKTFUNK_GAMEPAD=switchpro`, or the +/// per-pad kind a client declares for a Nintendo-family physical pad. +pub type SwitchProManager = UhidManager; diff --git a/crates/punktfunk-host/src/inject/proto/switch_proto.rs b/crates/punktfunk-host/src/inject/proto/switch_proto.rs new file mode 100644 index 00000000..d4ab7aed --- /dev/null +++ b/crates/punktfunk-host/src/inject/proto/switch_proto.rs @@ -0,0 +1,654 @@ +//! Transport-independent Nintendo Switch Pro Controller contract — the report codec + canned +//! handshake replies the Linux UHID backend ([`super::switch_pro`]) drives `hid-nintendo` with. +//! +//! Everything here is pinned against the kernel driver source (drivers/hid/hid-nintendo.c — +//! the ONE consumer of these bytes; a virtual pad must answer its probe exactly or no input +//! devices appear): +//! +//! - **USB handshake**: 2-byte output reports `0x80 ` (handshake / baudrate / no-timeout), +//! each ACKed with an input report `0x81 ` (`joycon_send_usb` matches only those two +//! bytes). +//! - **Subcommands**: output report `0x01` (packet counter + 8 rumble bytes + subcommand id + +//! args), ACKed with input report `0x21` — a 12-byte input-state header, then ack byte / +//! echoed subcommand id / payload. The driver matches ONLY the echoed id (byte 14) and +//! requires ≥ 49 bytes; real hardware sends 64. +//! - **SPI flash reads** (subcommand `0x10`): the driver reads the user-calibration magics +//! (absent here → `0xFF 0xFF`, so it takes the factory path), the factory stick calibrations +//! (9-byte packed 12-bit triples — max/center/min order DIFFERS left vs right), and the +//! 24-byte factory IMU calibration. We serve blobs chosen so the math is clean: sticks +//! centered at [`STICK_CENTER`] ± [`STICK_RANGE`], IMU offsets 0 with the driver's default +//! scales (accel 16384, gyro 13371) so raw units pass through 1:1. +//! - **Input report `0x30`**: 3 button bytes (bit layout per `JC_BTN_*`), two packed 12-bit +//! stick triples, battery/connection, and 3 IMU sample frames (accel then gyro, i16 LE). +//! - **Rumble**: 4 encoded bytes per side in every `0x01`/`0x10` output; we decode the +//! amplitude through the driver's own `joycon_rumble_amplitudes` table (inverted) back to the +//! 0..=0xFFFF wire magnitudes it was scaled from (left = strong/low, right = weak/high). +//! +//! Wire-mapping subtleties (see the plan doc, gamepad-new-types §4): +//! - **Positional swap.** Wire `BTN_A` is the SOUTH button (GameStream convention); on a Switch +//! pad SOUTH is `B`. `from_gamepad` maps wire-south → the report's B bit (and X/Y likewise), +//! so the physical-position ↔ glyph relationship stays correct end-to-end. +//! - **Units.** Wire motion is DualSense-convention (20 LSB/°·s, 10000 LSB/g); the report wants +//! real-Pro-Controller raw units (≈14.247 LSB/°·s per `JC_IMU_GYRO_RES_PER_DPS`, 4096 LSB/g +//! per `JC_IMU_ACCEL_RES_PER_G`), which our calibration blobs make the driver consume 1:1. + +use punktfunk_core::input::gamepad as gs; + +pub const SWITCH_VENDOR: u32 = 0x057E; // Nintendo Co., Ltd +pub const SWITCH_PRODUCT: u32 = 0x2009; // Pro Controller + +/// Nintendo Switch Pro Controller **USB** HID report descriptor (203 bytes) — a verbatim +/// real-device capture (usbhid-dump off a wired Pro Controller; three independent public +/// captures agree byte-for-byte: mzyy94's usbhid-dump, ToadKing's full USB capture, and +/// spacemeowx2's annotated dump). Declares exactly the report ids `hid-nintendo` exchanges +/// wired (inputs 0x30/0x21/0x81, outputs 0x01/0x10/0x80/0x82); the driver reads raw events, +/// so the descriptor only has to `hid_parse()` — but this is what real hardware presents. +/// NOT the Bluetooth descriptor (that one is ~170 bytes with a different report set). +#[rustfmt::skip] +pub const PROCON_RDESC: &[u8] = &[ + 0x05, 0x01, 0x15, 0x00, 0x09, 0x04, 0xA1, 0x01, 0x85, 0x30, 0x05, 0x01, 0x05, 0x09, 0x19, 0x01, + 0x29, 0x0A, 0x15, 0x00, 0x25, 0x01, 0x75, 0x01, 0x95, 0x0A, 0x55, 0x00, 0x65, 0x00, 0x81, 0x02, + 0x05, 0x09, 0x19, 0x0B, 0x29, 0x0E, 0x15, 0x00, 0x25, 0x01, 0x75, 0x01, 0x95, 0x04, 0x81, 0x02, + 0x75, 0x01, 0x95, 0x02, 0x81, 0x03, 0x0B, 0x01, 0x00, 0x01, 0x00, 0xA1, 0x00, 0x0B, 0x30, 0x00, + 0x01, 0x00, 0x0B, 0x31, 0x00, 0x01, 0x00, 0x0B, 0x32, 0x00, 0x01, 0x00, 0x0B, 0x35, 0x00, 0x01, + 0x00, 0x15, 0x00, 0x27, 0xFF, 0xFF, 0x00, 0x00, 0x75, 0x10, 0x95, 0x04, 0x81, 0x02, 0xC0, 0x0B, + 0x39, 0x00, 0x01, 0x00, 0x15, 0x00, 0x25, 0x07, 0x35, 0x00, 0x46, 0x3B, 0x01, 0x65, 0x14, 0x75, + 0x04, 0x95, 0x01, 0x81, 0x02, 0x05, 0x09, 0x19, 0x0F, 0x29, 0x12, 0x15, 0x00, 0x25, 0x01, 0x75, + 0x01, 0x95, 0x04, 0x81, 0x02, 0x75, 0x08, 0x95, 0x34, 0x81, 0x03, 0x06, 0x00, 0xFF, 0x85, 0x21, + 0x09, 0x01, 0x75, 0x08, 0x95, 0x3F, 0x81, 0x03, 0x85, 0x81, 0x09, 0x02, 0x75, 0x08, 0x95, 0x3F, + 0x81, 0x03, 0x85, 0x01, 0x09, 0x03, 0x75, 0x08, 0x95, 0x3F, 0x91, 0x83, 0x85, 0x10, 0x09, 0x04, + 0x75, 0x08, 0x95, 0x3F, 0x91, 0x83, 0x85, 0x80, 0x09, 0x05, 0x75, 0x08, 0x95, 0x3F, 0x91, 0x83, + 0x85, 0x82, 0x09, 0x06, 0x75, 0x08, 0x95, 0x3F, 0x91, 0x83, 0xC0, +]; +/// Every input report we emit is the full USB size (the driver requires ≥ 49 for `0x21`). +pub const SWITCH_REPORT_LEN: usize = 64; + +/// Stick raw center + full-deflection range of OUR virtual pad's calibration (12-bit axis). +/// The factory blobs below advertise exactly this, so the driver maps +/// `center ± range → ∓/± 32767` — one clean linear scale from the wire values. +pub const STICK_CENTER: u16 = 2048; +pub const STICK_RANGE: u16 = 1400; + +/// `battery and connection info` byte (report byte 2): high 3 bits = level (4 = full), +/// BIT(4) = charging, BIT(0) = host powered — "full + charging + wired", so no low-battery +/// warnings ever. +pub const BAT_CON_FULL_WIRED: u8 = 0x91; +/// `vibrator_report` (report byte 12): must be non-zero or the driver stops pumping its rumble +/// queue (`joycon_ctlr_read_handler` gates on it). Real hardware sends 0x70-ish. +pub const VIBRATOR_READY: u8 = 0x70; + +// Button bits of the 24-bit little-endian button field (report bytes 3..6), per the kernel's +// JC_BTN_* defines. +pub mod btn { + pub const Y: u32 = 1 << 0; + pub const X: u32 = 1 << 1; + pub const B: u32 = 1 << 2; + pub const A: u32 = 1 << 3; + pub const R: u32 = 1 << 6; + pub const ZR: u32 = 1 << 7; + pub const MINUS: u32 = 1 << 8; + pub const PLUS: u32 = 1 << 9; + pub const RSTICK: u32 = 1 << 10; + pub const LSTICK: u32 = 1 << 11; + pub const HOME: u32 = 1 << 12; + pub const CAPTURE: u32 = 1 << 13; + pub const DOWN: u32 = 1 << 16; + pub const UP: u32 = 1 << 17; + pub const RIGHT: u32 = 1 << 18; + pub const LEFT: u32 = 1 << 19; + pub const L: u32 = 1 << 22; + pub const ZL: u32 = 1 << 23; +} + +/// Full Pro Controller state serialized into report `0x30` (and the `0x21` reply headers). +/// Sticks are the RAW 12-bit values ([`STICK_CENTER`]-centered); motion is raw IMU units. +#[derive(Clone, Copy)] +pub struct SwitchState { + /// 24-bit `JC_BTN_*` field. + pub buttons: u32, + pub lx: u16, + pub ly: u16, + pub rx: u16, + pub ry: u16, + /// Raw gyro (≈14.247 LSB/°·s) and accel (4096 LSB/g), driver axis order x/y/z. + pub gyro: [i16; 3], + pub accel: [i16; 3], +} + +impl SwitchState { + /// Centered sticks, nothing pressed, flat at rest (1 g on +Z — a pad lying on the desk, so + /// SDL/games don't see a free-falling controller). + pub fn neutral() -> SwitchState { + SwitchState { + buttons: 0, + lx: STICK_CENTER, + ly: STICK_CENTER, + rx: STICK_CENTER, + ry: STICK_CENTER, + gyro: [0; 3], + accel: [0, 0, 4096], + } + } + + /// Map a GameStream/XInput pad frame into Pro Controller state. Face buttons are mapped + /// **positionally** (wire A = south → Switch B, etc. — see the module doc); triggers are + /// digital on a Pro Controller, so any analog pull presses ZL/ZR. The wire paddles have no + /// Switch slot — fold them via [`super::steam_remap`] BEFORE calling this (like the + /// DualSense-family backends do). + pub fn from_gamepad( + buttons: u32, + lx: i16, + ly: i16, + rx: i16, + ry: i16, + lt: u8, + rt: u8, + ) -> SwitchState { + let on = |bit: u32| buttons & bit != 0; + let mut b = 0u32; + // Positional: wire south/east/west/north → the Switch button at that position. + if on(gs::BTN_A) { + b |= btn::B; // south + } + if on(gs::BTN_B) { + b |= btn::A; // east + } + if on(gs::BTN_X) { + b |= btn::Y; // west + } + if on(gs::BTN_Y) { + b |= btn::X; // north + } + if on(gs::BTN_LB) { + b |= btn::L; + } + if on(gs::BTN_RB) { + b |= btn::R; + } + if lt > 0 { + b |= btn::ZL; + } + if rt > 0 { + b |= btn::ZR; + } + if on(gs::BTN_BACK) { + b |= btn::MINUS; + } + if on(gs::BTN_START) { + b |= btn::PLUS; + } + if on(gs::BTN_LS_CLICK) { + b |= btn::LSTICK; + } + if on(gs::BTN_RS_CLICK) { + b |= btn::RSTICK; + } + if on(gs::BTN_GUIDE) { + b |= btn::HOME; + } + if on(gs::BTN_MISC1) { + b |= btn::CAPTURE; + } + if on(gs::BTN_DPAD_UP) { + b |= btn::UP; + } + if on(gs::BTN_DPAD_DOWN) { + b |= btn::DOWN; + } + if on(gs::BTN_DPAD_LEFT) { + b |= btn::LEFT; + } + if on(gs::BTN_DPAD_RIGHT) { + b |= btn::RIGHT; + } + SwitchState { + buttons: b, + lx: stick_raw(lx), + ly: stick_raw(ly), + rx: stick_raw(rx), + ry: stick_raw(ry), + ..SwitchState::neutral() + } + } + + /// Apply a wire motion sample (DualSense-convention units) as raw IMU values. No axis flip: + /// both conventions are x-toward-triggers / z-up for a Pro Controller held like a DualSense, + /// and the driver applies no negation for the Pro (only the right Joy-Con negates). + pub fn apply_motion(&mut self, gyro: [i16; 3], accel: [i16; 3]) { + // gyro: wire 20 LSB/°·s → raw 14.247 LSB/°·s; accel: wire 10000 LSB/g → raw 4096 LSB/g. + self.gyro = gyro.map(|v| ((v as i32 * 14247) / 20000) as i16); + self.accel = accel.map(|v| ((v as i32 * 4096) / 10000) as i16); + } +} + +/// Wire stick value (i16, +32767 = right/up) → raw 12-bit axis. The driver Y-negates BOTH the +/// wire's and evdev's conventions away: it computes `evdev_y = -scale(raw_y)`, and evdev's +/// gamepad convention is negative-up — so wire +y (up) maps to raw above-center, exactly like x. +pub fn stick_raw(v: i16) -> u16 { + let raw = STICK_CENTER as i32 + (v as i32 * STICK_RANGE as i32) / 32767; + raw.clamp(0, 0xFFF) as u16 +} + +/// Pack two 12-bit values into the 3-byte stick / calibration wire form +/// (`hid_field_extract` little-endian bitfield order). +pub fn pack12(a: u16, b: u16) -> [u8; 3] { + [ + (a & 0xFF) as u8, + ((a >> 8) & 0x0F) as u8 | ((b & 0x0F) << 4) as u8, + ((b >> 4) & 0xFF) as u8, + ] +} + +/// Write the shared 13-byte input-state header (report id .. `vibrator_report`) that both the +/// `0x30` stream and every `0x21` subcommand reply carry. +fn write_header(r: &mut [u8; SWITCH_REPORT_LEN], id: u8, st: &SwitchState, timer: u8) { + r[0] = id; + r[1] = timer; + r[2] = BAT_CON_FULL_WIRED; + r[3] = (st.buttons & 0xFF) as u8; + r[4] = ((st.buttons >> 8) & 0xFF) as u8; + r[5] = ((st.buttons >> 16) & 0xFF) as u8; + r[6..9].copy_from_slice(&pack12(st.lx, st.ly)); + r[9..12].copy_from_slice(&pack12(st.rx, st.ry)); + r[12] = VIBRATOR_READY; +} + +/// Serialize the full/standard input report `0x30`: state header + 3 IMU sample frames +/// (accel x/y/z then gyro x/y/z, i16 LE — `struct joycon_imu_data`). We repeat the current +/// sample across all three 5 ms sub-frames (we sample per report, not per sub-frame). +pub fn serialize_report_0x30(st: &SwitchState, timer: u8) -> [u8; SWITCH_REPORT_LEN] { + let mut r = [0u8; SWITCH_REPORT_LEN]; + write_header(&mut r, 0x30, st, timer); + for frame in 0..3 { + let off = 13 + frame * 12; + for (i, v) in st.accel.iter().enumerate() { + r[off + i * 2..off + i * 2 + 2].copy_from_slice(&v.to_le_bytes()); + } + for (i, v) in st.gyro.iter().enumerate() { + r[off + 6 + i * 2..off + 6 + i * 2 + 2].copy_from_slice(&v.to_le_bytes()); + } + } + r +} + +/// Build the `0x81 ` input report acknowledging a USB `0x80 ` command +/// (`joycon_send_usb` matches exactly those two bytes). +pub fn build_usb_ack(cmd: u8) -> [u8; SWITCH_REPORT_LEN] { + let mut r = [0u8; SWITCH_REPORT_LEN]; + r[0] = 0x81; + r[1] = cmd; + r +} + +/// Build a `0x21` subcommand reply: state header, then ack / echoed subcommand id / payload. +/// The driver matches on the echoed id only; the MSB-set ack byte mirrors real hardware +/// (`0x80` plain ack, `0x80 | data-type` when a payload follows). +pub fn build_subcmd_reply( + st: &SwitchState, + timer: u8, + ack: u8, + subcmd: u8, + payload: &[u8], +) -> [u8; SWITCH_REPORT_LEN] { + let mut r = [0u8; SWITCH_REPORT_LEN]; + write_header(&mut r, 0x21, st, timer); + r[13] = ack; + r[14] = subcmd; + let n = payload.len().min(SWITCH_REPORT_LEN - 15); + r[15..15 + n].copy_from_slice(&payload[..n]); + r +} + +/// The device-info payload (subcommand `0x02`): firmware 4.33, type `0x03` = **Pro Controller** +/// (`ctlr_type` — the value that selects the Pro button/stick/IMU paths), `0x02`, the 6-byte +/// MAC (parsed into `ctlr->mac_addr`, printed + used as the input devices' `uniq`), `0x01`, +/// and `0x01` = "colors in SPI" (not read by the driver). +pub fn device_info_payload(mac: &[u8; 6]) -> [u8; 12] { + let mut p = [0u8; 12]; + p[0] = 0x04; + p[1] = 0x21; + p[2] = 0x03; // JOYCON_CTLR_TYPE_PRO + p[3] = 0x02; + p[4..10].copy_from_slice(mac); + p[10] = 0x01; + p[11] = 0x01; + p +} + +/// A stable per-pad virtual MAC (Nintendo OUI + our index) — the driver requires one from +/// device info and keys the input devices' `uniq` off it. +pub fn switch_mac(index: u8) -> [u8; 6] { + [0x7C, 0xBB, 0x8A, 0xDF, 0x00, index] +} + +/// The canned SPI-flash contents (subcommand `0x10`): reply payload = echoed LE address + +/// echoed length + the flash bytes. `None` for an unmapped range (the caller then replies with +/// zeroes — the driver falls back to defaults rather than aborting). +/// +/// Served ranges: +/// - `0x8010`/`0x801B`/`0x8026` (user-cal magics, 2 B): NOT `0xB2 0xA1` → user cal absent, the +/// driver takes the factory path. +/// - `0x603D`/`0x6046` (factory stick cal, 9 B): [`STICK_CENTER`] ± [`STICK_RANGE`] on every +/// axis. **Byte order differs**: left = max-above ++ center ++ min-below; right = center ++ +/// min-below ++ max-above (`joycon_read_stick_calibration`). +/// - `0x6020` (factory IMU cal, 24 B): offsets 0, accel scale 16384, gyro scale 13371 — the +/// driver's own defaults, making its per-sample math the identity (accel) / ×1000 (gyro). +pub fn spi_flash_read(addr: u32, len: u8) -> Option> { + let cal_pair = pack12(STICK_RANGE, STICK_RANGE); + let center_pair = pack12(STICK_CENTER, STICK_CENTER); + let data: Vec = match (addr, len) { + (0x8010 | 0x801B | 0x8026, 2) => vec![0xFF, 0xFF], + (0x603D, 9) => [cal_pair, center_pair, cal_pair].concat(), + (0x6046, 9) => [center_pair, cal_pair, cal_pair].concat(), + (0x6020, 24) => { + let mut v = Vec::with_capacity(24); + v.extend_from_slice(&[0u8; 6]); // accel offsets = 0 + for _ in 0..3 { + v.extend_from_slice(&16384u16.to_le_bytes()); // accel scale (driver default) + } + v.extend_from_slice(&[0u8; 6]); // gyro offsets = 0 + for _ in 0..3 { + v.extend_from_slice(&13371u16.to_le_bytes()); // gyro scale (driver default) + } + v + } + _ => return None, + }; + let mut payload = Vec::with_capacity(5 + data.len()); + payload.extend_from_slice(&addr.to_le_bytes()); + payload.push(len); + payload.extend_from_slice(&data); + Some(payload) +} + +/// One decoded host-bound output report from the driver. +pub enum SwitchOutput { + /// `0x80 ` USB command — answer with [`build_usb_ack`]. + UsbCmd(u8), + /// `0x01` subcommand (with its rumble bytes) — answer with a `0x21` reply. + Subcmd { + id: u8, + /// Subcommand argument bytes (report bytes 11..). + args: Vec, + /// Decoded rumble `(low, high)` magnitudes. + rumble: (u16, u16), + }, + /// `0x10` rumble-only report — no reply expected. + Rumble((u16, u16)), +} + +/// Parse one output report from the driver. Returns `None` for anything unrecognized/short. +pub fn parse_output(data: &[u8]) -> Option { + match *data.first()? { + 0x80 => Some(SwitchOutput::UsbCmd(*data.get(1)?)), + 0x01 if data.len() >= 11 => Some(SwitchOutput::Subcmd { + id: data[10], + args: data.get(11..).map(|s| s.to_vec()).unwrap_or_default(), + rumble: decode_rumble(&data[2..10]), + }), + 0x10 if data.len() >= 10 => Some(SwitchOutput::Rumble(decode_rumble(&data[2..10]))), + _ => None, + } +} + +/// The driver's `joycon_rumble_amplitudes` table, amplitude column only, indexed by +/// `amp_high / 2` (the encoded high-band amplitude byte is always even). Copied verbatim from +/// hid-nintendo.c; last entry = `joycon_max_rumble_amp` (1003). +#[rustfmt::skip] +const RUMBLE_AMPS: [u16; 101] = [ + 0, 10, 12, 14, 17, 20, 24, 28, 33, 40, + 47, 56, 67, 80, 95, 112, 117, 123, 128, 134, + 140, 146, 152, 159, 166, 173, 181, 189, 198, 206, + 215, 225, 230, 235, 240, 245, 251, 256, 262, 268, + 273, 279, 286, 292, 298, 305, 311, 318, 325, 332, + 340, 347, 355, 362, 370, 378, 387, 395, 404, 413, + 422, 431, 440, 450, 460, 470, 480, 491, 501, 512, + 524, 535, 547, 559, 571, 584, 596, 609, 623, 636, + 650, 665, 679, 694, 709, 725, 741, 757, 773, 790, + 808, 825, 843, 862, 881, 900, 920, 940, 960, 981, + 1003, +]; + +/// Invert the driver's per-side rumble encoding back to the 0..=0xFFFF magnitude it scaled +/// from: byte1's even bits carry the amplitude-table index × 2 (`data[1] = freq_high_lo + +/// amp.high`, where the freq contribution is only ever bit 0). +fn side_amplitude(side: &[u8]) -> u16 { + let idx = ((side[1] & 0xFE) / 2) as usize; + let amp = RUMBLE_AMPS[idx.min(RUMBLE_AMPS.len() - 1)] as u32; + // Driver: amp = magnitude * 1003 / 65535 — invert, saturating at full scale. + ((amp * 65535) / 1003).min(65535) as u16 +} + +/// Decode the 8 rumble bytes (left side = strong → wire `low`, right side = weak → wire +/// `high`, per `joycon_play_effect`). +pub fn decode_rumble(bytes: &[u8]) -> (u16, u16) { + if bytes.len() < 8 { + return (0, 0); + } + (side_amplitude(&bytes[..4]), side_amplitude(&bytes[4..8])) +} + +/// Decode a player-lights subcommand payload (`(flash << 4) | on`, one bit per LED) into the +/// wire `PlayerLeds` bits: a flashing LED counts as on. +pub fn player_leds_bits(arg: u8) -> u8 { + (arg & 0x0F) | (arg >> 4) +} + +#[cfg(test)] +mod tests { + use super::*; + + /// The positional swap, pinned: wire south/east/west/north land on the Switch B/A/Y/X bits + /// (the driver then maps them back to BTN_SOUTH/EAST/WEST/NORTH — position-correct + /// end-to-end), and the rest of the buttons land on their JC_BTN_* bits. + #[test] + fn positional_swap_and_button_bits() { + let st = SwitchState::from_gamepad(gs::BTN_A, 0, 0, 0, 0, 0, 0); + assert_eq!(st.buttons, btn::B); + let st = SwitchState::from_gamepad(gs::BTN_B, 0, 0, 0, 0, 0, 0); + assert_eq!(st.buttons, btn::A); + let st = SwitchState::from_gamepad(gs::BTN_X, 0, 0, 0, 0, 0, 0); + assert_eq!(st.buttons, btn::Y); + let st = SwitchState::from_gamepad(gs::BTN_Y, 0, 0, 0, 0, 0, 0); + assert_eq!(st.buttons, btn::X); + // Shoulders / sticks / meta / dpad / triggers-as-digital. + let st = SwitchState::from_gamepad( + gs::BTN_LB | gs::BTN_RB | gs::BTN_BACK | gs::BTN_START | gs::BTN_GUIDE | gs::BTN_MISC1, + 0, + 0, + 0, + 0, + 255, + 1, + ); + assert_eq!( + st.buttons, + btn::L | btn::R | btn::MINUS | btn::PLUS | btn::HOME | btn::CAPTURE | btn::ZL | btn::ZR + ); + let st = SwitchState::from_gamepad(gs::BTN_DPAD_UP | gs::BTN_DPAD_LEFT, 0, 0, 0, 0, 0, 0); + assert_eq!(st.buttons, btn::UP | btn::LEFT); + } + + /// Sticks: wire full deflection → center ± range on the raw 12-bit axis, both axes the same + /// direction (the driver's own Y negation restores evdev's negative-up). + #[test] + fn stick_scaling() { + assert_eq!(stick_raw(0), STICK_CENTER); + assert_eq!(stick_raw(32767), STICK_CENTER + STICK_RANGE); + assert_eq!(stick_raw(-32767), STICK_CENTER - STICK_RANGE); + // Extreme min doesn't underflow past the 12-bit range. + assert!(stick_raw(i16::MIN) <= 0xFFF); + } + + /// The 3-byte 12-bit packing matches `hid_field_extract`'s little-endian bitfield order: + /// value A at bit 0, value B at bit 12. + #[test] + fn pack12_layout() { + assert_eq!(pack12(0x578, 0x578), [0x78, 0x85, 0x57]); // 1400/1400 (the cal pair) + assert_eq!(pack12(0x800, 0x800), [0x00, 0x08, 0x80]); // 2048/2048 (the center pair) + // Extract back: a = b0 | (b1 & 0xF) << 8; b = (b1 >> 4) | b2 << 4. + let p = pack12(0xABC, 0x123); + let a = p[0] as u16 | ((p[1] as u16 & 0xF) << 8); + let b = ((p[1] as u16) >> 4) | ((p[2] as u16) << 4); + assert_eq!((a, b), (0xABC, 0x123)); + } + + /// Report 0x30 layout, pinned against `struct joycon_input_report` + `joycon_imu_data`: + /// header bytes, packed sticks, and the 3 × 12-byte IMU frames (accel then gyro, LE). + #[test] + fn report_0x30_layout() { + let mut st = SwitchState::neutral(); + st.buttons = btn::B | btn::MINUS | btn::ZL; + st.gyro = [0x1122, -2, 3]; + st.accel = [-1, 0x3344, 5]; + let r = serialize_report_0x30(&st, 7); + assert_eq!(r[0], 0x30); + assert_eq!(r[1], 7); + assert_eq!(r[2], BAT_CON_FULL_WIRED); + assert_eq!(r[3], 0x04); // B = bit 2 + assert_eq!(r[4], 0x01); // MINUS = bit 8 + assert_eq!(r[5], 0x80); // ZL = bit 23 + assert_eq!(&r[6..9], &pack12(STICK_CENTER, STICK_CENTER)); + assert_eq!(&r[9..12], &pack12(STICK_CENTER, STICK_CENTER)); + assert_eq!(r[12], VIBRATOR_READY); + // Frame 0 at byte 13: accel x/y/z then gyro x/y/z, i16 LE. + assert_eq!(&r[13..15], &(-1i16).to_le_bytes()); + assert_eq!(&r[15..17], &0x3344u16.to_le_bytes()); + assert_eq!(&r[19..21], &0x1122u16.to_le_bytes()); + // Frames repeat identically at +12 and +24. + assert_eq!(&r[13..25], &r[25..37]); + assert_eq!(&r[13..25], &r[37..49]); + } + + /// Subcommand replies: ≥ 49 bytes (we send 64), ack at byte 13, echoed id at byte 14 (the + /// ONLY byte the driver's matcher checks), payload from byte 15. + #[test] + fn subcmd_reply_layout() { + let st = SwitchState::neutral(); + let r = build_subcmd_reply(&st, 3, 0x90, 0x10, &[0xAA, 0xBB]); + assert_eq!(r.len(), SWITCH_REPORT_LEN); + assert_eq!(r[0], 0x21); + assert_eq!(r[13], 0x90); + assert_eq!(r[14], 0x10); + assert_eq!(&r[15..17], &[0xAA, 0xBB]); + // USB ack: exactly the two bytes joycon_send_usb matches. + let a = build_usb_ack(0x02); + assert_eq!((a[0], a[1]), (0x81, 0x02)); + } + + /// SPI blobs: magics read as ABSENT (≠ B2 A1); the stick blobs put center strictly between + /// min and max on both axes in the driver's per-side byte order; the reply echoes addr+len. + #[test] + fn spi_blobs_valid() { + for addr in [0x8010u32, 0x801B, 0x8026] { + let p = spi_flash_read(addr, 2).unwrap(); + assert_eq!(&p[..4], &addr.to_le_bytes()); + assert_eq!(p[4], 2); + assert!(!(p[5] == 0xB2 && p[6] == 0xA1)); + } + let unpack = |b: &[u8]| -> (u16, u16) { + let a = b[0] as u16 | ((b[1] as u16 & 0xF) << 8); + let y = ((b[1] as u16) >> 4) | ((b[2] as u16) << 4); + (a, y) + }; + // Left: max-above ++ center ++ min-below. + let l = spi_flash_read(0x603D, 9).unwrap(); + let (data, hdr) = (&l[5..], &l[..5]); + assert_eq!(hdr, &[0x3D, 0x60, 0, 0, 9]); + let (max_above, _) = unpack(&data[0..3]); + let (center, _) = unpack(&data[3..6]); + let (min_below, _) = unpack(&data[6..9]); + assert_eq!(center, STICK_CENTER); + assert!(center - min_below < center && center < center + max_above); + // Right: center ++ min-below ++ max-above. + let r = spi_flash_read(0x6046, 9).unwrap(); + let (rc, _) = unpack(&r[5..8]); + assert_eq!(rc, STICK_CENTER); + // IMU: offsets 0, driver-default scales — the identity calibration. + let imu = spi_flash_read(0x6020, 24).unwrap(); + let d = &imu[5..]; + assert_eq!(&d[0..6], &[0; 6]); + assert_eq!(&d[6..8], &16384u16.to_le_bytes()); + assert_eq!(&d[12..18], &[0; 6]); + assert_eq!(&d[18..20], &13371u16.to_le_bytes()); + // Unmapped range → None. + assert!(spi_flash_read(0x6050, 12).is_none()); + } + + /// Motion unit conversion: wire (20 LSB/°·s, 10000 LSB/g) → raw (14.247 LSB/°·s, 4096 LSB/g). + #[test] + fn motion_units() { + let mut st = SwitchState::neutral(); + // 100 °/s = wire 2000 → raw ≈ 1424; 1 g = wire 10000 → raw 4096. + st.apply_motion([2000, 0, -2000], [10000, -10000, 0]); + assert_eq!(st.gyro, [1424, 0, -1424]); + assert_eq!(st.accel, [4096, -4096, 0]); + } + + /// Rumble decode inverts the driver's encoder: a neutral packet decodes to silence; the + /// max-amplitude packet decodes to full scale; left = low/strong, right = high/weak. + #[test] + fn rumble_decode() { + // Neutral per the driver's tables: freq defaults + amp 0. + let neutral = [0x00, 0x01, 0x40, 0x40, 0x00, 0x01, 0x40, 0x40]; + assert_eq!(decode_rumble(&neutral), (0, 0)); + // Max amp (0xC8 → index 100 → 1003 → 65535) on the LEFT only → (low=full, high=0). + let left_max = [0x00, 0xC8, 0x40, 0x72, 0x00, 0x01, 0x40, 0x40]; + assert_eq!(decode_rumble(&left_max), (65535, 0)); + // Mid-table on the right: amp_high 0x20 → index 16 → 117 → 117*65535/1003 = 7644. + let right_mid = [0x00, 0x01, 0x40, 0x40, 0x00, 0x20, 0x48, 0x40]; + assert_eq!(decode_rumble(&right_mid), (0, 7644)); + // The freq bit riding data[1] bit0 must not disturb the amplitude index. + let with_freq_bit = [0x00, 0x21, 0x48, 0x40, 0x00, 0x01, 0x40, 0x40]; + assert_eq!(decode_rumble(&with_freq_bit).0, 7644); + // Short slice → silence, not a panic. + assert_eq!(decode_rumble(&[0x10; 4]), (0, 0)); + } + + /// Output-report parse: the three shapes the driver sends. + #[test] + fn parse_output_shapes() { + assert!(matches!( + parse_output(&[0x80, 0x02]), + Some(SwitchOutput::UsbCmd(0x02)) + )); + let mut sub = vec![0x01, 0x05]; + sub.extend_from_slice(&[0x00, 0x01, 0x40, 0x40, 0x00, 0x01, 0x40, 0x40]); + sub.push(0x10); // subcmd id + sub.extend_from_slice(&[0x3D, 0x60, 0x00, 0x00, 0x09]); // SPI addr+len args + match parse_output(&sub) { + Some(SwitchOutput::Subcmd { id, args, rumble }) => { + assert_eq!(id, 0x10); + assert_eq!(&args[..5], &[0x3D, 0x60, 0x00, 0x00, 0x09]); + assert_eq!(rumble, (0, 0)); + } + _ => panic!("expected subcmd"), + } + let mut rum = vec![0x10, 0x06]; + rum.extend_from_slice(&[0x00, 0xC8, 0x40, 0x72, 0x00, 0x01, 0x40, 0x40]); + assert!(matches!( + parse_output(&rum), + Some(SwitchOutput::Rumble((65535, 0))) + )); + assert!(parse_output(&[0x21]).is_none()); + assert!(parse_output(&[]).is_none()); + } + + /// Player lights: solid + flashing nibbles both count as lit. + #[test] + fn player_lights() { + assert_eq!(player_leds_bits(0x01), 0b0001); + assert_eq!(player_leds_bits(0x10), 0b0001); // flashing LED 1 + assert_eq!(player_leds_bits(0x23), 0b0011 | 0b0010); + } + + /// Device info: type byte 0x03 (Pro Controller) at payload[2], MAC at [4..10]. + #[test] + fn device_info_shape() { + let mac = switch_mac(3); + let p = device_info_payload(&mac); + assert_eq!(p[2], 0x03); + assert_eq!(&p[4..10], &mac); + assert_eq!(mac[5], 3); + } +} diff --git a/crates/punktfunk-host/src/main.rs b/crates/punktfunk-host/src/main.rs index 9be4030c..b167e2da 100644 --- a/crates/punktfunk-host/src/main.rs +++ b/crates/punktfunk-host/src/main.rs @@ -323,6 +323,71 @@ fn real_main() -> Result<()> { println!("dualsense-test: done"); Ok(()) } + // Create a virtual Switch Pro Controller via UHID and exercise it (validation, no + // streaming session): answers the full hid-nintendo probe conversation, then cycles the + // A/B buttons (positionally swapped) + sweeps the left stick, printing rumble / player- + // light feedback. Verify with `evtest` (hid-nintendo input devices), `dmesg | grep + // nintendo`, SDL identifying a "Nintendo Switch Pro Controller". + #[cfg(target_os = "linux")] + Some("switchpro-test") => { + use inject::switch_pro::SwitchProPad; + use inject::switch_proto::SwitchState; + let secs: u64 = args + .iter() + .skip_while(|a| *a != "--seconds") + .nth(1) + .and_then(|s| s.parse().ok()) + .unwrap_or(20); + use std::time::{Duration, Instant}; + let mut pad = SwitchProPad::open(0) + .context("create virtual Switch Pro Controller via /dev/uhid")?; + // Answer the driver's probe conversation promptly — every step blocks hid-nintendo + // init until its reply lands; also stream neutral 0x30 reports like real hardware. + println!("virtual Switch Pro created — servicing the hid-nintendo probe…"); + let init = Instant::now() + Duration::from_millis(2500); + let mut hb = Instant::now(); + while Instant::now() < init { + let fb = pad.service(0); + for o in fb.hidout { + println!(" probe feedback: {o:?}"); + } + if hb.elapsed() >= Duration::from_millis(15) { + hb = Instant::now(); + let _ = pad.write_state(&SwitchState::neutral()); + } + std::thread::sleep(Duration::from_millis(2)); + } + println!("probe window over — cycling buttons + stick for {secs}s (check evtest)"); + let deadline = Instant::now() + Duration::from_secs(secs); + let (mut i, mut last_write) = (0i32, Instant::now()); + while Instant::now() < deadline { + let fb = pad.service(0); + if let Some((low, high)) = fb.rumble { + println!(" rumble from kernel/game: low={low} high={high}"); + } + for o in fb.hidout { + println!(" hid output from kernel/game: {o:?}"); + } + // ~15 ms cadence = the real controller's report rate (also keeps the driver's + // post-probe subcommand rate limiter fed). + if last_write.elapsed() >= Duration::from_millis(15) { + last_write = Instant::now(); + i += 1; + let step = i / 20; // change the pressed button every ~300 ms + let buttons = if step % 2 == 0 { + punktfunk_core::input::gamepad::BTN_A + } else { + punktfunk_core::input::gamepad::BTN_B + }; + let lx = (((i % 64) - 32) * 1024) as i16; // sweep left stick X + let st = SwitchState::from_gamepad(buttons, lx, 0, 0, 0, 0, 0); + pad.write_state(&st).context("write Switch Pro report")?; + } + std::thread::sleep(Duration::from_millis(2)); + } + println!("switchpro-test: done"); + Ok(()) + } // Windows: create a virtual DualSense via the UMDF driver (SwDeviceCreate per-session devnode // + the shared-memory channel) and hold it, pushing one fixed frame (Cross + LS-right). Drives // the real DualSenseWindowsManager, so it validates the device lifecycle end to end. Verify diff --git a/crates/punktfunk-host/src/punktfunk1.rs b/crates/punktfunk-host/src/punktfunk1.rs index 2f0ed588..60ff16e7 100644 --- a/crates/punktfunk-host/src/punktfunk1.rs +++ b/crates/punktfunk-host/src/punktfunk1.rs @@ -1777,6 +1777,8 @@ struct Pads { dualshock4: Option, #[cfg(target_os = "linux")] steamdeck: Option, + #[cfg(target_os = "linux")] + switchpro: Option, #[cfg(target_os = "windows")] dualsense_win: Option, #[cfg(target_os = "windows")] @@ -1808,6 +1810,8 @@ impl Pads { dualshock4: None, #[cfg(target_os = "linux")] steamdeck: None, + #[cfg(target_os = "linux")] + switchpro: None, #[cfg(target_os = "windows")] dualsense_win: None, #[cfg(target_os = "windows")] @@ -1879,6 +1883,11 @@ impl Pads { .get_or_insert_with(crate::inject::steam_controller::SteamControllerManager::new) .handle(ev), #[cfg(target_os = "linux")] + GamepadPref::SwitchPro => self + .switchpro + .get_or_insert_with(crate::inject::switch_pro::SwitchProManager::new) + .handle(ev), + #[cfg(target_os = "linux")] GamepadPref::XboxOne => self .xboxone .get_or_insert_with(|| { @@ -1958,6 +1967,12 @@ impl Pads { m.apply_rich(rich) } } + #[cfg(target_os = "linux")] + GamepadPref::SwitchPro => { + if let Some(m) = &mut self.switchpro { + m.apply_rich(rich) + } + } #[cfg(target_os = "windows")] GamepadPref::DualSense => { if let Some(m) = &mut self.dualsense_win { @@ -2009,6 +2024,9 @@ impl Pads { if let Some(m) = &mut self.steamdeck { m.pump(&mut rumble, &mut hidout); } + if let Some(m) = &mut self.switchpro { + m.pump(&mut rumble, &mut hidout); + } } #[cfg(target_os = "windows")] { @@ -2044,6 +2062,9 @@ impl Pads { if let Some(m) = &mut self.steamdeck { m.heartbeat(gap); } + if let Some(m) = &mut self.switchpro { + m.heartbeat(gap); + } } #[cfg(target_os = "windows")] { @@ -2749,7 +2770,9 @@ fn pick_gamepad(pref: GamepadPref, env: Option<&str>, linux: bool, windows: bool // DualSense plus native back/Fn buttons, so the wire paddles stop hitting the fold/drop // policy. Degrades to Xbox360 elsewhere like its siblings. GamepadPref::DualSenseEdge if linux || windows => GamepadPref::DualSenseEdge, - // Switch Pro: no backend yet (N2) — falls through to Xbox360 below. + // Switch Pro: Linux UHID hid-nintendo (≥ 5.16) — correct Nintendo glyphs + positional + // layout + gyro + HD rumble. No Windows backend; folds to Xbox360 there. + GamepadPref::SwitchPro if linux => GamepadPref::SwitchPro, _ => GamepadPref::Xbox360, } } @@ -2766,6 +2789,7 @@ fn degrade_if_no_uhid(chosen: GamepadPref) -> GamepadPref { | GamepadPref::DualSenseEdge | GamepadPref::DualShock4 | GamepadPref::SteamDeck + | GamepadPref::SwitchPro ); if needs_uhid && std::fs::OpenOptions::new() @@ -5348,10 +5372,12 @@ mod tests { DualSenseEdge ); assert_eq!(pick_gamepad(DualSenseEdge, None, false, false), Xbox360); - // Switch Pro: no backend yet (gamepad-new-types N2) — folds to Xbox360 everywhere. - assert_eq!(pick_gamepad(SwitchPro, None, true, false), Xbox360); - assert_eq!(pick_gamepad(Auto, Some("switchpro"), true, false), Xbox360); + // Switch Pro: native on Linux (UHID hid-nintendo); Xbox360 on Windows and elsewhere. + assert_eq!(pick_gamepad(SwitchPro, None, true, false), SwitchPro); + assert_eq!(pick_gamepad(Auto, Some("switchpro"), true, false), SwitchPro); + assert_eq!(pick_gamepad(Auto, Some("switch"), true, false), SwitchPro); assert_eq!(pick_gamepad(SwitchPro, None, false, true), Xbox360); + assert_eq!(pick_gamepad(SwitchPro, None, false, false), Xbox360); } #[test] diff --git a/docs-site/content/docs/configuration.md b/docs-site/content/docs/configuration.md index 31209162..1b1cb61b 100644 --- a/docs-site/content/docs/configuration.md +++ b/docs-site/content/docs/configuration.md @@ -95,7 +95,7 @@ See your desktop page ([KDE](/docs/kde), [GNOME](/docs/gnome)) for when to set t | Setting | Values | Meaning | |---|---|---| -| `PUNKTFUNK_GAMEPAD` | `xbox360` · `xboxone` · `dualsense` · `dualsenseedge` · `dualshock4` · `steamdeck` · `steamcontroller` (aliases: `ps5`, `edge`, `ps4`, `deck`, …) | The virtual pad the host creates. Usually **auto-resolved from the client's physical controller** — set this only to force a type. `xbox360` (XInput) is the universal fallback. `dualsenseedge` gives the client's back paddles native buttons. DualSense (Edge)/DualShock 4/Steam Deck need Linux UHID; unsupported choices fold to Xbox 360. | +| `PUNKTFUNK_GAMEPAD` | `xbox360` · `xboxone` · `dualsense` · `dualsenseedge` · `dualshock4` · `steamdeck` · `switchpro` · `steamcontroller` (aliases: `ps5`, `edge`, `ps4`, `deck`, `switch`, …) | The virtual pad the host creates. Usually **auto-resolved from the client's physical controller** — set this only to force a type. `xbox360` (XInput) is the universal fallback. `dualsenseedge` gives the client's back paddles native buttons; `switchpro` gives Nintendo-family pads correct glyphs/layout + gyro. DualSense (Edge)/DualShock 4/Steam Deck/Switch Pro need Linux UHID; unsupported choices fold to Xbox 360. | | `PUNKTFUNK_STEAM_GADGET` | `1` · `0` | Force the raw USB-gadget virtual Steam Deck on/off. **On by default on SteamOS**, off elsewhere. Lets Steam promote the virtual Deck to full Steam Input. | ## Audio / microphone