Files
punktfunk/crates/punktfunk-host/src/native/input.rs
T
enricobuehlerandClaude Fable 5 ed3d236ab8 feat(pad-audio): DualSense audio haptics + speaker, host->client end to end
The 0xD1 pad-audio plane streams a DualSense's voice-coil haptics (back
channel pair, 5 ms Opus frames) and speaker (front pair, 10 ms) per pad from
a Windows host to the SDL clients, which render them into a USB DualSense's
own 4-channel audio device.

Wire (punktfunk-core, ABI v15): PAD_AUDIO_MAGIC 0xD1 [pad][kind][seq][pts]
[opus]; CLIENT_CAP_PAD_AUDIO 0x04 / HOST_CAP_PAD_AUDIO 0x20; per-pad render
capability rides GamepadArrival flags bits 8/9, sent only toward a host that
advertised its cap so old hosts see byte-identical arrivals; silence is a
frozen seq (mic-mute discipline), loss is a seq gap concealed via
AudioGapTracker. HidOutput::AudioCtl (0xCD kind 0x06) forwards the 0x02
report's audio-control bytes 5..=10 change-only, value-deduped, with a
once-per-pad "title asserted haptics-select" diagnosis log.

Windows host endpoint provider (audio/windows/pad_endpoint.rs): per-pad
render endpoints are additional devnode instances of Valve's Steam Streaming
Speakers driver (SetupDiRegisterDeviceInfo, NOT the class installer - it
needs an interactive window station), stamped with DualSense identity: desc
"Wireless Controller", device name "DualSense Wireless Controller",
ContainerId = the virtual pad's PFDS GUID, 4ch/48k format triplet.
IPropertyStore route first, ACL-repaired registry fallback (the MMDevices
keys deny writes even to SYSTEM; the owner's implicit WRITE_DAC + an ACE for
S-1-5-18 resolved by SID is the way in). Provisioned at host startup
(PUNKTFUNK_PAD_AUDIO, PUNKTFUNK_PAD_AUDIO_SLOTS, default 1), idempotent via
a persisted PunktfunkPadIndex marker; pad endpoints are structurally
ineligible for the mic/loopback wiring plan and guarded against default-
device theft; capture is WASAPI loopback on the stamped endpoint. Devtest:
punktfunk-host pad-endpoint ensure|remove|status.

Host service (native/pad_audio.rs): per-(session,pad) thread, loopback 4ch
-> pair splitter -> per-kind stereo Opus (48k LowDelay CBR 64k) -> per-kind
silence gate (opens at peak>=1e-3, 250 ms hangover, gated = no send + frozen
seq) -> datagrams. Spawned from the native input pump when a DualSense/Edge
arrival carries audio bits and both caps negotiated; idempotent re-arrivals;
reaped on remove and teardown.

Client tier A (pf-client-core/pad_audio.rs): settings pad_haptics (default
on) and pad_speaker (default "pad"); tier A = wired USB DS5/Edge via SDL
connection state with an audio-sibling fallback; correlation maps the SDL
HID path to the pad's own render endpoint (Windows: ContainerId match +
4ch gate via registry; Linux: Sony sink signature); renderer decodes both
kinds into a quad interleave and plays it on the pad's endpoint (WASAPI
autoconvert / PipeWire target.object, 240-2400 frame ring floor,
dont-reconnect so an unplug never re-routes haptics to the desktop
speakers). SDL's DualSense driver sets "disable audio haptics" whenever it
drives rumble emulation, so tier-A pads suppress wire rumble and send one
cleared-enable-bits effects packet to keep the actuators live; AudioCtl
bytes fold back into the effects packet at report-minus-one offsets.

Verification: punktfunk-core 265 tests (macOS) + clippy -D warnings (mac +
Linux docker); pf-inject 85 tests (Linux docker); punktfunk-host cargo
check + clippy + 19 pad tests + 46 audio-module tests (Windows box);
pf-client-core 30 tests + clippy (Linux docker CI image) + cargo check
(Windows box); punktfunk-client-session clippy (Linux) + check (Windows);
cargo fmt --all --check clean on the final tree. NOT yet verified: any
on-glass run (host deploy + real title + physical pad), the stamp-route
split at runtime, exclusive-mode Initialize isolation, Linux-host emission
(the per-pad PipeWire sink is not in this change - Windows hosts only).
Scope excluded deliberately: tier B (Apple CoreHaptics) and tier C
(haptics->rumble derivation), pad_speaker="mix", Android leg, settings UI
surfaces (keys are serde-defaulted), GameStream-plane arrivals (audio_caps
always 0 there).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-01 12:07:06 +02:00

