refactor(inject/linux/dualshock4): convert to UhidManager<Ds4LinuxProto> (3.3)
DualShock4Manager becomes a pub type alias of UhidManager<Ds4LinuxProto> (the same shape as the other three DS-family conversions); the bespoke last_led lightbar dedup folds into the shared HidoutDedup exactly as the Windows DS4 conversion did. With 3.3.0 already applied, the proto half is byte-identical to Ds4WinProto except the transport open — the codec, the mappers, and now the manager all shared. Verified on .21: clippy --all-targets -D warnings clean; full suite 290 pass / 0 fail. Part of G12/3.3 (§3a.4 commit 7). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -18,14 +18,12 @@ use super::dualshock4_proto::{
|
|||||||
parse_ds4_output, serialize_state, Ds4Feedback, DS4_INPUT_REPORT_LEN, DS4_PRODUCT, DS4_TOUCH_H,
|
parse_ds4_output, serialize_state, Ds4Feedback, DS4_INPUT_REPORT_LEN, DS4_PRODUCT, DS4_TOUCH_H,
|
||||||
DS4_TOUCH_W, DS4_VENDOR,
|
DS4_TOUCH_W, DS4_VENDOR,
|
||||||
};
|
};
|
||||||
use crate::gamestream::gamepad::{GamepadEvent, MAX_PADS};
|
use crate::inject::uhid_manager::{PadFeedback, PadProto, UhidManager};
|
||||||
use crate::inject::pad_gate::PadGate;
|
|
||||||
use anyhow::{Context, Result};
|
use anyhow::{Context, Result};
|
||||||
use punktfunk_core::quic::{HidOutput, RichInput};
|
use punktfunk_core::quic::{HidOutput, RichInput};
|
||||||
use std::fs::{File, OpenOptions};
|
use std::fs::{File, OpenOptions};
|
||||||
use std::io::{Read, Write};
|
use std::io::{Read, Write};
|
||||||
use std::os::unix::fs::OpenOptionsExt;
|
use std::os::unix::fs::OpenOptionsExt;
|
||||||
use std::time::{Duration, Instant};
|
|
||||||
|
|
||||||
// /dev/uhid event ABI (linux/uhid.h) — identical to the DualSense backend's; see `super::dualsense`.
|
// /dev/uhid event ABI (linux/uhid.h) — identical to the DualSense backend's; see `super::dualsense`.
|
||||||
const UHID_PATH: &str = "/dev/uhid";
|
const UHID_PATH: &str = "/dev/uhid";
|
||||||
@@ -263,200 +261,103 @@ impl Drop for DualShock4Pad {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// All virtual DualShock 4 pads of a session — the PS4 analog of
|
/// The DualShock-4-specific half of the shared stateful manager (see [`PadProto`]): UHID transport
|
||||||
/// [`DualSenseManager`](super::dualsense::DualSenseManager), selected with `PUNKTFUNK_GAMEPAD=ps4`.
|
/// open, the [`DsState`] mappers, and the kernel-handshake service pass. Lifecycle (slot table,
|
||||||
/// Like the DualSense it keeps each pad's full [`DsState`] and re-emits the merged report whenever
|
/// unplug sweep, heartbeat, dedup) lives in [`UhidManager`]; the lightbar dedup that used to be a
|
||||||
/// buttons/sticks ([`handle`](Self::handle)) or touchpad/motion ([`apply_rich`](Self::apply_rich))
|
/// bespoke `last_led` vec (the kernel bundles the lightbar into every output report, incl.
|
||||||
/// change. [`pump`](Self::pump) services the kernel handshake and routes a game's feedback back:
|
/// rumble-only writes) now rides the shared `HidoutDedup` — identical semantics, `Led` compared
|
||||||
/// motor rumble on the universal plane, the lightbar on the HID-output plane.
|
/// against the last-forwarded value and re-armed on create/unplug.
|
||||||
pub struct DualShock4Manager {
|
pub struct Ds4LinuxProto {
|
||||||
pads: Vec<Option<DualShock4Pad>>,
|
|
||||||
/// Each pad's current full report — buttons/sticks merged with persisted touch + motion.
|
|
||||||
state: Vec<DsState>,
|
|
||||||
/// Last rumble forwarded per pad, so a report that only changes the lightbar doesn't re-send it.
|
|
||||||
last_rumble: Vec<(u16, u16)>,
|
|
||||||
/// Last lightbar RGB forwarded per pad — the kernel bundles the lightbar into every output
|
|
||||||
/// report (incl. rumble-only writes), so dedup here to avoid flooding the HID-output plane.
|
|
||||||
last_led: Vec<Option<(u8, u8, u8)>>,
|
|
||||||
/// When each pad last wrote an input report — drives [`heartbeat`](Self::heartbeat).
|
|
||||||
last_write: Vec<Instant>,
|
|
||||||
/// Create-retry gate: a transient `/dev/uhid` failure backs off and retries instead of
|
|
||||||
/// permanently disabling every pad for the session.
|
|
||||||
gate: PadGate,
|
|
||||||
/// Fallback policy for the Steam back grips a client may send (the DS4 has no back-button HID
|
/// Fallback policy for the Steam back grips a client may send (the DS4 has no back-button HID
|
||||||
/// slot). `PUNKTFUNK_STEAM_REMAP=paddles=…`; default drop.
|
/// slot). `PUNKTFUNK_STEAM_REMAP=paddles=…`; default drop.
|
||||||
remap: crate::inject::steam_remap::RemapConfig,
|
remap: crate::inject::steam_remap::RemapConfig,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Default for DualShock4Manager {
|
impl Default for Ds4LinuxProto {
|
||||||
fn default() -> DualShock4Manager {
|
fn default() -> Ds4LinuxProto {
|
||||||
DualShock4Manager::new()
|
Ds4LinuxProto {
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl DualShock4Manager {
|
|
||||||
pub fn new() -> DualShock4Manager {
|
|
||||||
DualShock4Manager {
|
|
||||||
pads: (0..MAX_PADS).map(|_| None).collect(),
|
|
||||||
state: vec![DsState::neutral(); MAX_PADS],
|
|
||||||
last_rumble: vec![(0, 0); MAX_PADS],
|
|
||||||
last_led: vec![None; MAX_PADS],
|
|
||||||
last_write: vec![Instant::now(); MAX_PADS],
|
|
||||||
gate: PadGate::new(),
|
|
||||||
remap: crate::inject::steam_remap::RemapConfig::from_env(),
|
remap: crate::inject::steam_remap::RemapConfig::from_env(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Handle one decoded controller event (create/destroy by mask, then merge button/stick state).
|
impl PadProto for Ds4LinuxProto {
|
||||||
pub fn handle(&mut self, ev: &GamepadEvent) {
|
type Pad = DualShock4Pad;
|
||||||
match ev {
|
type State = DsState;
|
||||||
GamepadEvent::Arrival { index, kind, .. } => {
|
const LABEL: &'static str = "DualShock 4";
|
||||||
tracing::info!(index, kind, "controller arrival (DualShock 4)");
|
const DEVICE: &'static str = "DualShock 4";
|
||||||
self.ensure(*index as usize);
|
const CREATE_HINT: &'static str = "";
|
||||||
}
|
|
||||||
GamepadEvent::State(f) => {
|
fn open(&mut self, idx: u8) -> Result<DualShock4Pad> {
|
||||||
let idx = f.index as usize;
|
let p = DualShock4Pad::open(idx)?;
|
||||||
if idx >= MAX_PADS {
|
tracing::info!(
|
||||||
return;
|
index = idx,
|
||||||
}
|
"virtual DualShock 4 created (UHID hid-playstation)"
|
||||||
// Unplugs: drop any allocated pad whose mask bit cleared, resetting its state.
|
);
|
||||||
for (i, slot) in self.pads.iter_mut().enumerate() {
|
Ok(p)
|
||||||
if slot.is_some() && f.active_mask & (1 << i) == 0 {
|
|
||||||
tracing::info!(index = i, "controller unplugged (DualShock 4)");
|
|
||||||
*slot = None;
|
|
||||||
self.state[i] = DsState::neutral();
|
|
||||||
self.last_rumble[i] = (0, 0);
|
|
||||||
self.last_led[i] = None;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if f.active_mask & (1 << idx) == 0 {
|
|
||||||
return; // this event WAS the unplug
|
|
||||||
}
|
|
||||||
self.ensure(idx);
|
|
||||||
// Merge buttons/sticks/triggers, preserving touch + motion (those arrive on the
|
|
||||||
// rich-input plane and must survive a button-only frame).
|
|
||||||
let prev = self.state[idx];
|
|
||||||
// Steam back grips have no DS4 slot — fold them onto standard buttons per the
|
|
||||||
// configured policy (default drop) so they aren't silently lost.
|
|
||||||
let buttons =
|
|
||||||
crate::inject::steam_remap::fold_paddles(f.buttons, self.remap.paddles);
|
|
||||||
let mut s = DsState::from_gamepad(
|
|
||||||
buttons,
|
|
||||||
f.ls_x,
|
|
||||||
f.ls_y,
|
|
||||||
f.rs_x,
|
|
||||||
f.rs_y,
|
|
||||||
f.left_trigger,
|
|
||||||
f.right_trigger,
|
|
||||||
);
|
|
||||||
s.touch = prev.touch;
|
|
||||||
s.gyro = prev.gyro;
|
|
||||||
s.accel = prev.accel;
|
|
||||||
s.touch_click = prev.touch_click;
|
|
||||||
self.state[idx] = s;
|
|
||||||
self.write(idx);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Apply one rich client→host event (touchpad contact / motion sample) to an existing pad,
|
fn neutral(&self) -> DsState {
|
||||||
/// preserving its button/stick state. Rich events never create a pad; they're dropped if the
|
DsState::neutral()
|
||||||
/// pad isn't present.
|
|
||||||
pub fn apply_rich(&mut self, rich: RichInput) {
|
|
||||||
let idx = match rich {
|
|
||||||
RichInput::Touchpad { pad, .. }
|
|
||||||
| RichInput::Motion { pad, .. }
|
|
||||||
| RichInput::TouchpadEx { pad, .. } => pad as usize,
|
|
||||||
};
|
|
||||||
if idx >= MAX_PADS || self.pads[idx].is_none() {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
// The shared DualSense-family mapping (dualsense_proto::DsState::apply_rich): Steam
|
|
||||||
// dual pads split the one touchpad left/right, pad clicks ride touch_click.
|
|
||||||
self.state[idx].apply_rich(rich, DS4_TOUCH_W, DS4_TOUCH_H);
|
|
||||||
self.write(idx);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn write(&mut self, idx: usize) {
|
/// Merge buttons/sticks/triggers from the frame, preserving touch + motion + pad clicks (those
|
||||||
let st = self.state[idx];
|
/// arrive on the rich-input plane and must survive a button-only frame).
|
||||||
if let Some(pad) = self.pads[idx].as_mut() {
|
fn merge_frame(&self, prev: &DsState, f: &crate::gamestream::gamepad::GamepadFrame) -> DsState {
|
||||||
let _ = pad.write_state(&st);
|
// Steam back grips have no DS4 slot — fold them onto standard buttons per the configured
|
||||||
}
|
// policy (default drop) so they aren't silently lost.
|
||||||
self.last_write[idx] = Instant::now();
|
let buttons = crate::inject::steam_remap::fold_paddles(f.buttons, self.remap.paddles);
|
||||||
|
let mut s = DsState::from_gamepad(
|
||||||
|
buttons,
|
||||||
|
f.ls_x,
|
||||||
|
f.ls_y,
|
||||||
|
f.rs_x,
|
||||||
|
f.rs_y,
|
||||||
|
f.left_trigger,
|
||||||
|
f.right_trigger,
|
||||||
|
);
|
||||||
|
s.touch = prev.touch;
|
||||||
|
s.gyro = prev.gyro;
|
||||||
|
s.accel = prev.accel;
|
||||||
|
s.touch_click = prev.touch_click;
|
||||||
|
s
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Re-emit each live pad's CURRENT report if it's been silent for `max_gap` — a real DS4 streams
|
/// The shared DualSense-family mapping (dualsense_proto::DsState::apply_rich): Steam dual pads
|
||||||
/// report `0x01` continuously, and `hid-playstation` / SDL treat a multi-second silence (a
|
/// split the one touchpad left/right, pad clicks ride touch_click.
|
||||||
/// held-steady stick) as an unplugged controller. Idempotent (a stale-but-correct frame);
|
fn apply_rich(&self, st: &mut DsState, rich: RichInput) {
|
||||||
/// `write_state` bumps the counter + timestamp so each is a fresh, well-formed report.
|
st.apply_rich(rich, DS4_TOUCH_W, DS4_TOUCH_H);
|
||||||
pub fn heartbeat(&mut self, max_gap: Duration) {
|
|
||||||
let now = Instant::now();
|
|
||||||
for i in 0..self.pads.len() {
|
|
||||||
if self.pads[i].is_some() && now.duration_since(self.last_write[i]) >= max_gap {
|
|
||||||
self.write(i);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn ensure(&mut self, idx: usize) {
|
fn write_state(&self, pad: &mut DualShock4Pad, st: &DsState) {
|
||||||
if idx >= MAX_PADS || self.pads[idx].is_some() || !self.gate.allow(Instant::now()) {
|
let _ = pad.write_state(st);
|
||||||
return;
|
|
||||||
}
|
|
||||||
match DualShock4Pad::open(idx as u8) {
|
|
||||||
Ok(p) => {
|
|
||||||
tracing::info!(
|
|
||||||
index = idx,
|
|
||||||
"virtual DualShock 4 created (UHID hid-playstation)"
|
|
||||||
);
|
|
||||||
self.pads[idx] = Some(p);
|
|
||||||
self.state[idx] = DsState::neutral();
|
|
||||||
self.last_rumble[idx] = (0, 0);
|
|
||||||
self.last_led[idx] = None;
|
|
||||||
self.last_write[idx] = Instant::now();
|
|
||||||
self.gate.on_success();
|
|
||||||
}
|
|
||||||
Err(e) => {
|
|
||||||
tracing::error!(error = %format!("{e:#}"), "virtual DualShock 4 creation failed — retrying with backoff");
|
|
||||||
self.gate.on_failure(Instant::now());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Service every pad: answer the kernel's init handshake and parse a game's feedback. `rumble`
|
/// Answer the kernel's init handshake (it blocks `hid-playstation` init until its GET_REPORTs
|
||||||
/// is invoked `(index, low, high)` only when the motor level *changes* (universal 0xCA plane);
|
/// are answered — call frequently) and parse a game's feedback: motor rumble on the universal
|
||||||
/// `hidout` carries the lightbar (0xCD `Led`), deduped. Call frequently — the kernel blocks
|
/// 0xCA plane, the lightbar as a 0xCD `Led` event (a DS4 has no player LEDs / adaptive
|
||||||
/// `hid-playstation` init until its GET_REPORTs are answered.
|
/// triggers).
|
||||||
pub fn pump(
|
fn service(&self, pad: &mut DualShock4Pad, idx: u8) -> PadFeedback {
|
||||||
&mut self,
|
let fb = pad.service();
|
||||||
mut rumble: impl FnMut(u16, u16, u16),
|
PadFeedback {
|
||||||
mut hidout: impl FnMut(HidOutput),
|
rumble: fb.rumble,
|
||||||
) {
|
hidout: fb
|
||||||
for i in 0..self.pads.len() {
|
.led
|
||||||
let Some(pad) = self.pads[i].as_mut() else {
|
.map(|(r, g, b)| HidOutput::Led { pad: idx, r, g, b })
|
||||||
continue;
|
.into_iter()
|
||||||
};
|
.collect(),
|
||||||
let fb = pad.service();
|
|
||||||
if let Some(r) = fb.rumble {
|
|
||||||
if self.last_rumble[i] != r {
|
|
||||||
self.last_rumble[i] = r;
|
|
||||||
rumble(i as u16, r.0, r.1);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if let Some(rgb) = fb.led {
|
|
||||||
if self.last_led[i] != Some(rgb) {
|
|
||||||
self.last_led[i] = Some(rgb);
|
|
||||||
hidout(HidOutput::Led {
|
|
||||||
pad: i as u8,
|
|
||||||
r: rgb.0,
|
|
||||||
g: rgb.1,
|
|
||||||
b: rgb.2,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// All virtual DualShock 4 pads of a session — the PS4 analog of
|
||||||
|
/// [`DualSenseManager`](super::dualsense::DualSenseManager), selected with `PUNKTFUNK_GAMEPAD=ps4`.
|
||||||
|
/// Like the DualSense, the shared [`UhidManager`] keeps each pad's full [`DsState`], re-emits the
|
||||||
|
/// merged report whenever buttons/sticks or touchpad/motion change, and heartbeats it through
|
||||||
|
/// input silence (a real DS4 streams report `0x01` continuously — `hid-playstation`/SDL treat a
|
||||||
|
/// multi-second gap as an unplug).
|
||||||
|
pub type DualShock4Manager = UhidManager<Ds4LinuxProto>;
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|||||||
Reference in New Issue
Block a user