Files
punktfunk/crates/pf-inject/src/inject/linux/switch_pro.rs
T
enricobuehler 42a0dd52be
windows / build (aarch64-pc-windows-msvc) (pull_request) Successful in 1m10s
ci / docs-site (pull_request) Successful in 1m16s
apple / swift (pull_request) Successful in 1m21s
apple / screenshots (pull_request) Skipped
ci / web (pull_request) Successful in 1m37s
ci / rust-arm64 (pull_request) Successful in 2m0s
windows / build (x86_64-pc-windows-msvc) (pull_request) Successful in 2m2s
android / android (pull_request) Successful in 5m3s
ci / rust (pull_request) Successful in 9m44s
refactor(haptics): one copy of each thing every rumble path was transcribing
Twelve findings from the sweep's DRY/docs/dead-code tail. Most are small; three found
real defects hiding behind the duplication.

**The UHID event ABI existed five times.** Every UHID gamepad backend — DualSense,
DualShock 4, Switch Pro, Steam Controller, Steam Controller 2 — carried its own verbatim
copy of the kernel's constants plus its own `put_cstr`, and they had already drifted:
`switch_pro` was missing the SET_REPORT pair entirely, and `steam_controller` read a
FIXED 16-byte SET_REPORT window instead of the event's own `size`. That last one is a
bug in both directions — a longer report was truncated, and a shorter one had the parser
reading whatever the reused event buffer still held past the payload, i.e. acting on
rumble values the game never wrote. Now one `uhid_abi` module owns the numbers plus the
two accessors that are easy to get subtly wrong, with tests on exactly that.

**A dead force-feedback id fallback.** ff-core's `input_ff_upload` picks a free effect
slot and writes it into the effect BEFORE uinput forwards the request, so the `id == -1`
branch could never run — and allocating from a local counter would have been the wrong
answer anyway, since the kernel owns that id space. Removed, with a `debug_assert` where
it stood.

**Apple's HID path silently dropped weak rumble.** `hidByte` took the top byte with no
non-zero floor, so every amplitude below 0x0100 rendered as exactly nothing. Android has
always floored it at 1; this was the odd one out. That converter also existed twice
byte-identically inside one Gradle module — now one `wireAmplitudeToByte`.

Also: the DS5 output-report layout gets named offsets (`dualsense_proto::out_report`)
documenting all three transport bases — USB 0, SDL payload −1, Bluetooth +2 — since the
differing bases are transport-forced, not drift. `pf-client-core` cannot import them (it
and `pf-inject` do not depend on each other, and a DualSense layout has no business in
`punktfunk-core`, their only shared crate), so its copy now DERIVES its offsets by
explicit subtraction and a test pins the relationship. `PUNKTFUNK_HID_EFFECT_MAX` sizes
the struct it describes instead of a second literal 11 — the header now emits
`uint8_t effect[PUNKTFUNK_HID_EFFECT_MAX]`. The rumble policy engine's `min_pulse_ms`
and `keepalive_ms` docs stop naming cases nothing implements: no in-tree caller sets
`min_pulse_ms`, and the macOS DualSense-over-BT keepalive the doc cited CANNOT be served
by the quirk, because that renderer skips writes whose levels are unchanged and would
swallow the engine's re-emit — it keeps its own keepalive instead. `TrackpadHaptic` is
marked as staged scaffolding (the tag is on a shipped wire; removing the variant would
not reclaim it). Three ×257-vs-`<<8` doc comments corrected — the scaling itself is fine,
both round-trip to 255. `backstop_ms.max(160)` deleted as unreachable (the engine floors
at 500). New tests for `Ds5Feedback` and for the Android rumble JNI packing on BOTH sides,
with `MAX_PADS <= 16` now a compile-time assertion rather than a comment.

Closes S1-S9, S11, T2, T3 (design/haptics-sweep-2026-08-03.md M12).

S11's second half is NOT a defect and was left alone: `clients/session/src/main.rs`
calls `set_forwarding` unconditionally on every params-build (its own comment explains
why — browse mode reuses one service across launches), so `Ctl::Forwarding` routinely
arrives unchanged and that early-out is what stops a redundant `sync_open` + Valve-HIDAPI
cycle each launch.

Verified: pf-inject clippy -D warnings 0 / 91 tests; pf-client-core + punktfunk-core
clippy 0 / 437 tests (amd64 container); punktfunk-client-android 7 tests; Android :kit:
6 tests; Apple swift build + 189 tests / 0 failures; cargo fmt --all --check clean. Each
new test probed by reverting its fix — the fixed SET_REPORT window fails 3, a broken pack
shift fails 3, dropping the amplitude floor fails 1, and a wrong DS5 offset either fails
the pin or refuses to compile.
2026-08-04 22:52:38 +02:00

312 lines
14 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 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 12 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::uhid_abi::{
put_cstr, BUS_USB, HID_MAX_DESCRIPTOR_SIZE, UHID_CREATE2, UHID_DESTROY, UHID_EVENT_SIZE,
UHID_GET_REPORT, UHID_GET_REPORT_REPLY, UHID_INPUT2, UHID_OUTPUT, UHID_PATH,
};
use crate::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;
/// 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<SwitchProPad> {
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::steam_remap::RemapConfig,
}
impl Default for SwitchProProto {
fn default() -> SwitchProProto {
SwitchProProto {
remap: crate::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<SwitchProPad> {
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: &punktfunk_core::input::GamepadFrame,
) -> SwitchState {
let buttons = crate::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 {
let mut fb = pad.service(idx);
// Rumble-plane liveness: hid-nintendo embeds rumble data in every command it sends, so a
// poll that surfaced rumble is the activity signal; a writer that goes silent on a latched
// level is cut by the shared abandoned-rumble force-off (a physical Joy-Con/Pro's
// HD-rumble decays on its own far faster than the idle window).
fb.rumble_drove = Some(fb.rumble.is_some());
fb
}
}
/// 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<SwitchProProto>;