1210 lines
55 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.
//! The native input plane (plan §W1 — carved out of the [`super`] module): the client→host input
//! thread and the per-pad virtual-gamepad router ([`Pads`]) that fans mixed controller kinds out to
//! the right injector backend (uinput / UHID on Linux, XUSB / UMDF on Windows), plus rumble
//! feedback. `serve_session` spawns [`input_thread`] and feeds it a channel of [`ClientInput`].
use super::*;
/// Per-pad accumulated state: punktfunk/1 gamepad events are incremental (one button or axis
/// per datagram, see `punktfunk_core::input::gamepad`), the virtual xpad applies full frames.
/// A snapshot-capable client replaces the whole state at once ([`PadState::set_snapshot`]).
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
struct PadState {
buttons: u32,
left_trigger: u8,
right_trigger: u8,
ls_x: i16,
ls_y: i16,
rs_x: i16,
rs_y: i16,
}
impl PadState {
/// Fold one wire event into the state. `false` = unknown axis id (event dropped).
fn apply(&mut self, ev: &InputEvent) -> bool {
if ev.kind == InputKind::GamepadButton {
if ev.x != 0 {
self.buttons |= ev.code;
} else {
self.buttons &= !ev.code;
}
return true;
}
use punktfunk_core::input::gamepad::*;
let stick = ev.x.clamp(i16::MIN as i32, i16::MAX as i32) as i16;
let trigger = ev.x.clamp(0, 255) as u8;
match ev.code {
AXIS_LS_X => self.ls_x = stick,
AXIS_LS_Y => self.ls_y = stick,
AXIS_RS_X => self.rs_x = stick,
AXIS_RS_Y => self.rs_y = stick,
AXIS_LT => self.left_trigger = trigger,
AXIS_RT => self.right_trigger = trigger,
_ => return false,
}
true
}
/// Replace the whole state from one client snapshot (the [`InputKind::GamepadState`] form).
fn set_snapshot(&mut self, s: &punktfunk_core::input::GamepadSnapshot) {
self.buttons = s.buttons;
self.left_trigger = s.left_trigger;
self.right_trigger = s.right_trigger;
self.ls_x = s.ls_x;
self.ls_y = s.ls_y;
self.rs_x = s.rs_x;
self.rs_y = s.rs_y;
}
fn frame(&self, index: usize, active_mask: u16) -> punktfunk_core::input::GamepadFrame {
punktfunk_core::input::GamepadFrame {
index: index as i16,
active_mask,
buttons: self.buttons,
left_trigger: self.left_trigger,
right_trigger: self.right_trigger,
ls_x: self.ls_x,
ls_y: self.ls_y,
rs_x: self.rs_x,
rs_y: self.rs_y,
}
}
}
/// Highest pad index addressable on the wire (`flags` field / snapshot `pad`); the uinput
/// manager caps actual pad creation at its own MAX_PADS.
const MAX_WIRE_PADS: usize = punktfunk_core::input::MAX_PADS;
/// Per-pad virtual-gamepad router: each pad index is served by a backend of that pad's declared
/// kind ([`InputKind::GamepadArrival`](punktfunk_core::input::InputKind::GamepadArrival)), so ONE
/// session can MIX controller types — pad 0 a DualSense, pad 1 an Xbox pad. A pad the client never
/// declares uses `default` (the session kind resolved from the Hello — the pre-existing single-kind
/// behaviour).
///
/// Backends are created lazily per kind (an empty manager holds no device), and each owns only the
/// indices routed to it. A manager's `active_mask` unplug sweep stays correct across managers
/// because an index another manager owns is `None` in this one, so the sweep never touches it.
///
/// - Xbox 360 / One — uinput on Linux ([`GamepadManager`](crate::inject::gamepad::GamepadManager),
/// two identities), the XUSB companion driver (classic XInput) on Windows.
/// - DualSense / DualSense Edge / DualShock 4 — Linux UHID `hid-playstation`, or the Windows UMDF
/// minidriver (device-type 0/2/1).
/// - Steam Deck — Linux UHID `hid-steam` (or usbip/gadget), or the Windows UMDF minidriver
/// (device-type 3, Steam-Input-promoted).
///
/// [`resolve_pad_kind`] folds any kind a platform can't build into one it can, so this never
/// constructs a manager the build lacks.
struct Pads {
/// Declared (and host-resolved) kind per pad index; `default` until a `GamepadArrival` lands.
kinds: [GamepadPref; MAX_WIRE_PADS],
/// The kind of the manager that currently OWNS a built device at each index (`None` = no
/// device). A live device stays in its manager even if `kinds[idx]` later changes (the rare
/// arrival-after-first-frame reorder), so a pad is never duplicated across managers and its
/// removal always reaches the manager that actually holds it.
owner: [Option<GamepadPref>; MAX_WIRE_PADS],
xbox360: Option<crate::inject::gamepad::GamepadManager>,
#[cfg(target_os = "linux")]
xboxone: Option<crate::inject::gamepad::GamepadManager>,
#[cfg(target_os = "linux")]
dualsense: Option<crate::inject::dualsense::DualSenseManager>,
#[cfg(target_os = "linux")]
dualsense_edge: Option<crate::inject::dualsense::DualSenseEdgeManager>,
#[cfg(target_os = "linux")]
dualshock4: Option<crate::inject::dualshock4::DualShock4Manager>,
#[cfg(target_os = "linux")]
steamdeck: Option<crate::inject::steam_controller::SteamControllerManager>,
#[cfg(target_os = "linux")]
switchpro: Option<crate::inject::switch_pro::SwitchProManager>,
#[cfg(target_os = "linux")]
steamctrl: Option<crate::inject::steam_controller::SteamCtrlManager>,
#[cfg(target_os = "linux")]
steamctrl2: Option<crate::inject::steam_controller2::Triton2Manager>,
#[cfg(target_os = "linux")]
steamctrl2_puck: Option<crate::inject::steam_controller2::Triton2Manager>,
#[cfg(target_os = "windows")]
dualsense_win: Option<crate::inject::dualsense_windows::DualSenseWindowsManager>,
#[cfg(target_os = "windows")]
dualsense_edge_win: Option<crate::inject::dualsense_edge_windows::DualSenseEdgeWindowsManager>,
#[cfg(target_os = "windows")]
dualshock4_win: Option<crate::inject::dualshock4_windows::DualShock4WindowsManager>,
#[cfg(target_os = "windows")]
steamdeck_win: Option<crate::inject::steam_deck_windows::SteamDeckWindowsManager>,
}
impl Pads {
/// `default` is the session kind (see [`resolve_gamepad`]); every pad starts on it until the
/// client declares its own kind.
fn new(default: GamepadPref) -> Pads {
let default = resolve_pad_kind(default);
tracing::info!(
default = default.as_str(),
"gamepad backends: per-pad router (session default)"
);
Pads {
kinds: [default; MAX_WIRE_PADS],
owner: [None; MAX_WIRE_PADS],
xbox360: None,
#[cfg(target_os = "linux")]
xboxone: None,
#[cfg(target_os = "linux")]
dualsense: None,
#[cfg(target_os = "linux")]
dualsense_edge: None,
#[cfg(target_os = "linux")]
dualshock4: None,
#[cfg(target_os = "linux")]
steamdeck: None,
#[cfg(target_os = "linux")]
switchpro: None,
#[cfg(target_os = "linux")]
steamctrl: None,
#[cfg(target_os = "linux")]
steamctrl2: None,
#[cfg(target_os = "linux")]
steamctrl2_puck: None,
#[cfg(target_os = "windows")]
dualsense_win: None,
#[cfg(target_os = "windows")]
dualsense_edge_win: None,
#[cfg(target_os = "windows")]
dualshock4_win: None,
#[cfg(target_os = "windows")]
steamdeck_win: None,
}
}
/// Record a pad's client-declared kind (resolved to a buildable backend). Takes effect on the
/// pad's next frame; the arrival is sent before the pad's first input, so a device already
/// built under the wrong kind is only the rare arrival-after-first-frame reorder — it then
/// keeps the earlier kind until re-plug (no live device swap).
fn set_kind(&mut self, idx: usize, kind: GamepadPref) {
if idx >= MAX_WIRE_PADS {
return;
}
let resolved = resolve_pad_kind(kind);
if self.kinds[idx] != resolved {
tracing::info!(
pad = idx,
kind = resolved.as_str(),
"gamepad kind declared (per-pad)"
);
}
self.kinds[idx] = resolved;
}
fn handle(&mut self, ev: &punktfunk_core::input::GamepadEvent) {
use punktfunk_core::input::GamepadEvent;
// Present = a create/update frame (the pad's mask bit is set); a cleared bit is the
// removal frame emitted by the native detach path (`GamepadRemove`).
let (idx, present) = match ev {
GamepadEvent::State(f) => {
let idx = f.index as usize;
(idx, f.active_mask & (1 << idx) != 0)
}
GamepadEvent::Arrival { index, .. } => (*index as usize, true),
};
if idx >= MAX_WIRE_PADS {
return;
}
let (kind, new_owner) = route_decision(self.owner[idx], self.kinds[idx], present);
self.owner[idx] = new_owner;
self.route_handle(kind, ev);
}
/// Dispatch a decoded event to the manager for `kind`, creating it lazily.
fn route_handle(&mut self, kind: GamepadPref, ev: &punktfunk_core::input::GamepadEvent) {
match kind {
#[cfg(target_os = "linux")]
GamepadPref::DualSense => self
.dualsense
.get_or_insert_with(crate::inject::dualsense::DualSenseManager::new)
.handle(ev),
#[cfg(target_os = "linux")]
GamepadPref::DualSenseEdge => self
.dualsense_edge
.get_or_insert_with(crate::inject::dualsense::DualSenseEdgeManager::new)
.handle(ev),
#[cfg(target_os = "linux")]
GamepadPref::DualShock4 => self
.dualshock4
.get_or_insert_with(crate::inject::dualshock4::DualShock4Manager::new)
.handle(ev),
#[cfg(target_os = "linux")]
GamepadPref::SteamDeck => self
.steamdeck
.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::SteamController => self
.steamctrl
.get_or_insert_with(crate::inject::steam_controller::SteamCtrlManager::new)
.handle(ev),
#[cfg(target_os = "linux")]
GamepadPref::SteamController2 => self
.steamctrl2
.get_or_insert_with(crate::inject::steam_controller2::Triton2Manager::new)
.handle(ev),
#[cfg(target_os = "linux")]
GamepadPref::SteamController2Puck => self
.steamctrl2_puck
.get_or_insert_with(|| {
crate::inject::steam_controller2::Triton2Manager::with_backend(
crate::inject::steam_controller2::TritonProto::puck(),
)
})
.handle(ev),
#[cfg(target_os = "linux")]
GamepadPref::XboxOne => self
.xboxone
.get_or_insert_with(|| {
crate::inject::gamepad::GamepadManager::with_identity(
crate::inject::gamepad::PadIdentity::xbox_one(),
)
})
.handle(ev),
#[cfg(target_os = "windows")]
GamepadPref::DualSense => self
.dualsense_win
.get_or_insert_with(crate::inject::dualsense_windows::DualSenseWindowsManager::new)
.handle(ev),
#[cfg(target_os = "windows")]
GamepadPref::DualSenseEdge => self
.dualsense_edge_win
.get_or_insert_with(
crate::inject::dualsense_edge_windows::DualSenseEdgeWindowsManager::new,
)
.handle(ev),
#[cfg(target_os = "windows")]
GamepadPref::DualShock4 => self
.dualshock4_win
.get_or_insert_with(
crate::inject::dualshock4_windows::DualShock4WindowsManager::new,
)
.handle(ev),
#[cfg(target_os = "windows")]
GamepadPref::SteamDeck => self
.steamdeck_win
.get_or_insert_with(crate::inject::steam_deck_windows::SteamDeckWindowsManager::new)
.handle(ev),
_ => self
.xbox360
.get_or_insert_with(crate::inject::gamepad::GamepadManager::new)
.handle(ev),
}
}
/// Apply a rich client→host event (touchpad / motion) to the pad's kind manager, if it exists
/// (rich before the first frame = no device yet = a no-op anyway). The X-Box pads have no rich
/// plane, so those indices ignore it.
fn apply_rich(&mut self, rich: punktfunk_core::quic::RichInput) {
use punktfunk_core::quic::RichInput;
let idx = match rich {
RichInput::Touchpad { pad, .. }
| RichInput::Motion { pad, .. }
| RichInput::TouchpadEx { pad, .. }
| RichInput::HidReport { pad, .. } => pad as usize,
};
// Route to the manager that actually owns the device (falling back to the declared kind
// before the first frame builds it), so a pad's touchpad/motion never lands on the wrong
// backend after a kind change.
let kind = self
.owner
.get(idx)
.copied()
.flatten()
.or_else(|| self.kinds.get(idx).copied())
.unwrap_or(GamepadPref::Xbox360);
match kind {
#[cfg(target_os = "linux")]
GamepadPref::DualSense => {
if let Some(m) = &mut self.dualsense {
m.apply_rich(rich)
}
}
#[cfg(target_os = "linux")]
GamepadPref::DualSenseEdge => {
if let Some(m) = &mut self.dualsense_edge {
m.apply_rich(rich)
}
}
#[cfg(target_os = "linux")]
GamepadPref::DualShock4 => {
if let Some(m) = &mut self.dualshock4 {
m.apply_rich(rich)
}
}
#[cfg(target_os = "linux")]
GamepadPref::SteamDeck => {
if let Some(m) = &mut self.steamdeck {
m.apply_rich(rich)
}
}
#[cfg(target_os = "linux")]
GamepadPref::SwitchPro => {
if let Some(m) = &mut self.switchpro {
m.apply_rich(rich)
}
}
#[cfg(target_os = "linux")]
GamepadPref::SteamController => {
if let Some(m) = &mut self.steamctrl {
m.apply_rich(rich)
}
}
#[cfg(target_os = "linux")]
GamepadPref::SteamController2 => {
if let Some(m) = &mut self.steamctrl2 {
m.apply_rich(rich)
}
}
#[cfg(target_os = "linux")]
GamepadPref::SteamController2Puck => {
if let Some(m) = &mut self.steamctrl2_puck {
m.apply_rich(rich)
}
}
#[cfg(target_os = "windows")]
GamepadPref::DualSense => {
if let Some(m) = &mut self.dualsense_win {
m.apply_rich(rich)
}
}
#[cfg(target_os = "windows")]
GamepadPref::DualSenseEdge => {
if let Some(m) = &mut self.dualsense_edge_win {
m.apply_rich(rich)
}
}
#[cfg(target_os = "windows")]
GamepadPref::DualShock4 => {
if let Some(m) = &mut self.dualshock4_win {
m.apply_rich(rich)
}
}
#[cfg(target_os = "windows")]
GamepadPref::SteamDeck => {
if let Some(m) = &mut self.steamdeck_win {
m.apply_rich(rich)
}
}
_ => {}
}
}
/// Triton's USB output endpoint is polled at 1 kHz. Service its raw haptic writes on the same
/// cadence so PC-generated trackpad pulses do not sit for up to 4 ms and then arrive at the
/// client in bursts. Other backends keep the lower-frequency poll to avoid idle churn.
fn feedback_poll_interval(&self) -> std::time::Duration {
#[cfg(target_os = "linux")]
if self.steamctrl2.is_some() || self.steamctrl2_puck.is_some() {
return std::time::Duration::from_millis(1);
}
std::time::Duration::from_millis(4)
}
/// Service feedback for every instantiated backend each cycle. `rumble` carries motor
/// force-feedback on the universal plane (every backend, tagged with its own pad index);
/// `hidout` carries rich feedback (lightbar / player LEDs / adaptive triggers) for the UHID/UMDF
/// pads. The `&mut` closure re-borrows satisfy `FnMut` for each backend.
fn pump(
&mut self,
mut rumble: impl FnMut(u16, u16, u16),
mut hidout: impl FnMut(punktfunk_core::quic::HidOutput),
) {
if let Some(m) = &mut self.xbox360 {
m.pump_rumble(&mut rumble); // the X-Box pad has no rich-feedback plane
}
#[cfg(target_os = "linux")]
{
if let Some(m) = &mut self.xboxone {
m.pump_rumble(&mut rumble);
}
if let Some(m) = &mut self.dualsense {
m.pump(&mut rumble, &mut hidout);
}
if let Some(m) = &mut self.dualsense_edge {
m.pump(&mut rumble, &mut hidout);
}
if let Some(m) = &mut self.dualshock4 {
m.pump(&mut rumble, &mut hidout);
}
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);
}
if let Some(m) = &mut self.steamctrl {
m.pump(&mut rumble, &mut hidout);
}
if let Some(m) = &mut self.steamctrl2 {
m.pump(&mut rumble, &mut hidout);
}
if let Some(m) = &mut self.steamctrl2_puck {
m.pump(&mut rumble, &mut hidout);
}
}
#[cfg(target_os = "windows")]
{
if let Some(m) = &mut self.dualsense_win {
m.pump(&mut rumble, &mut hidout);
}
if let Some(m) = &mut self.dualsense_edge_win {
m.pump(&mut rumble, &mut hidout);
}
if let Some(m) = &mut self.dualshock4_win {
m.pump(&mut rumble, &mut hidout);
}
if let Some(m) = &mut self.steamdeck_win {
m.pump(&mut rumble, &mut hidout);
}
}
}
/// Keep every instantiated virtual UHID/UMDF pad alive during input silence (re-emit its HID
/// report so the kernel driver / SDL don't drop a held-steady pad). The X-Box pads need no
/// heartbeat (evdev holds last-known state). Per-pad gap timers inside each manager govern the
/// actual emit cadence, not this per-tick call.
fn heartbeat(&mut self) {
#[cfg(target_os = "linux")]
{
let gap = std::time::Duration::from_millis(8);
if let Some(m) = &mut self.dualsense {
m.heartbeat(gap);
}
if let Some(m) = &mut self.dualsense_edge {
m.heartbeat(gap);
}
if let Some(m) = &mut self.dualshock4 {
m.heartbeat(gap);
}
if let Some(m) = &mut self.steamdeck {
m.heartbeat(gap);
}
if let Some(m) = &mut self.switchpro {
m.heartbeat(gap);
}
if let Some(m) = &mut self.steamctrl {
m.heartbeat(gap);
}
if let Some(m) = &mut self.steamctrl2 {
m.heartbeat(gap);
}
}
#[cfg(target_os = "windows")]
{
let gap = std::time::Duration::from_millis(8);
if let Some(m) = &mut self.dualsense_win {
m.heartbeat(gap);
}
if let Some(m) = &mut self.dualsense_edge_win {
m.heartbeat(gap);
}
if let Some(m) = &mut self.dualshock4_win {
m.heartbeat(gap);
}
if let Some(m) = &mut self.steamdeck_win {
m.heartbeat(gap);
}
}
}
}
/// Per-pad 0xD1 streamers (`super::pad_audio`), keyed by pad index like every per-pad table
/// here (bounded by [`MAX_WIRE_PADS`]; only slots 0..4 can ever have a provisioned endpoint —
/// `spawn` refuses the rest). Spawned when a negotiated session's DualSense-family arrival
/// declares renderer bits, reaped on remove / re-declare / session teardown.
struct PadAudioSlots {
/// `(kinds, handle)` per running pad — `kinds` is the arrival's audio-caps mask, kept so
/// an identical re-arrival (they are re-sent against datagram loss) is a no-op.
slots: [Option<(u8, pad_audio::PadAudioHandle)>; MAX_WIRE_PADS],
}
impl PadAudioSlots {
fn new() -> PadAudioSlots {
PadAudioSlots {
slots: std::array::from_fn(|_| None),
}
}
/// Idempotent spawn: same kinds → keep the running streamer; changed kinds → restart with
/// the new mask; not running → spawn (a slot without an endpoint stays empty — bounded
/// retries, since arrivals are only re-sent a few times per slot open).
fn ensure(&mut self, conn: &quinn::Connection, pad: u8, kinds: u8) {
let idx = pad as usize;
if idx >= MAX_WIRE_PADS {
return;
}
if let Some((have, _)) = &self.slots[idx] {
if *have == kinds {
return; // identical re-arrival — keep the running streamer
}
tracing::info!(
pad = idx,
"pad-audio kinds changed — restarting the streamer"
);
self.stop(idx);
}
let stop = Arc::new(AtomicBool::new(false));
if let Some(h) = pad_audio::spawn(conn.clone(), pad, kinds, stop) {
self.slots[idx] = Some((kinds, h));
}
}
/// Stop + reap one pad's streamer. The join rides a detached reaper thread: a quiet pad's
/// capturer can sit out its ~5 s recv timeout, and this thread must keep its ≤4 ms
/// feedback cadence (games block on GET_REPORT handshakes) — the reaper still joins, just
/// not here. A failed reaper spawn falls back to the handle's own drop (signal + join).
fn stop(&mut self, idx: usize) {
if let Some((_, h)) = self.slots.get_mut(idx).and_then(|s| s.take()) {
h.signal();
let _ = std::thread::Builder::new()
.name("punktfunk1-padreap".into())
.spawn(move || h.stop());
}
}
/// Session teardown: flag every streamer FIRST so they wind down concurrently, then join —
/// the worst case is ONE quiet-endpoint recv timeout (~5 s), well inside the session's
/// 10 s side-thread join grace, not one per pad.
fn stop_all(&mut self) {
for s in self.slots.iter().flatten() {
s.1.signal();
}
for s in &mut self.slots {
if let Some((_, h)) = s.take() {
h.stop();
}
}
}
}
/// One client→host input item, both planes on ONE channel so the input thread wakes the
/// moment either arrives (a second rich channel drained after the 4 ms recv timeout cost
/// every pure-gyro motion sample up to 4 ms of quantization).
pub(super) enum ClientInput {
/// The 0xC8 plane: pointer / keyboard / gamepad button+axis.
Event(InputEvent),
/// The 0xCC plane: touchpad contacts + motion samples.
Rich(punktfunk_core::quic::RichInput),
/// The 0xCC/0x05 stylus plane: state-full pen sample batches, diffed into a per-session
/// virtual tablet (design/pen-tablet-input.md).
Pen(punktfunk_core::quic::PenBatch),
}
/// The per-session stylus lane: the core [`PenTracker`](punktfunk_core::quic::PenTracker)
/// diffs state-full batches into transitions, applied to a lazily-created uinput tablet
/// ([`crate::inject::pen::VirtualPen`]) — a session that never draws never creates a device,
/// and the device dies with the session (kernel removes the tablet, apps see the pen unplug).
struct PenSession {
tracker: punktfunk_core::quic::PenTracker,
dev: Option<crate::inject::pen::VirtualPen>,
/// Creation failed once — don't retry per batch (240 Hz of ioctl churn + log spam); the
/// tracker still consumes batches so its state stays coherent.
create_failed: bool,
last_rx: std::time::Instant,
/// Reused transition buffer (a batch yields at most a few).
out: Vec<punktfunk_core::quic::PenTransition>,
}
impl PenSession {
fn new() -> PenSession {
PenSession {
tracker: punktfunk_core::quic::PenTracker::default(),
dev: None,
create_failed: false,
last_rx: std::time::Instant::now(),
out: Vec::new(),
}
}
fn apply(&mut self, batch: &punktfunk_core::quic::PenBatch) {
self.last_rx = std::time::Instant::now();
if self.dev.is_none() && !self.create_failed {
match crate::inject::pen::VirtualPen::create() {
Ok(d) => self.dev = Some(d),
Err(e) => {
// Shouldn't happen when the Welcome advertised HOST_CAP_PEN off the same
// uinput probe — but permissions can change between then and first ink.
self.create_failed = true;
tracing::warn!(
error = %format!("{e:#}"),
"pen: virtual tablet creation failed — dropping pen input this session"
);
}
}
}
self.out.clear();
self.tracker.apply(batch, &mut self.out);
if let Some(dev) = self.dev.as_mut() {
dev.apply_batch(&self.out);
}
}
/// The dead-client failsafe ([`PEN_TOUCH_TIMEOUT_MS`](punktfunk_core::quic::PEN_TOUCH_TIMEOUT_MS)):
/// clients repeat the last sample (≤100 ms) while the pen is in range — a stationary
/// touching pen included — so silence past the timeout means the client is gone, and the
/// host must not leave the stroke inked-down. Called every input-loop wake; the loop caps
/// its recv timeout at 100 ms while the pen is active so this actually gets to run.
fn check_timeout(&mut self) {
if self.tracker.is_active()
&& self.last_rx.elapsed().as_millis()
>= punktfunk_core::quic::PEN_TOUCH_TIMEOUT_MS as u128
{
tracing::debug!("pen: sample stream went silent — force-releasing the stroke");
self.release_all();
}
}
/// Lift buttons/tip/proximity in order (a no-op when idle). Session end + the timeout.
fn release_all(&mut self) {
self.out.clear();
self.tracker.force_release(&mut self.out);
if let Some(dev) = self.dev.as_mut() {
dev.apply_batch(&self.out);
}
}
fn active(&self) -> bool {
self.tracker.is_active()
}
}
/// Default TTL stamped on a non-zero rumble envelope (0xCA v2): how long the client renders the
/// level before silencing unless the host renews it. Tolerates 23 lost renewals (same loss
/// margin the old flat 500 ms refresh gave) while capping a host-abandoned rumble at this on every
/// client — versus the per-platform client heuristics it replaces (SDL 1.5 s, Apple 1.6 s, Android
/// up to the QUIC idle-timeout). Overridable via `PUNKTFUNK_RUMBLE_TTL_MS` (floored at
/// [`RUMBLE_TTL_FLOOR_MS`] so expiry jitter stays below the clients' tick granularity).
const RUMBLE_TTL_MS: u16 = 400;
/// Floor for the `PUNKTFUNK_RUMBLE_TTL_MS` hatch — below this the ~50 ms client ticks make expiry
/// audible (see `rumble-envelope-plan.md` §5).
const RUMBLE_TTL_FLOOR_MS: u16 = 150;
/// Ceiling for the `PUNKTFUNK_RUMBLE_TTL_MS` hatch. A lease longer than a few seconds defeats the
/// design's "an abandoned rumble stops promptly" goal, and keeping it well under `u16::MAX` means
/// the wire never emits a TTL a narrower client-side slot could mistake for a sentinel.
const RUMBLE_TTL_CEIL_MS: u16 = 5_000;
/// Floor for the derived renewal interval (renew = ttl × 3/10) so an aggressive TTL hatch can't
/// spin the renewal loop faster than this.
const RUMBLE_RENEW_FLOOR_MS: u64 = 60;
/// How many times a transition-to-zero (a stop) is re-sent on the renewal ticks after the
/// immediate stop datagram, before the pad goes quiet. Covers stop-datagram loss for legacy
/// clients (a v2 client also self-silences at TTL); even a fully lost burst heals via the client's
/// own expiry. `3` total zero sends = the immediate one + this many renewal re-sends.
const RUMBLE_STOP_BURST: u8 = 2;
/// Send one rumble datagram on the universal 0xCA plane. `envelope_on` picks the self-terminating
/// v2 form (`[level][seq][ttl_ms]`, the default) or the legacy v1 level datagram (the
/// `PUNKTFUNK_RUMBLE_ENVELOPE=0` bisect hatch). Best-effort like every side-plane datagram.
fn send_rumble(
conn: &quinn::Connection,
envelope_on: bool,
pad: u16,
low: u16,
high: u16,
seq: u8,
ttl_ms: u16,
) {
let d: Vec<u8> = if envelope_on {
punktfunk_core::quic::encode_rumble_datagram_v2(pad, low, high, seq, ttl_ms).to_vec()
} else {
punktfunk_core::quic::encode_rumble_datagram(pad, low, high).to_vec()
};
let _ = conn.send_datagram(d.into());
}
/// The per-session input thread: route pointer/keyboard events to the host-lifetime injector
/// service (`inj_tx`) and gamepad events to this session's [`Pads`] router (`gamepad` — the
/// resolved Hello preference is the per-pad default; clients declare each pad's kind so a session
/// can mix uinput X-Box pads and virtual DualSense pads), with rich
/// client→host input (touchpad / motion, [`ClientInput::Rich`]) applied on arrival and
/// feedback pumped between events — rumble on the universal datagram plane, DualSense
/// LED/trigger feedback on the HID-output plane. The gamepads are created and torn down with
/// the session; the pointer/keyboard injector (and its portal grant) lives in the service,
/// across sessions.
///
/// Rumble is emitted as self-terminating 0xCA v2 envelopes (`[level][seq][ttl_ms]`): the host owns
/// the timeline, renewing an active level every ~`RUMBLE_TTL_MS × 3/10` ms and letting an
/// abandoned one expire client-side, so "stuck rumble" is inexpressible on the wire (see
/// `punktfunk-planning/design/rumble-envelope-plan.md`). `PUNKTFUNK_RUMBLE_ENVELOPE=0` reverts to
/// legacy v1 level datagrams + the flat 500 ms refresh (bisect hatch).
pub(super) fn input_thread(
rx: std::sync::mpsc::Receiver<ClientInput>,
conn: quinn::Connection,
inj_tx: std::sync::mpsc::Sender<InputEvent>,
gamepad: GamepadPref,
pad_audio_on: bool,
) {
let mut pads = Pads::new(gamepad);
// Per-pad 0xD1 audio streamers, live only when the Welcome granted the cap (`pad_audio_on`
// — read back off the negotiated host_caps). Spawned on DualSense-family arrivals that
// declare renderer bits, reaped on remove/teardown below.
let mut pad_streams = PadAudioSlots::new();
// Motion-cadence observability (debug level): inter-arrival percentiles per 5 s window,
// the measurement a "gyro feels floaty" report needs. Bounded: 5 s at even a 1 kHz pad
// is 5000 u32s.
let mut motion_gaps_us: Vec<u32> = Vec::new();
let mut last_motion: Option<std::time::Instant> = None;
let mut motion_window = std::time::Instant::now();
let mut pad_state = [PadState::default(); MAX_WIRE_PADS];
let mut pad_mask = 0u16;
// Last applied snapshot seq per pad (`None` until the first one): the reorder gate for
// `InputKind::GamepadState` — a late datagram with an older seq must not roll held state back.
let mut pad_seq: [Option<u8>; MAX_WIRE_PADS] = [None; MAX_WIRE_PADS];
// Rumble self-terminating envelopes (0xCA v2). Each non-zero level is authorized for
// `rumble_ttl_ms`; the host renews an active pad every `rumble_renew` and lets an abandoned
// one expire on the client, so a dropped transition heals on the next renewal and a stop that
// is lost heals via the stop burst (or the client's own TTL expiry). `rumble_seq` is the
// per-pad wrapping reorder counter (bumped on changes AND renewals) the client gates on;
// `rumble_stop_burst` counts the post-stop zero re-sends still owed. `PUNKTFUNK_RUMBLE_ENVELOPE=0`
// reverts to legacy v1 datagrams re-sent flat every 500 ms.
let mut rumble_state = [(0u16, 0u16); MAX_WIRE_PADS];
let mut rumble_seen = [false; MAX_WIRE_PADS];
let mut rumble_seq = [0u8; MAX_WIRE_PADS];
let mut rumble_stop_burst = [0u8; MAX_WIRE_PADS];
let mut last_refresh = std::time::Instant::now();
let rumble_envelope_on = std::env::var("PUNKTFUNK_RUMBLE_ENVELOPE").as_deref() != Ok("0");
let rumble_ttl_ms: u16 = std::env::var("PUNKTFUNK_RUMBLE_TTL_MS")
.ok()
.and_then(|s| s.parse::<u16>().ok())
.map(|v| v.clamp(RUMBLE_TTL_FLOOR_MS, RUMBLE_TTL_CEIL_MS))
.unwrap_or(RUMBLE_TTL_MS);
// Renew at 30 % of the TTL (≈120 ms for the 400 ms default) so 23 renewals cover the lease;
// in legacy mode the periodic block instead runs the old flat 500 ms full-state refresh.
let rumble_refresh_interval = if rumble_envelope_on {
std::time::Duration::from_millis((rumble_ttl_ms as u64 * 3 / 10).max(RUMBLE_RENEW_FLOOR_MS))
} else {
std::time::Duration::from_millis(500)
};
// Pointer buttons / keys the client currently holds down. The injector is host-lifetime, so a
// press left dangling by an abrupt client disconnect stays latched in the compositor across the
// reconnect (Mutter keeps the implicit pointer grab of the still-pressed button — a stuck
// left-button-down then turns every later click into a drag: windows move, but clicking buttons
// and text inputs does nothing). We synthesize the matching up-events when this session ends —
// see the release loop after the `break`.
// Sets (not Vecs) so the presence test is O(1), not O(n) per event, and bounded by `MAX_HELD`
// so a client flooding distinct never-released codes can't grow the tracking state or spike the
// input thread (security-review 2026-06-28 S3). A real keyboard+mouse holds far fewer at once;
// codes past the cap simply aren't tracked for end-of-session release (worst case: one unreleased
// key on a pathological disconnect, which the injector's own state still bounds).
const MAX_HELD: usize = 256;
let mut held_buttons: std::collections::HashSet<u32> = std::collections::HashSet::new();
let mut held_keys: std::collections::HashSet<u32> = std::collections::HashSet::new();
let mut pen = PenSession::new();
loop {
// While a pen is in range/touching, wake at least every 100 ms so the stroke failsafe
// (PenSession::check_timeout) has real granularity against its 200 ms deadline.
let poll = if pen.active() {
pads.feedback_poll_interval()
.min(std::time::Duration::from_millis(100))
} else {
pads.feedback_poll_interval()
};
match rx.recv_timeout(poll) {
// Rich input (touchpad / motion) is applied the moment it arrives; the single channel
// wakes for gyro samples instead of making them wait out the feedback poll interval.
Ok(ClientInput::Rich(rich)) => {
if matches!(rich, punktfunk_core::quic::RichInput::Motion { .. }) {
let now = std::time::Instant::now();
if let Some(prev) = last_motion.replace(now) {
let gap = now.duration_since(prev);
if gap < std::time::Duration::from_secs(1) {
motion_gaps_us.push(gap.as_micros() as u32);
}
}
if motion_window.elapsed() >= std::time::Duration::from_secs(5)
&& !motion_gaps_us.is_empty()
{
motion_gaps_us.sort_unstable();
let p = |q: f64| {
motion_gaps_us[(q * (motion_gaps_us.len() - 1) as f64) as usize]
};
tracing::debug!(
samples = motion_gaps_us.len() + 1,
gap_p50_us = p(0.5),
gap_p95_us = p(0.95),
gap_max_us = motion_gaps_us.last().copied().unwrap_or(0),
"motion cadence (client gyro inter-arrival, 5 s window)"
);
motion_gaps_us.clear();
motion_window = std::time::Instant::now();
}
}
pads.apply_rich(rich);
}
// Stylus batches apply on arrival like rich input — the tracker synthesizes the
// transitions, the lazily-created virtual tablet renders them.
Ok(ClientInput::Pen(batch)) => pen.apply(&batch),
Ok(ClientInput::Event(ev)) => match ev.kind {
InputKind::GamepadButton | InputKind::GamepadAxis => {
// A bad index / unknown axis just doesn't update a pad — fall through (no
// `continue`) so the rich-input drain + feedback pump below still run every
// iteration (the DualSense GET_REPORT handshake must be serviced promptly).
let idx = ev.flags as usize;
if idx < MAX_WIRE_PADS && pad_state[idx].apply(&ev) {
pad_mask |= 1 << idx;
let frame = pad_state[idx].frame(idx, pad_mask);
pads.handle(&punktfunk_core::input::GamepadEvent::State(frame));
}
}
InputKind::GamepadState => {
// Idempotent full-state snapshot from a capable client (see
// `GamepadSnapshot`): applied only when its seq supersedes the last one, so
// a datagram the network reordered can't roll held state backwards. The
// client refreshes touched pads every ~100 ms, so an unchanged refresh is
// the common case — skip the frame emit then (an XInput packet-number bump
// for identical state is pure churn), but always advance the gate.
use punktfunk_core::input::GamepadSnapshot;
if let Some(snap) = GamepadSnapshot::from_event(&ev) {
let idx = snap.pad as usize;
if idx < MAX_WIRE_PADS && GamepadSnapshot::seq_newer(snap.seq, pad_seq[idx])
{
pad_seq[idx] = Some(snap.seq);
let before = pad_state[idx];
pad_state[idx].set_snapshot(&snap);
let first = pad_mask & (1 << idx) == 0;
if first || pad_state[idx] != before {
pad_mask |= 1 << idx;
let frame = pad_state[idx].frame(idx, pad_mask);
pads.handle(&punktfunk_core::input::GamepadEvent::State(frame));
}
}
}
}
InputKind::GamepadRemove => {
// Mid-session hot-unplug from a snapshot-capable client (the native plane's
// `activeGamepadMask` equivalent). Seq-gated in the SAME per-pad sequence
// space as snapshots, so a snapshot the network reordered past this removal
// is dropped (older seq) and can't resurrect the pad — while a later re-plug
// on the same index arrives with a still-newer seq and is accepted. Clearing
// the `active_mask` bit and re-emitting the frame fires every backend's
// unplug sweep (`inject/*/gamepad.rs`), tearing down just this pad's device.
let (pad, seq) = punktfunk_core::input::decode_gamepad_remove(ev.flags);
let idx = pad as usize;
if idx < MAX_WIRE_PADS
&& punktfunk_core::input::GamepadSnapshot::seq_newer(seq, pad_seq[idx])
{
pad_seq[idx] = Some(seq);
if pad_mask & (1 << idx) != 0 {
pad_mask &= !(1 << idx);
pad_state[idx] = PadState::default();
let frame = pad_state[idx].frame(idx, pad_mask);
pads.handle(&punktfunk_core::input::GamepadEvent::State(frame));
tracing::info!(pad = idx, "gamepad unplugged (native detach)");
}
// Fresh feedback bookkeeping so a later re-plug on this index inherits no
// stale rumble lease/seq (a lease still ticking would buzz the new pad).
rumble_state[idx] = (0, 0);
rumble_seen[idx] = false;
rumble_seq[idx] = 0;
rumble_stop_burst[idx] = 0;
// The unplugged pad's 0xD1 streamer goes with it (seq-gated like the
// rest of this arm, so a reordered stale removal can't kill the
// stream of a re-plugged pad). A re-plug re-arrives and re-spawns.
pad_streams.stop(idx);
}
}
InputKind::GamepadArrival => {
// Per-pad controller kind declaration (mixed types): route this pad's future
// frames to a backend of the declared kind. `code` = the GamepadPref wire
// byte, `flags` = pad index in the LOW BYTE — bits 8/9 carry the pad's
// audio-render caps (haptics/speaker) from a pad-audio-capable client, so
// the index MUST come from `decode_gamepad_arrival`, never the whole word.
// Applied before the pad's first frame (the client sends it on slot open),
// so the device is built as the right type from the start. The audio caps
// are surfaced here for the 0xD1 capture path (which emits pad audio only
// toward pads that declared a renderer).
let (pad, audio_caps) = punktfunk_core::input::decode_gamepad_arrival(ev.flags);
let idx = pad as usize;
let kind = GamepadPref::from_u8(ev.code as u8);
if audio_caps != 0 {
tracing::debug!(
pad = idx,
haptics = audio_caps & 0x01 != 0,
speaker = audio_caps & 0x02 != 0,
"pad-audio render caps declared (arrival flags bits 8/9)"
);
}
pads.set_kind(idx, kind);
// Pad audio (0xD1): stream toward DualSense-family pads that declared a
// renderer, only on a session that negotiated the cap. Idempotent across
// the arrival re-sends (same kinds keeps the running streamer); a
// re-declare without bits — or as a kind with no pad audio — stops it.
if pad_audio_on {
let want = if matches!(
kind,
GamepadPref::DualSense | GamepadPref::DualSenseEdge
) {
audio_caps
} else {
0
};
if want != 0 {
pad_streams.ensure(&conn, pad, want);
} else {
pad_streams.stop(idx);
}
}
}
_ => {
// Track press/release so a mid-press disconnect can be undone below.
match ev.kind {
InputKind::MouseButtonDown if held_buttons.len() < MAX_HELD => {
held_buttons.insert(ev.code);
}
InputKind::MouseButtonUp => {
held_buttons.remove(&ev.code);
}
InputKind::KeyDown if held_keys.len() < MAX_HELD => {
held_keys.insert(ev.code);
}
InputKind::KeyUp => {
held_keys.remove(&ev.code);
}
_ => {}
}
// Pointer/keyboard → the host-lifetime injector service (one persistent
// portal session for every punktfunk/1 session). A send error only means the
// service thread is gone (host shutting down) — dropping the event is fine,
// input is lossy by design.
let _ = inj_tx.send(ev);
}
},
Err(std::sync::mpsc::RecvTimeoutError::Timeout) => {}
Err(std::sync::mpsc::RecvTimeoutError::Disconnected) => break,
}
// Pen stroke failsafe: a silent sample stream past the deadline force-releases (see
// PenSession::check_timeout — clients heartbeat while the pen is down).
pen.check_timeout();
// Service feedback every iteration (≤1 ms for Triton, ≤4 ms otherwise; games block on
// EVIOCSFF, and HID handshakes must be answered promptly). Rumble → the universal 0xCA
// plane; rich/raw HID feedback → 0xCD.
pads.pump(
|pad, low, high| {
let idx = pad as usize;
if idx < MAX_WIRE_PADS {
let prev = rumble_state[idx];
// Log the silent→active transition (once per buzz) so a live test can tell
// "host never gets rumble from the game" apart from "client doesn't render it".
if prev == (0, 0) && (low != 0 || high != 0) {
tracing::debug!(pad, low, high, "rumble: forwarding to client (0xCA)");
}
rumble_state[idx] = (low, high);
rumble_seen[idx] = true;
// Bump the reorder counter on every change, then arm the stop burst on a
// transition to zero (so a lost stop still reaches a legacy client) and clear
// it when the game re-asserts a non-zero level.
rumble_seq[idx] = rumble_seq[idx].wrapping_add(1);
if (low, high) == (0, 0) {
rumble_stop_burst[idx] = if prev != (0, 0) { RUMBLE_STOP_BURST } else { 0 };
} else {
rumble_stop_burst[idx] = 0;
}
let ttl = if (low, high) == (0, 0) {
0
} else {
rumble_ttl_ms
};
send_rumble(
&conn,
rumble_envelope_on,
pad,
low,
high,
rumble_seq[idx],
ttl,
);
} else {
// Out-of-range pad (a backend never produces these) — forward without gating.
send_rumble(&conn, rumble_envelope_on, pad, low, high, 0, rumble_ttl_ms);
}
},
|h| {
let _ = conn.send_datagram(h.encode().into());
},
);
// Keep the virtual DualSense from going silent during steady input (no-op for X-Box): a
// held-steady pad sends no wire events, so without a periodic re-emit the kernel/SDL drop
// it as unplugged. The 8 ms gap inside heartbeat() governs the rate, not this ≤4 ms tick.
pads.heartbeat();
if last_refresh.elapsed() >= rumble_refresh_interval {
last_refresh = std::time::Instant::now();
if rumble_envelope_on {
// Renewal: refresh an active pad's lease (bump seq, fresh TTL), and drain each
// pad's post-stop zero burst, then let it go quiet — no perpetual zero refreshes.
for i in 0..MAX_WIRE_PADS {
if !rumble_seen[i] {
continue;
}
let (low, high) = rumble_state[i];
if (low, high) != (0, 0) {
rumble_seq[i] = rumble_seq[i].wrapping_add(1);
send_rumble(
&conn,
true,
i as u16,
low,
high,
rumble_seq[i],
rumble_ttl_ms,
);
} else if rumble_stop_burst[i] > 0 {
rumble_stop_burst[i] -= 1;
rumble_seq[i] = rumble_seq[i].wrapping_add(1);
send_rumble(&conn, true, i as u16, 0, 0, rumble_seq[i], 0);
}
}
} else {
// Legacy: re-send the current level of every seen pad every 500 ms (v1).
for (i, &(low, high)) in rumble_state.iter().enumerate() {
if rumble_seen[i] {
let d = punktfunk_core::quic::encode_rumble_datagram(i as u16, low, high);
let _ = conn.send_datagram(d.to_vec().into());
}
}
}
}
}
// Pen: lift anything still inked (buttons → tip → proximity), then the device dies with
// this thread (VirtualPen::drop → UI_DEV_DESTROY; apps see the tablet unplug).
pen.release_all();
// Session ended (client gone). Release anything still held through the host-lifetime injector —
// its EIS connection (and any implicit grab Mutter holds for our pressed button) outlives this
// session, so without this a button pressed at disconnect stays latched and breaks clicks for
// the next session. Mirror of the injector's own release_all, but keyed off the session, which
// is where a client actually vanishes mid-press.
if !held_buttons.is_empty() || !held_keys.is_empty() {
tracing::debug!(
buttons = held_buttons.len(),
keys = held_keys.len(),
"input: releasing held buttons/keys at session end"
);
}
for code in held_buttons {
let _ = inj_tx.send(InputEvent {
kind: InputKind::MouseButtonUp,
_pad: [0; 3],
code,
x: 0,
y: 0,
flags: 0,
});
}
for code in held_keys {
let _ = inj_tx.send(InputEvent {
kind: InputKind::KeyUp,
_pad: [0; 3],
code,
x: 0,
y: 0,
flags: 0,
});
}
// Reap the per-pad 0xD1 streamers with the session (after the instant release sends above
// — this can block on a quiet pad's capturer timeout, see PadAudioSlots::stop_all).
pad_streams.stop_all();
}
#[cfg(test)]
mod tests {
use super::*;
use punktfunk_core::input::{InputEvent, InputKind};
#[test]
fn pad_snapshot_replaces_state_and_seq_gates() {
use punktfunk_core::input::{gamepad, GamepadSnapshot};
let mut state = PadState::default();
let mut last_seq: Option<u8> = None;
// Legacy accumulation first (an older client), then a snapshot replaces it wholesale.
let axis = InputEvent {
kind: InputKind::GamepadAxis,
_pad: [0; 3],
code: gamepad::AXIS_LT,
x: 200,
y: 0,
flags: 0,
};
assert!(state.apply(&axis));
assert_eq!(state.left_trigger, 200);
let snap = GamepadSnapshot {
pad: 0,
seq: 1,
buttons: gamepad::BTN_A,
left_trigger: 255,
right_trigger: 0,
ls_x: 100,
ls_y: -100,
rs_x: 0,
rs_y: 0,
};
assert!(GamepadSnapshot::seq_newer(snap.seq, last_seq));
last_seq = Some(snap.seq);
state.set_snapshot(&snap);
assert_eq!(state.left_trigger, 255);
assert_eq!(state.buttons, gamepad::BTN_A);
assert_eq!((state.ls_x, state.ls_y), (100, -100));
// A reordered (stale) snapshot must not roll the trigger back.
let stale = GamepadSnapshot {
seq: 0,
left_trigger: 10,
..snap
};
assert!(!GamepadSnapshot::seq_newer(stale.seq, last_seq));
// The unchanged-refresh case the input thread skips the frame emit for: identical
// payload with a newer seq compares equal after apply.
let refresh = GamepadSnapshot { seq: 2, ..snap };
assert!(GamepadSnapshot::seq_newer(refresh.seq, last_seq));
let before = state;
state.set_snapshot(&refresh);
assert_eq!(state, before);
// The snapshot survives the wire roundtrip into the same PadState shape.
let dec =
GamepadSnapshot::from_event(&InputEvent::decode(&snap.to_event().encode()).unwrap())
.unwrap();
assert_eq!(dec, snap);
}
fn gp(kind: InputKind, code: u32, x: i32, pad: u32) -> InputEvent {
InputEvent {
kind,
_pad: [0; 3],
code,
x,
y: 0,
flags: pad,
}
}
/// Incremental wire events accumulate into the full pad frame the virtual xpad applies.
#[test]
fn gamepad_accumulator() {
use punktfunk_core::input::gamepad::*;
let mut s = PadState::default();
assert!(s.apply(&gp(InputKind::GamepadButton, BTN_A, 1, 0)));
assert!(s.apply(&gp(InputKind::GamepadButton, BTN_LB, 1, 0)));
assert!(s.apply(&gp(InputKind::GamepadAxis, AXIS_LS_X, -32768, 0)));
assert!(s.apply(&gp(InputKind::GamepadAxis, AXIS_RT, 255, 0)));
let f = s.frame(2, 0b0100);
assert_eq!(f.buttons, BTN_A | BTN_LB);
assert_eq!((f.ls_x, f.right_trigger), (-32768, 255));
assert_eq!((f.index, f.active_mask), (2, 0b0100));
// Release folds out; axis values clamp; unknown axis ids are rejected.
assert!(s.apply(&gp(InputKind::GamepadButton, BTN_A, 0, 0)));
assert_eq!(s.frame(0, 1).buttons, BTN_LB);
assert!(s.apply(&gp(InputKind::GamepadAxis, AXIS_LT, 9_999, 0)));
assert_eq!(s.left_trigger, 255);
assert!(!s.apply(&gp(InputKind::GamepadAxis, 42, 1, 0)));
}
}