refactor(host/W6.2): extract the input-injection backends into the pf-inject crate
inject.rs + inject/* (the per-OS injectors — wlroots virtual-input, KWin
fake_input, libei/reis, gamescope-EI on Linux; SendInput on Windows — plus the
virtual-gamepad HID stack: DualSense/DualShock4/Switch Pro/Steam Controller/Deck
over uhid/usbip and the Windows UMDF drivers, the proto codecs, the injector
service, and the uhid manager) move into crates/pf-inject behind the
InputInjector trait (plan §W6). It consumes punktfunk_core::input (the neutral
GamepadEvent/InputEvent vocabulary, moved to core in W5) + the pf-driver-proto
wire contract, and reaches pf-capture only for the Windows gamepad-channel
WUDFHost check + the resident-mouse compose-kick hook.
The one inject->vdisplay coupling (the libei gamescope-EI backend needs the EIS
relay socket path) is broken via a leaf: gamescope_ei_socket_file moves to
pf-paths as the shared contract — the gamescope producer (host vdisplay) keeps
its session-env-lock wrapper around it, the libei consumer (pf-inject) reads it
directly post-retarget. The host keeps a `mod inject { pub use pf_inject::* }`
shim so every crate::inject::* path (the native/gamestream input planes + devtest)
is unchanged; the heavy input deps (wayland/reis/xkbcommon/usbip + the KWin
fake-input protocol XML) moved with the crate.
Verified: Linux clippy -D warnings (pf-inject + host nvenc,vulkan-encode,pyrowave
--all-targets) + pf-inject 69/69 + host 230/230 tests; Windows clippy -D warnings
(pf-inject --all-targets + host nvenc,amf-qsv --all-targets) Finished exit 0.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,90 @@
|
||||
//! Virtual Sony DualSense **Edge** on Windows via the UMDF minidriver — the Edge sibling of
|
||||
//! [`super::dualsense_windows`]. Same transport ([`DsWinPad`]: a per-session `SwDeviceCreate`
|
||||
//! devnode + the sealed shared-memory channel), same report codec ([`super::dualsense_proto`]);
|
||||
//! the host stamps `device_type = 2` so the one UMDF driver serves the Edge descriptor /
|
||||
//! `VID_054C&PID_0DF2` attributes, and the wire back-grip bits map onto the Edge's native
|
||||
//! `buttons[2]` slots instead of the fold/drop policy — a client's Deck grips / Elite paddles
|
||||
//! reach games as real buttons. Feedback is identical to the plain DualSense (rumble arrives with
|
||||
//! the vibration-v2 flag, which [`parse_ds_output`](super::dualsense_proto::parse_ds_output)
|
||||
//! already handles).
|
||||
|
||||
use super::dualsense_proto::{edge_paddle_bits, DsState, DS_TOUCH_H, DS_TOUCH_W};
|
||||
use super::dualsense_windows::{DsWinPad, WinDsIdentity};
|
||||
use crate::uhid_manager::{PadFeedback, PadProto, UhidManager};
|
||||
use anyhow::Result;
|
||||
use punktfunk_core::quic::RichInput;
|
||||
|
||||
/// The Windows-Edge half of the shared stateful manager (see [`PadProto`]): the shared
|
||||
/// [`DsWinPad`] transport under the Edge identity, with the Edge paddle mapping in `merge_frame`.
|
||||
/// No remap config — every wire paddle has a native slot.
|
||||
#[derive(Default)]
|
||||
pub struct DsEdgeWinProto;
|
||||
|
||||
impl PadProto for DsEdgeWinProto {
|
||||
type Pad = DsWinPad;
|
||||
type State = DsState;
|
||||
const LABEL: &'static str = "DualSense Edge/Windows";
|
||||
const DEVICE: &'static str = "DualSense Edge";
|
||||
const CREATE_HINT: &'static str =
|
||||
" (install/repair: punktfunk-host.exe driver install --gamepad)";
|
||||
|
||||
fn open(&mut self, idx: u8) -> Result<DsWinPad> {
|
||||
let p = DsWinPad::open(idx, &WinDsIdentity::dualsense_edge())?;
|
||||
tracing::info!(
|
||||
index = idx,
|
||||
"virtual DualSense Edge created (Windows UMDF shm channel)"
|
||||
);
|
||||
Ok(p)
|
||||
}
|
||||
|
||||
fn neutral(&self) -> DsState {
|
||||
DsState::neutral()
|
||||
}
|
||||
|
||||
/// Merge buttons/sticks/triggers from the frame, preserving the rich-plane fields — like the
|
||||
/// plain DualSense, EXCEPT the wire paddles land on the Edge's own `buttons[2]` bits
|
||||
/// (rebuilt from every button frame, so no extra persistence).
|
||||
fn merge_frame(&self, prev: &DsState, f: &punktfunk_core::input::GamepadFrame) -> DsState {
|
||||
let mut s = DsState::from_gamepad(
|
||||
f.buttons,
|
||||
f.ls_x,
|
||||
f.ls_y,
|
||||
f.rs_x,
|
||||
f.rs_y,
|
||||
f.left_trigger,
|
||||
f.right_trigger,
|
||||
);
|
||||
s.buttons[2] |= edge_paddle_bits(f.buttons);
|
||||
s.touch = prev.touch;
|
||||
s.gyro = prev.gyro;
|
||||
s.accel = prev.accel;
|
||||
s.touch_click = prev.touch_click;
|
||||
s
|
||||
}
|
||||
|
||||
/// The shared DualSense-family mapping (dualsense_proto::DsState::apply_rich): Steam dual pads
|
||||
/// split the one touchpad left/right, pad clicks ride touch_click.
|
||||
fn apply_rich(&self, st: &mut DsState, rich: RichInput) {
|
||||
st.apply_rich(rich, DS_TOUCH_W, DS_TOUCH_H);
|
||||
}
|
||||
|
||||
fn write_state(&self, pad: &mut DsWinPad, st: &DsState) {
|
||||
pad.write_state(st);
|
||||
}
|
||||
|
||||
/// Poll the section for a game's feedback: motor rumble on the universal 0xCA plane, the rich
|
||||
/// lightbar/player-LED/trigger events on the 0xCD plane.
|
||||
fn service(&self, pad: &mut DsWinPad, idx: u8) -> PadFeedback {
|
||||
let fb = pad.service(idx);
|
||||
PadFeedback {
|
||||
rumble: fb.rumble,
|
||||
hidout: fb.hidout,
|
||||
game_drove: Some(fb.fresh),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// All virtual DualSense Edge pads of a session — the Windows analogue of
|
||||
/// [`DualSenseEdgeManager`](crate::dualsense::DualSenseEdgeManager), with the same method
|
||||
/// surface (via the shared [`UhidManager`]) as the other Windows pad managers.
|
||||
pub type DualSenseEdgeWindowsManager = UhidManager<DsEdgeWinProto>;
|
||||
@@ -0,0 +1,563 @@
|
||||
//! Virtual Sony DualSense on Windows via the UMDF minidriver (`packaging/windows/drivers/pf-dualsense`).
|
||||
//!
|
||||
//! The Windows analogue of the Linux UHID backend ([`super::dualsense`]): same [`DsState`] model and
|
||||
//! the same byte-level report codec ([`super::dualsense_proto`]), but a different transport. Where
|
||||
//! the Linux backend writes report `0x01` to `/dev/uhid` and reads report `0x02` via `UHID_OUTPUT`,
|
||||
//! the Windows backend talks to the UMDF driver over an **unnamed shared DATA section** (256 B `PadShm`:
|
||||
//! magic `u32@0`, input report `@8`, output seq `u32@72`, output report `@76`) reached over the
|
||||
//! **sealed channel** ([`PadChannel`], `design/gamepad-channel-sealing.md`): the host duplicates the
|
||||
//! section handle into the driver's WUDFHost, bootstrapped via the named `Global\pfds-boot-<idx>`
|
||||
//! mailbox. The driver feeds game `READ_REPORT`s from the input bytes and publishes a game's `0x02`
|
||||
//! (rumble / lightbar / player-LEDs / adaptive triggers) into the output bytes. `hidclass` gates the
|
||||
//! device stack, so this user-mode IPC is the only viable channel (a UMDF driver has no control
|
||||
//! device); see `windows-dualsense-scoping.md`.
|
||||
//!
|
||||
//! Device lifecycle: each pad `SwDeviceCreate`s a `pf_pad_<index>` software devnode (hardware id
|
||||
//! `pf_dualsense`, enumerator `punktfunk`) on open and `SwDeviceClose`s it on drop, so the virtual
|
||||
//! DualSense appears/disappears with the session — matching the Linux UHID pad. (The driver itself
|
||||
//! must already be installed; the installer stages it.)
|
||||
|
||||
use super::dualsense_proto::{
|
||||
parse_ds_output, serialize_state, DsFeedback, DsState, DS_INPUT_REPORT_LEN, DS_TOUCH_H,
|
||||
DS_TOUCH_W,
|
||||
};
|
||||
use super::gamepad_raii::{sw_create_cb, PadChannel, SwCreateCtx};
|
||||
use crate::uhid_manager::{PadFeedback, PadProto, UhidManager};
|
||||
use anyhow::{anyhow, Result};
|
||||
use punktfunk_core::quic::RichInput;
|
||||
use std::ffi::c_void;
|
||||
use std::sync::atomic::{fence, AtomicU32, Ordering};
|
||||
use std::time::Duration;
|
||||
use windows::core::{w, GUID, PCWSTR};
|
||||
use windows::Win32::Devices::Enumeration::Pnp::{
|
||||
SwDeviceClose, SwDeviceCreate, HSWDEVICE, SW_DEVICE_CREATE_INFO,
|
||||
};
|
||||
use windows::Win32::Foundation::{CloseHandle, E_FAIL, WAIT_OBJECT_0};
|
||||
use windows::Win32::System::Threading::{CreateEventW, WaitForSingleObject};
|
||||
|
||||
/// Shared-section layout — the single source of truth is [`pf_driver_proto::gamepad::PadShm`] (offset
|
||||
/// asserts pin every field; the `pf_dualsense` driver maps the same struct). Derive the size/offsets/magic
|
||||
/// from it so a layout change is a compile error, not a hand-synced literal (audit §6.1). `pub(super)` so
|
||||
/// the sibling DualShock 4 backend ([`super::dualshock4_windows`]) reuses the exact offsets.
|
||||
pub(super) const SHM_SIZE: usize = core::mem::size_of::<pf_driver_proto::gamepad::PadShm>();
|
||||
pub(super) const SHM_MAGIC: u32 = pf_driver_proto::gamepad::PAD_MAGIC; // "PFDS"
|
||||
pub(super) const OFF_INPUT: usize = core::mem::offset_of!(pf_driver_proto::gamepad::PadShm, input);
|
||||
pub(super) const OFF_OUT_SEQ: usize =
|
||||
core::mem::offset_of!(pf_driver_proto::gamepad::PadShm, out_seq);
|
||||
pub(super) const OFF_OUTPUT: usize =
|
||||
core::mem::offset_of!(pf_driver_proto::gamepad::PadShm, output);
|
||||
/// Device-type selector the driver reads to choose which HID identity/descriptor it serves: 0 =
|
||||
/// DualSense (the default — the section is zeroed), 1 = DualShock 4.
|
||||
pub(super) const OFF_DEVTYPE: usize =
|
||||
core::mem::offset_of!(pf_driver_proto::gamepad::PadShm, device_type);
|
||||
pub(super) const OFF_DRIVER_PROTO: usize =
|
||||
core::mem::offset_of!(pf_driver_proto::gamepad::PadShm, driver_proto);
|
||||
pub(super) const OFF_PAD_INDEX: usize =
|
||||
core::mem::offset_of!(pf_driver_proto::gamepad::PadShm, pad_index);
|
||||
pub(super) const DEVTYPE_DUALSHOCK4: u8 = pf_driver_proto::gamepad::DEVTYPE_DUALSHOCK4;
|
||||
pub(super) const DEVTYPE_DUALSENSE_EDGE: u8 = pf_driver_proto::gamepad::DEVTYPE_DUALSENSE_EDGE;
|
||||
|
||||
/// A single virtual DualSense: the SwDeviceCreate'd `pf_pad_<index>` software devnode (the driver
|
||||
/// loads on it and the HID DualSense appears to games) plus the sealed shared-memory channel.
|
||||
/// Dropping it removes the devnode (`SwDeviceClose`) and closes both sections.
|
||||
/// `pub`: the type appears as `type Pad` in the `PadProto` impl (a public trait), like the
|
||||
/// Linux pads.
|
||||
pub struct DsWinPad {
|
||||
/// Per-session devnode from SwDeviceCreate, when it succeeds (RAII — `SwDeviceClose` on drop).
|
||||
/// `None` falls back to an out-of-band `pf_dualsense` devnode (installer/devgen).
|
||||
_sw: Option<super::gamepad_raii::SwDevice>,
|
||||
/// The sealed channel: unnamed DATA section (`PadShm`) + bootstrap mailbox + handle delivery.
|
||||
channel: PadChannel,
|
||||
/// Watches the section's `driver_proto` field and logs attach / never-attached diagnosis.
|
||||
attach: super::gamepad_raii::DriverAttach,
|
||||
seq: u8,
|
||||
ts: u32,
|
||||
last_out_seq: u32,
|
||||
}
|
||||
|
||||
/// The PnP identity for a virtual controller devnode — varies by controller type so the same
|
||||
/// [`create_swdevice`] builds a DualSense (`VID_054C&PID_0CE6`) or a DualShock 4
|
||||
/// (`VID_054C&PID_09CC`). The fields map onto the `SW_DEVICE_CREATE_INFO` identity discussed below.
|
||||
pub(super) struct SwDeviceProfile<'a> {
|
||||
/// PnP instance id — distinct namespaces per type (`pf_pad_<idx>` vs `pf_ds4_<idx>`) so the two
|
||||
/// never reuse the same devnode shell.
|
||||
pub instance: &'a str,
|
||||
/// `Data1` of the deterministic ContainerId — a per-device-FAMILY tag (`"PFDS"` for the pads,
|
||||
/// `"PFMO"` for the virtual mouse) so two families at the same index never share a container
|
||||
/// (Windows would group them into one "device" in the Devices UI).
|
||||
pub container_tag: u32,
|
||||
/// Index for the deterministic per-pad ContainerId — ALSO stamped into the devnode Location,
|
||||
/// which the driver reads as its bootstrap-mailbox index.
|
||||
pub container_index: u8,
|
||||
/// The INF-matched hardware id (`pf_dualsense` / `pf_dualshock4`), listed FIRST so the INF binds.
|
||||
pub hwid: &'a str,
|
||||
/// The USB VID&PID token (`VID_054C&PID_0CE6`) used to synthesize the USB hardware/compatible ids.
|
||||
pub usb_vid_pid: &'a str,
|
||||
/// USB composite interface number to synthesize (`&MI_xx` appended to the USB hardware ids).
|
||||
/// hidclass mirrors the parent's `USB\VID…` tokens into the HID child's hardware ids, and
|
||||
/// hidapi/SDL/Steam parse the child's `MI_` token as `bInterfaceNumber` (defaulting to 0 when
|
||||
/// absent) — the Steam Deck's controller lives on interface 2, the gate the N4 spike hit.
|
||||
pub usb_mi: Option<u8>,
|
||||
/// Device description shown in Device Manager.
|
||||
pub description: &'a str,
|
||||
}
|
||||
|
||||
/// Spawn the per-session virtual controller devnode under enumerator `punktfunk` (instance
|
||||
/// `profile.instance`). The returned `HSWDEVICE` owns it — `SwDeviceClose` removes it on drop, so the
|
||||
/// pad appears/disappears with the session and nothing persists.
|
||||
///
|
||||
/// **Game-detection identity** (see `design/windows-dualsense-game-detection.md`). `HIDD_ATTRIBUTES`
|
||||
/// alone (VID/PID via the IOCTL) satisfies SDL/HIDAPI/RawInput, but a native PS5 path (libScePad-
|
||||
/// style raw HID) classifies the *connection type* by walking from the HID child to its parent
|
||||
/// (`CM_Get_Parent`) and string-matching `"USB"`/`"BTHENUM"` in that parent's
|
||||
/// `DEVPKEY_Device_CompatibleIds`; with no bus identity the pad reads as `UNKNOWN` and the native
|
||||
/// path rejects it. So we set, via `SW_DEVICE_CREATE_INFO` (NOT `pProperties` — bus/identity info is
|
||||
/// create-time-only and a `DEVPROPERTY` write of these keys is ignored):
|
||||
/// - `pszzCompatibleIds` starting with a `USB\` token → the parent walk resolves `bus_type = USB`.
|
||||
/// - `pszzHardwareIds` = `pf_dualsense` **first** (so the INF still binds our UMDF driver) followed
|
||||
/// by `USB\VID_054C&PID_0CE6[&REV_0100]`, which makes hidclass derive the real-DualSense child
|
||||
/// hardware ids `HID\VID_054C&PID_0CE6[&REV_0100]` (the set a genuine USB DS5 exposes).
|
||||
/// - a deterministic, non-sentinel per-pad `pContainerId` (groups the pad's devnodes; avoids the
|
||||
/// null-sentinel ContainerId that trips an `xinput1_4` slot-skip bug).
|
||||
///
|
||||
/// (Validated live on `.173`: the INF still binds, the child gains the `HID\VID&PID` ids, and the
|
||||
/// parent walk reports USB. Remaining gap: GameInput parses VID/PID from the child *instance path*
|
||||
/// `HID\punktfunk\…`, which only a real USB-bus instance path — a bus driver — would change.)
|
||||
///
|
||||
/// Two requirements each yield E_INVALIDARG if violated: the enumerator name must not contain `_`
|
||||
/// (hence `punktfunk`, not `pf_dualsense`), and the completion callback is mandatory (the docs mark
|
||||
/// `pCallback` as `[in]`, not optional — a NULL callback is rejected). The caller must be
|
||||
/// Administrator (the host service runs as LocalSystem).
|
||||
pub(super) fn create_swdevice(p: &SwDeviceProfile) -> Result<(HSWDEVICE, Option<String>)> {
|
||||
// Build a double-NUL-terminated UTF-16 multi-sz from a list of ids.
|
||||
let multi_sz = |ids: &[&str]| -> Vec<u16> {
|
||||
ids.iter()
|
||||
.flat_map(|s| s.encode_utf16().chain(std::iter::once(0)))
|
||||
.chain(std::iter::once(0))
|
||||
.collect()
|
||||
};
|
||||
let mi = p.usb_mi.map(|n| format!("&MI_{n:02}")).unwrap_or_default();
|
||||
let usb_rev = format!("USB\\{}&REV_0100{mi}", p.usb_vid_pid);
|
||||
let usb = format!("USB\\{}{mi}", p.usb_vid_pid);
|
||||
let hwids = multi_sz(&[
|
||||
p.hwid, // FIRST → the INF binds our UMDF driver on this id
|
||||
usb_rev.as_str(),
|
||||
usb.as_str(),
|
||||
]);
|
||||
let compat = multi_sz(&[
|
||||
usb.as_str(), // a `USB\` token → native bus-type detection resolves USB
|
||||
"USB\\Class_03&SubClass_00&Prot_00",
|
||||
"USB\\Class_03",
|
||||
]);
|
||||
let instid: Vec<u16> = p
|
||||
.instance
|
||||
.encode_utf16()
|
||||
.chain(std::iter::once(0))
|
||||
.collect();
|
||||
let desc: Vec<u16> = p
|
||||
.description
|
||||
.encode_utf16()
|
||||
.chain(std::iter::once(0))
|
||||
.collect();
|
||||
// The pad index, stamped into the device Location — the driver reads it to poll `pfds-boot-<index>`
|
||||
// (multi-pad). The buffer outlives the SwDeviceCreate call (we wait on the event before return).
|
||||
let loc: Vec<u16> = format!("{}", p.container_index)
|
||||
.encode_utf16()
|
||||
.chain(std::iter::once(0))
|
||||
.collect();
|
||||
// Deterministic ContainerId {<tag>-0000-0000-0000-0000000000<idx>} (tag e.g. "PFDS"/"PFMO").
|
||||
let container = GUID::from_values(
|
||||
p.container_tag,
|
||||
0x0000,
|
||||
0x0000,
|
||||
[0, 0, 0, 0, 0, 0, 0, p.container_index],
|
||||
);
|
||||
|
||||
// SAFETY: zeroed then the fields we use are set; cbSize identifies the struct version. The id
|
||||
// buffers and `container` outlive the SwDeviceCreate call (we wait on the event before return).
|
||||
let mut info: SW_DEVICE_CREATE_INFO = unsafe { std::mem::zeroed() };
|
||||
info.cbSize = std::mem::size_of::<SW_DEVICE_CREATE_INFO>() as u32;
|
||||
info.pszInstanceId = PCWSTR(instid.as_ptr());
|
||||
info.pszzHardwareIds = PCWSTR(hwids.as_ptr());
|
||||
info.pszzCompatibleIds = PCWSTR(compat.as_ptr());
|
||||
info.pContainerId = &container;
|
||||
info.pszDeviceDescription = PCWSTR(desc.as_ptr());
|
||||
info.pszDeviceLocation = PCWSTR(loc.as_ptr());
|
||||
info.CapabilityFlags = 0x0000_000B; // DriverRequired | SilentInstall | Removable
|
||||
|
||||
// SAFETY: a manual-reset, initially-unsignaled, unnamed event.
|
||||
let event = unsafe { CreateEventW(None, true, false, PCWSTR::null())? };
|
||||
// `result` starts as E_FAIL, NOT S_OK: if the wait below times out, a zero-initialised HRESULT
|
||||
// would read as success and mask the failure (found by the 2026-07 driver-health audit).
|
||||
let mut ctx = SwCreateCtx {
|
||||
event,
|
||||
result: E_FAIL,
|
||||
instance_id: [0; 128],
|
||||
};
|
||||
// SAFETY: info + the buffers + ctx outlive the call (we wait on the event before returning);
|
||||
// windows-rs returns the HSWDEVICE (the C out-param) as the Result value.
|
||||
let hsw = match unsafe {
|
||||
SwDeviceCreate(
|
||||
w!("punktfunk"),
|
||||
w!("HTREE\\ROOT\\0"),
|
||||
&info,
|
||||
None,
|
||||
Some(sw_create_cb),
|
||||
Some(&mut ctx as *mut SwCreateCtx as *const c_void),
|
||||
)
|
||||
} {
|
||||
Ok(h) => h,
|
||||
Err(e) => {
|
||||
// SAFETY: event is valid.
|
||||
unsafe {
|
||||
let _ = CloseHandle(event);
|
||||
}
|
||||
return Err(anyhow!("SwDeviceCreate failed: {e}"));
|
||||
}
|
||||
};
|
||||
// Block until PnP finishes enumerating (the callback signals), then check its result.
|
||||
// SAFETY: event is valid.
|
||||
let wait = unsafe { WaitForSingleObject(event, 10_000) };
|
||||
// SAFETY: event is valid.
|
||||
unsafe {
|
||||
let _ = CloseHandle(event);
|
||||
}
|
||||
if wait != WAIT_OBJECT_0 {
|
||||
// SAFETY: hsw is the handle SwDeviceCreate returned.
|
||||
unsafe { SwDeviceClose(hsw) };
|
||||
return Err(anyhow!(
|
||||
"SwDeviceCreate enumeration callback never fired (10s) — PnP may be wedged"
|
||||
));
|
||||
}
|
||||
if ctx.result.is_err() {
|
||||
// SAFETY: hsw is the handle SwDeviceCreate returned.
|
||||
unsafe { SwDeviceClose(hsw) };
|
||||
return Err(anyhow!(
|
||||
"SwDeviceCreate enumeration failed: {:?}",
|
||||
ctx.result
|
||||
));
|
||||
}
|
||||
Ok((hsw, ctx.instance_id()))
|
||||
}
|
||||
|
||||
/// The identity a [`DsWinPad`] enumerates with — the plain DualSense or the Edge share the whole
|
||||
/// transport (section layout, input report shape, output parse); only the `device_type` stamp and
|
||||
/// the PnP identity differ. The DS4 differs in report codec too, so it keeps its own pad type.
|
||||
pub(super) struct WinDsIdentity {
|
||||
/// `device_type` stamped into the section (the driver picks its HID identity off it).
|
||||
pub devtype: u8,
|
||||
/// PnP instance-id prefix (`pf_pad` / `pf_edge`) — distinct namespaces per type.
|
||||
pub instance_prefix: &'static str,
|
||||
/// The INF-matched hardware id.
|
||||
pub hwid: &'static str,
|
||||
/// The USB VID&PID token for the synthesized bus identity.
|
||||
pub usb_vid_pid: &'static str,
|
||||
/// Device Manager description.
|
||||
pub description: &'static str,
|
||||
}
|
||||
|
||||
impl WinDsIdentity {
|
||||
pub(super) const fn dualsense() -> WinDsIdentity {
|
||||
WinDsIdentity {
|
||||
devtype: 0,
|
||||
instance_prefix: "pf_pad",
|
||||
hwid: "pf_dualsense",
|
||||
usb_vid_pid: "VID_054C&PID_0CE6",
|
||||
description: "punktfunk Virtual DualSense",
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) const fn dualsense_edge() -> WinDsIdentity {
|
||||
WinDsIdentity {
|
||||
devtype: DEVTYPE_DUALSENSE_EDGE,
|
||||
instance_prefix: "pf_edge",
|
||||
hwid: "pf_dualsenseedge",
|
||||
usb_vid_pid: "VID_054C&PID_0DF2",
|
||||
description: "punktfunk Virtual DualSense Edge",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl DsWinPad {
|
||||
/// Create the sealed channel (unnamed DATA section + `Global\pfds-boot-<index>` mailbox), stamp
|
||||
/// the device type FIRST (so it's visible the moment magic is) + the pad index + a neutral
|
||||
/// report + the magic LAST, then spawn the devnode (the driver loads on it and receives the
|
||||
/// DATA handle over the bootstrap). The devnode lives for the pad's lifetime — dropping the pad
|
||||
/// removes it (`SwDeviceClose`).
|
||||
pub(super) fn open(index: u8, id: &WinDsIdentity) -> Result<DsWinPad> {
|
||||
let boot_name = pf_driver_proto::gamepad::pad_boot_name(index);
|
||||
let mut channel = PadChannel::create(boot_name.clone(), SHM_SIZE)?;
|
||||
let base = channel.data_base();
|
||||
// SAFETY: base points at SHM_SIZE writable bytes; the OFF_* offsets are in range.
|
||||
unsafe {
|
||||
*base.add(OFF_DEVTYPE) = id.devtype;
|
||||
std::ptr::write_unaligned(base.add(OFF_PAD_INDEX) as *mut u32, index as u32);
|
||||
std::ptr::write_unaligned(base.add(OFF_INPUT) as *mut [u8; DS_INPUT_REPORT_LEN], {
|
||||
let mut r = [0u8; DS_INPUT_REPORT_LEN];
|
||||
serialize_state(&mut r, &DsState::neutral(), 0, 0);
|
||||
r
|
||||
});
|
||||
std::ptr::write_unaligned(base as *mut u32, SHM_MAGIC);
|
||||
}
|
||||
// Spawn the per-session devnode via SwDeviceCreate; `SwDeviceClose` removes it on drop. On the
|
||||
// rare failure we keep the section + data plane and fall back to an out-of-band devnode
|
||||
// (installer / dev-box devgen) — its persistent driver polls the same mailbox name.
|
||||
let inst = format!("{}_{index}", id.instance_prefix);
|
||||
let (hsw, instance_id) = match create_swdevice(&SwDeviceProfile {
|
||||
instance: &inst,
|
||||
container_tag: 0x5046_4453, // "PFDS"
|
||||
container_index: index,
|
||||
hwid: id.hwid,
|
||||
usb_vid_pid: id.usb_vid_pid,
|
||||
usb_mi: None, // single-interface USB devices (real DS/Edge have no MI_ token)
|
||||
description: id.description,
|
||||
}) {
|
||||
Ok((h, i)) => (Some(h), i),
|
||||
Err(e) => {
|
||||
tracing::warn!(error = %format!("{e:#}"), hwid = id.hwid, "SwDeviceCreate failed; falling back to an out-of-band devnode");
|
||||
(None, None)
|
||||
}
|
||||
};
|
||||
let _sw = hsw.map(super::gamepad_raii::SwDevice::new);
|
||||
// Bounded eager delivery so the driver holds the DATA section before hidclass asks it for
|
||||
// descriptors (the driver reads `device_type` from the section to pick its HID identity).
|
||||
channel.deliver_eager(Duration::from_millis(1500));
|
||||
Ok(DsWinPad {
|
||||
_sw,
|
||||
channel,
|
||||
attach: super::gamepad_raii::DriverAttach::new(
|
||||
id.hwid,
|
||||
"pf_dualsense.inf", // one driver package serves every PS identity
|
||||
"C:\\Users\\Public\\pfds-driver.log",
|
||||
boot_name,
|
||||
instance_id,
|
||||
),
|
||||
seq: 0,
|
||||
ts: 0,
|
||||
last_out_seq: 0,
|
||||
})
|
||||
}
|
||||
|
||||
/// Serialize `st` into report `0x01` and publish it to the section's input slot.
|
||||
pub(super) fn write_state(&mut self, st: &DsState) {
|
||||
self.seq = self.seq.wrapping_add(1);
|
||||
self.ts = self.ts.wrapping_add(1);
|
||||
let mut r = [0u8; DS_INPUT_REPORT_LEN];
|
||||
serialize_state(&mut r, st, self.seq, self.ts);
|
||||
// SAFETY: base points at SHM_SIZE bytes; input slot is OFF_INPUT..OFF_INPUT+64. Unlike the
|
||||
// XUSB `packet` / DualSense `out_seq` fields, the input path has NO driver-polled change-detect
|
||||
// field to publish last: the `pf_dualsense` driver streams the whole `input` region to game
|
||||
// READ_REPORTs on its ~125 Hz timer, and the report's own sequence counter (r[7], mid-report)
|
||||
// is consumed by the game's HID stack, not the driver — so it cannot serve as a separable
|
||||
// publish flag without a seqlock generation the driver `Acquire`-reads (a `PadShm` layout +
|
||||
// driver change, deferred). The `Release` fence after the copy orders the report-body stores
|
||||
// ahead of this pad's next `Release` publish (the bootstrap/seq stores in `channel.pump()`),
|
||||
// giving the copy Release visibility on a weakly-ordered core (ARM64); on x86-TSO it is a
|
||||
// no-op. Residual: absent a driver-side `Acquire` on a per-frame input generation, a torn
|
||||
// single frame is still theoretically possible but self-heals on the next ~250 Hz write.
|
||||
unsafe {
|
||||
std::ptr::copy_nonoverlapping(
|
||||
r.as_ptr(),
|
||||
self.channel.data_base().add(OFF_INPUT),
|
||||
r.len(),
|
||||
);
|
||||
fence(Ordering::Release);
|
||||
};
|
||||
}
|
||||
|
||||
/// Poll the section's output slot; parse a new `0x02` report (rumble / LEDs / triggers) into a
|
||||
/// [`DsFeedback`] for pad `pad`. Returns empty feedback if the driver hasn't published anything
|
||||
/// new. Also ticks the sealed-channel delivery and feeds the driver-attach health watcher (the
|
||||
/// driver's ~125 Hz timer stamps `driver_proto` while it has the section mapped).
|
||||
pub(super) fn service(&mut self, pad: u8) -> DsFeedback {
|
||||
self.channel.pump();
|
||||
let mut fb = DsFeedback::default();
|
||||
// SAFETY: base points at SHM_SIZE bytes.
|
||||
let proto = unsafe {
|
||||
std::ptr::read_unaligned(self.channel.data_base().add(OFF_DRIVER_PROTO) as *const u32)
|
||||
};
|
||||
self.attach.observe(proto);
|
||||
// SAFETY: base points at SHM_SIZE bytes; `OFF_OUT_SEQ` (== 72) is 4-aligned off the
|
||||
// page-aligned base, so the `AtomicU32` view is valid. The driver bumps `out_seq` AFTER
|
||||
// writing the `output` report, so an `Acquire` load here orders the `output` copy below after
|
||||
// it — a fresh seq guarantees a coherent snapshot of the output bytes on a weakly-ordered core
|
||||
// (ARM64). On x86-TSO it is a plain load.
|
||||
let seq = unsafe {
|
||||
(*(self.channel.data_base().add(OFF_OUT_SEQ) as *const AtomicU32))
|
||||
.load(Ordering::Acquire)
|
||||
};
|
||||
if seq != self.last_out_seq {
|
||||
self.last_out_seq = seq;
|
||||
fb.fresh = true;
|
||||
let mut out = [0u8; 64];
|
||||
// SAFETY: output slot is OFF_OUTPUT..OFF_OUTPUT+64 within the section.
|
||||
unsafe {
|
||||
std::ptr::copy_nonoverlapping(
|
||||
self.channel.data_base().add(OFF_OUTPUT),
|
||||
out.as_mut_ptr(),
|
||||
64,
|
||||
)
|
||||
};
|
||||
parse_ds_output(pad, &out, &mut fb);
|
||||
}
|
||||
fb
|
||||
}
|
||||
}
|
||||
|
||||
/// The Windows-DualSense half of the shared stateful manager (see [`PadProto`]): the UMDF
|
||||
/// sealed-channel open, the same [`DsState`] mappers as `linux/dualsense.rs`, and the section
|
||||
/// feedback poll. Lifecycle (slot table, unplug sweep, heartbeat, dedup) lives in [`UhidManager`].
|
||||
pub struct DsWinProto {
|
||||
/// Fallback policy for the Steam back grips a client may send (the DualSense has no back-button
|
||||
/// HID slot). `PUNKTFUNK_STEAM_REMAP=paddles=…`; default drop. Parity with `linux/dualsense.rs`.
|
||||
remap: crate::steam_remap::RemapConfig,
|
||||
}
|
||||
|
||||
impl Default for DsWinProto {
|
||||
fn default() -> DsWinProto {
|
||||
DsWinProto {
|
||||
remap: crate::steam_remap::RemapConfig::from_env(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl PadProto for DsWinProto {
|
||||
type Pad = DsWinPad;
|
||||
type State = DsState;
|
||||
const LABEL: &'static str = "DualSense/Windows";
|
||||
const DEVICE: &'static str = "DualSense";
|
||||
const CREATE_HINT: &'static str =
|
||||
" (install/repair: punktfunk-host.exe driver install --gamepad)";
|
||||
|
||||
fn open(&mut self, idx: u8) -> Result<DsWinPad> {
|
||||
let p = DsWinPad::open(idx, &WinDsIdentity::dualsense())?;
|
||||
tracing::info!(
|
||||
index = idx,
|
||||
"virtual DualSense created (Windows UMDF shm channel)"
|
||||
);
|
||||
Ok(p)
|
||||
}
|
||||
|
||||
fn neutral(&self) -> DsState {
|
||||
DsState::neutral()
|
||||
}
|
||||
|
||||
/// Merge buttons/sticks/triggers from the frame, preserving touch + motion + pad clicks (rich-
|
||||
/// plane fields that must survive a button-only frame) — exactly as `linux/dualsense.rs` does.
|
||||
fn merge_frame(&self, prev: &DsState, f: &punktfunk_core::input::GamepadFrame) -> DsState {
|
||||
// Steam back grips have no DualSense slot — fold them onto standard buttons per the
|
||||
// configured policy (default drop) so they aren't silently lost.
|
||||
let buttons = crate::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
|
||||
}
|
||||
|
||||
/// The shared DualSense-family mapping (dualsense_proto::DsState::apply_rich): Steam dual pads
|
||||
/// split the one touchpad left/right, pad clicks ride touch_click.
|
||||
fn apply_rich(&self, st: &mut DsState, rich: RichInput) {
|
||||
st.apply_rich(rich, DS_TOUCH_W, DS_TOUCH_H);
|
||||
}
|
||||
|
||||
fn write_state(&self, pad: &mut DsWinPad, st: &DsState) {
|
||||
pad.write_state(st);
|
||||
}
|
||||
|
||||
/// Poll the section for a game's feedback: motor rumble on the universal 0xCA plane, the rich
|
||||
/// lightbar/player-LED/trigger events on the 0xCD plane.
|
||||
fn service(&self, pad: &mut DsWinPad, idx: u8) -> PadFeedback {
|
||||
let fb = pad.service(idx);
|
||||
PadFeedback {
|
||||
rumble: fb.rumble,
|
||||
hidout: fb.hidout,
|
||||
game_drove: Some(fb.fresh),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// **N4 spike** (gamepad-new-types §6, timeboxed): create a software-devnode HID **Steam Deck**
|
||||
/// (`device_type = 3`, `VID_28DE&PID_1205`) and hold it for `secs`, streaming the neutral Deck
|
||||
/// frame, so the go/no-go question — does Steam Input on Windows promote a software-devnode HID
|
||||
/// Deck, or does it require a real USB bus identity (the documented GameInput instance-path
|
||||
/// gap)? — can be answered by watching Steam's `logs/controller.txt` / controller settings
|
||||
/// while this holds. Never used by a session; wired to the `deck-windows-spike` subcommand.
|
||||
pub fn deck_spike_hold(index: u8, secs: u64) -> Result<()> {
|
||||
let boot_name = pf_driver_proto::gamepad::pad_boot_name(index);
|
||||
let mut channel = PadChannel::create(boot_name, SHM_SIZE)?;
|
||||
let base = channel.data_base();
|
||||
// Neutral Deck input frame: [0x01, 0x00, ID_CONTROLLER_DECK_STATE=0x09, 0x3C], all released.
|
||||
let mut neutral = [0u8; 64];
|
||||
(neutral[0], neutral[2], neutral[3]) = (0x01, 0x09, 0x3C);
|
||||
// SAFETY: base points at SHM_SIZE writable bytes; the OFF_* offsets are in range. Device-type
|
||||
// FIRST, magic LAST — the same publish order the session pads use.
|
||||
unsafe {
|
||||
*base.add(OFF_DEVTYPE) = pf_driver_proto::gamepad::DEVTYPE_STEAMDECK;
|
||||
std::ptr::write_unaligned(base.add(OFF_PAD_INDEX) as *mut u32, index as u32);
|
||||
std::ptr::write_unaligned(base.add(OFF_INPUT) as *mut [u8; 64], neutral);
|
||||
std::ptr::write_unaligned(base as *mut u32, SHM_MAGIC);
|
||||
}
|
||||
let inst = format!("pf_deckspike_{index}");
|
||||
let (hsw, _) = create_swdevice(&SwDeviceProfile {
|
||||
instance: &inst,
|
||||
container_tag: 0x5046_4453, // "PFDS"
|
||||
container_index: index,
|
||||
hwid: "pf_steamdeck",
|
||||
usb_vid_pid: "VID_28DE&PID_1205",
|
||||
// The Deck's controller interface — the promotion gate the first spike run hit
|
||||
// (hidapi parses MI_ from the child hwids; absent = interface 0, Steam wants 2).
|
||||
usb_mi: Some(2),
|
||||
description: "punktfunk Virtual Steam Deck (spike)",
|
||||
})?;
|
||||
let _sw = super::gamepad_raii::SwDevice::new(hsw);
|
||||
channel.deliver_eager(std::time::Duration::from_millis(1500));
|
||||
println!(
|
||||
"virtual Steam Deck devnode up (28DE:1205, device_type 3) — holding {secs}s.\n\
|
||||
Observe: Get-PnpDevice -PresentOnly | findstr 1205; Steam logs\\controller.txt for a\n\
|
||||
detect/promote line; Steam Settings > Controller for a 'Steam Deck' entry.\n\
|
||||
GO = Steam lists/promotes it; NO-GO = it never appears (the Linux `Interface: -1` gap\n\
|
||||
applies verbatim — document and keep the SteamDeck->DualSense Windows fold)."
|
||||
);
|
||||
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(secs);
|
||||
let mut last_out_seq = 0u32;
|
||||
while std::time::Instant::now() < deadline {
|
||||
channel.pump();
|
||||
// Log any feature/output traffic Steam sends — each one is spike evidence.
|
||||
// SAFETY: base points at SHM_SIZE bytes; OFF_OUT_SEQ is in range.
|
||||
let seq =
|
||||
unsafe { std::ptr::read_unaligned(channel.data_base().add(OFF_OUT_SEQ) as *const u32) };
|
||||
if seq != last_out_seq {
|
||||
last_out_seq = seq;
|
||||
let mut out = [0u8; 16];
|
||||
// SAFETY: output slot is OFF_OUTPUT..OFF_OUTPUT+64 within the section.
|
||||
unsafe {
|
||||
std::ptr::copy_nonoverlapping(
|
||||
channel.data_base().add(OFF_OUTPUT),
|
||||
out.as_mut_ptr(),
|
||||
16,
|
||||
)
|
||||
};
|
||||
println!(" output report from a client (Steam?): {out:02x?}");
|
||||
}
|
||||
std::thread::sleep(std::time::Duration::from_millis(50));
|
||||
}
|
||||
println!("deck-windows-spike: done (devnode removed on exit)");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// All virtual DualSense pads of a session — the Windows analogue of
|
||||
/// [`DualSenseManager`](super::dualsense::DualSenseManager). Same method surface (via the shared
|
||||
/// [`UhidManager`]) so the session input thread drives either backend identically. The heartbeat
|
||||
/// keeps the section fresh (the driver's timer streams whatever's in it) — parity with the UHID
|
||||
/// backend's silence heartbeat.
|
||||
pub type DualSenseWindowsManager = UhidManager<DsWinProto>;
|
||||
@@ -0,0 +1,241 @@
|
||||
//! Virtual Sony DualShock 4 on Windows via the UMDF minidriver — the PS4 sibling of
|
||||
//! [`super::dualsense_windows`]. Same transport (a per-session `SwDeviceCreate` devnode + the sealed
|
||||
//! shared-memory channel bootstrapped via `Global\pfds-boot-<idx>`), same controller model
|
||||
//! ([`DsState`]); only the PnP identity (`VID_054C&PID_09CC`, hardware id `pf_dualshock4`) and the
|
||||
//! report codec ([`super::dualshock4_proto`]) differ. The host stamps `device_type = 1` (DualShock 4)
|
||||
//! into the DATA section so the one UMDF driver serves the DS4 descriptor / attributes / features
|
||||
//! instead of the DualSense ones. Feedback is motor rumble (universal 0xCA plane) + the lightbar
|
||||
//! (0xCD `Led`); a DS4 has no adaptive triggers / player LEDs.
|
||||
|
||||
use super::dualsense_proto::DsState;
|
||||
use super::dualsense_windows::{
|
||||
create_swdevice, SwDeviceProfile, DEVTYPE_DUALSHOCK4, OFF_DEVTYPE, OFF_DRIVER_PROTO, OFF_INPUT,
|
||||
OFF_OUTPUT, OFF_OUT_SEQ, OFF_PAD_INDEX, SHM_MAGIC, SHM_SIZE,
|
||||
};
|
||||
use super::dualshock4_proto::{
|
||||
parse_ds4_output, serialize_state, Ds4Feedback, DS4_INPUT_REPORT_LEN, DS4_TOUCH_H, DS4_TOUCH_W,
|
||||
};
|
||||
use super::gamepad_raii::PadChannel;
|
||||
use crate::uhid_manager::{PadFeedback, PadProto, UhidManager};
|
||||
use anyhow::Result;
|
||||
use punktfunk_core::quic::{HidOutput, RichInput};
|
||||
use std::time::Duration;
|
||||
|
||||
/// A single virtual DualShock 4: the `SwDeviceCreate`'d `pf_ds4_<index>` devnode plus the sealed
|
||||
/// shared-memory channel. Dropping it removes the devnode and closes both sections.
|
||||
/// `pub`: the type appears as `type Pad` in the `PadProto` impl (a public trait), like the
|
||||
/// Linux pads.
|
||||
pub struct Ds4WinPad {
|
||||
/// Per-session devnode from SwDeviceCreate, when it succeeds (RAII — `SwDeviceClose` on drop).
|
||||
_sw: Option<super::gamepad_raii::SwDevice>,
|
||||
/// The sealed channel: unnamed DATA section (`PadShm`) + bootstrap mailbox + handle delivery.
|
||||
channel: PadChannel,
|
||||
/// Watches the section's `driver_proto` field and logs attach / never-attached diagnosis.
|
||||
attach: super::gamepad_raii::DriverAttach,
|
||||
counter: u8,
|
||||
ts: u16,
|
||||
last_out_seq: u32,
|
||||
}
|
||||
|
||||
impl Ds4WinPad {
|
||||
/// Create the sealed channel, stamp `device_type = DualShock 4` + the pad index + a neutral
|
||||
/// report + the magic LAST, then spawn the `pf_ds4_<index>` devnode (the driver loads on it and
|
||||
/// receives the DATA handle over the bootstrap).
|
||||
fn open(index: u8) -> Result<Ds4WinPad> {
|
||||
let boot_name = pf_driver_proto::gamepad::pad_boot_name(index);
|
||||
let mut channel = PadChannel::create(boot_name.clone(), SHM_SIZE)?;
|
||||
let base = channel.data_base();
|
||||
// device-type FIRST (so it's visible the moment magic is), pad index, neutral report,
|
||||
// magic LAST.
|
||||
// SAFETY: base points at SHM_SIZE writable bytes; the OFF_* offsets are in range.
|
||||
unsafe {
|
||||
*base.add(OFF_DEVTYPE) = DEVTYPE_DUALSHOCK4;
|
||||
std::ptr::write_unaligned(base.add(OFF_PAD_INDEX) as *mut u32, index as u32);
|
||||
std::ptr::write_unaligned(base.add(OFF_INPUT) as *mut [u8; DS4_INPUT_REPORT_LEN], {
|
||||
let mut r = [0u8; DS4_INPUT_REPORT_LEN];
|
||||
serialize_state(&mut r, &DsState::neutral(), 0, 0);
|
||||
r
|
||||
});
|
||||
std::ptr::write_unaligned(base as *mut u32, SHM_MAGIC);
|
||||
}
|
||||
let inst = format!("pf_ds4_{index}");
|
||||
let (hsw, instance_id) = match create_swdevice(&SwDeviceProfile {
|
||||
instance: &inst,
|
||||
container_tag: 0x5046_4453, // "PFDS"
|
||||
container_index: index,
|
||||
hwid: "pf_dualshock4",
|
||||
usb_vid_pid: "VID_054C&PID_09CC",
|
||||
usb_mi: None,
|
||||
description: "punktfunk Virtual DualShock 4",
|
||||
}) {
|
||||
Ok((h, id)) => (Some(h), id),
|
||||
Err(e) => {
|
||||
tracing::warn!(error = %format!("{e:#}"), "SwDeviceCreate failed; DualShock 4 devnode unavailable");
|
||||
(None, None)
|
||||
}
|
||||
};
|
||||
let _sw = hsw.map(super::gamepad_raii::SwDevice::new);
|
||||
// Bounded eager delivery — for the DS4 this is what closes the identity race: the driver
|
||||
// must read `device_type = 1` from the delivered DATA section before hidclass asks it for
|
||||
// descriptors, or the pad would enumerate with the (default) DualSense identity.
|
||||
channel.deliver_eager(Duration::from_millis(1500));
|
||||
Ok(Ds4WinPad {
|
||||
_sw,
|
||||
channel,
|
||||
attach: super::gamepad_raii::DriverAttach::new(
|
||||
"pf_dualshock4",
|
||||
"pf_dualsense.inf", // one driver package serves both HID identities
|
||||
"C:\\Users\\Public\\pfds-driver.log",
|
||||
boot_name,
|
||||
instance_id,
|
||||
),
|
||||
counter: 0,
|
||||
ts: 0,
|
||||
last_out_seq: 0,
|
||||
})
|
||||
}
|
||||
|
||||
/// Serialize `st` into report `0x01` and publish it to the section's input slot.
|
||||
fn write_state(&mut self, st: &DsState) {
|
||||
self.counter = self.counter.wrapping_add(1);
|
||||
self.ts = self.ts.wrapping_add(188); // ~1ms in the DS4's 5.33µs sensor-clock units
|
||||
let mut r = [0u8; DS4_INPUT_REPORT_LEN];
|
||||
serialize_state(&mut r, st, self.counter, self.ts);
|
||||
// SAFETY: base points at SHM_SIZE bytes; input slot is OFF_INPUT..OFF_INPUT+64.
|
||||
unsafe {
|
||||
std::ptr::copy_nonoverlapping(
|
||||
r.as_ptr(),
|
||||
self.channel.data_base().add(OFF_INPUT),
|
||||
r.len(),
|
||||
)
|
||||
};
|
||||
}
|
||||
|
||||
/// Poll the section's output slot; parse a new `0x05` report (rumble / lightbar) into a
|
||||
/// [`Ds4Feedback`]. Returns empty feedback if the driver hasn't published anything new. Also
|
||||
/// ticks the sealed-channel delivery and feeds the driver-attach health watcher (the driver's
|
||||
/// ~125 Hz timer stamps `driver_proto`).
|
||||
fn service(&mut self) -> Ds4Feedback {
|
||||
self.channel.pump();
|
||||
let mut fb = Ds4Feedback::default();
|
||||
// SAFETY: base points at SHM_SIZE bytes.
|
||||
let proto = unsafe {
|
||||
std::ptr::read_unaligned(self.channel.data_base().add(OFF_DRIVER_PROTO) as *const u32)
|
||||
};
|
||||
self.attach.observe(proto);
|
||||
// SAFETY: base points at SHM_SIZE bytes.
|
||||
let seq = unsafe {
|
||||
std::ptr::read_unaligned(self.channel.data_base().add(OFF_OUT_SEQ) as *const u32)
|
||||
};
|
||||
if seq != self.last_out_seq {
|
||||
self.last_out_seq = seq;
|
||||
fb.fresh = true;
|
||||
let mut out = [0u8; 64];
|
||||
// SAFETY: output slot is OFF_OUTPUT..OFF_OUTPUT+64 within the section.
|
||||
unsafe {
|
||||
std::ptr::copy_nonoverlapping(
|
||||
self.channel.data_base().add(OFF_OUTPUT),
|
||||
out.as_mut_ptr(),
|
||||
64,
|
||||
)
|
||||
};
|
||||
parse_ds4_output(&out, &mut fb);
|
||||
}
|
||||
fb
|
||||
}
|
||||
}
|
||||
|
||||
/// The Windows-DualShock-4 half of the shared stateful manager (see [`PadProto`]): the UMDF
|
||||
/// sealed-channel open (device-type 1), the same [`DsState`] mappers as `linux/dualshock4.rs`, and
|
||||
/// the section feedback poll. Lifecycle (slot table, unplug sweep, heartbeat, dedup) lives in
|
||||
/// [`UhidManager`]; the lightbar dedup that used to be a bespoke `last_led` vec now rides the
|
||||
/// shared `HidoutDedup` (identical semantics — `Led` is compared against the last-forwarded value
|
||||
/// and re-armed on create/unplug).
|
||||
pub struct Ds4WinProto {
|
||||
/// 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. Parity with `linux/dualshock4.rs`.
|
||||
remap: crate::steam_remap::RemapConfig,
|
||||
}
|
||||
|
||||
impl Default for Ds4WinProto {
|
||||
fn default() -> Ds4WinProto {
|
||||
Ds4WinProto {
|
||||
remap: crate::steam_remap::RemapConfig::from_env(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl PadProto for Ds4WinProto {
|
||||
type Pad = Ds4WinPad;
|
||||
type State = DsState;
|
||||
const LABEL: &'static str = "DualShock 4/Windows";
|
||||
const DEVICE: &'static str = "DualShock 4";
|
||||
const CREATE_HINT: &'static str =
|
||||
" (install/repair: punktfunk-host.exe driver install --gamepad)";
|
||||
|
||||
fn open(&mut self, idx: u8) -> Result<Ds4WinPad> {
|
||||
let p = Ds4WinPad::open(idx)?;
|
||||
tracing::info!(
|
||||
index = idx,
|
||||
"virtual DualShock 4 created (Windows UMDF shm channel)"
|
||||
);
|
||||
Ok(p)
|
||||
}
|
||||
|
||||
fn neutral(&self) -> DsState {
|
||||
DsState::neutral()
|
||||
}
|
||||
|
||||
/// Merge buttons/sticks/triggers from the frame, preserving touch + motion + pad clicks (rich-
|
||||
/// plane fields that must survive a button-only frame) — exactly as `linux/dualshock4.rs` does.
|
||||
fn merge_frame(&self, prev: &DsState, f: &punktfunk_core::input::GamepadFrame) -> DsState {
|
||||
// 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::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
|
||||
}
|
||||
|
||||
/// The shared DualSense-family mapping (dualsense_proto::DsState::apply_rich): Steam dual pads
|
||||
/// split the one touchpad left/right, pad clicks ride touch_click.
|
||||
fn apply_rich(&self, st: &mut DsState, rich: RichInput) {
|
||||
st.apply_rich(rich, DS4_TOUCH_W, DS4_TOUCH_H);
|
||||
}
|
||||
|
||||
fn write_state(&self, pad: &mut Ds4WinPad, st: &DsState) {
|
||||
pad.write_state(st);
|
||||
}
|
||||
|
||||
/// Poll the section for a game's feedback: motor rumble on the universal 0xCA plane, the
|
||||
/// lightbar as a 0xCD `Led` event (a DS4 has no player LEDs / adaptive triggers).
|
||||
fn service(&self, pad: &mut Ds4WinPad, idx: u8) -> PadFeedback {
|
||||
let fb = pad.service();
|
||||
PadFeedback {
|
||||
rumble: fb.rumble,
|
||||
hidout: fb
|
||||
.led
|
||||
.map(|(r, g, b)| HidOutput::Led { pad: idx, r, g, b })
|
||||
.into_iter()
|
||||
.collect(),
|
||||
game_drove: Some(fb.fresh),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// All virtual DualShock 4 pads of a session — the Windows analogue of
|
||||
/// [`DualShock4Manager`](super::dualshock4::DualShock4Manager), with the same method surface (via
|
||||
/// the shared [`UhidManager`]) as the Windows DualSense manager so the session input thread drives
|
||||
/// either backend identically.
|
||||
pub type DualShock4WindowsManager = UhidManager<Ds4WinProto>;
|
||||
@@ -0,0 +1,714 @@
|
||||
//! Per-pad Windows resource RAII + the **sealed gamepad channel** broker (DualSense / DualShock 4 /
|
||||
//! XUSB backends).
|
||||
//!
|
||||
//! Each virtual pad owns three OS resources: the **unnamed** DATA section the `pf_dualsense`/`pf_xusb`
|
||||
//! driver works against (`XusbShm`/`PadShm`), the tiny **named** bootstrap mailbox
|
||||
//! (`pf_driver_proto::gamepad::PadBootstrap`) that hands the driver a duplicated handle to it, and the
|
||||
//! `SwDeviceCreate`'d software devnode the driver loads on. [`Shm`] and [`SwDevice`] own the resources
|
||||
//! with RAII; [`PadChannel`] owns the two sections plus the delivery handshake.
|
||||
//!
|
||||
//! **Why the channel is sealed** (`design/gamepad-channel-sealing.md`): the DATA section used to be a
|
||||
//! `Global\pf…-shm-<index>` named section with an SY+LS DACL, which let any *sibling LocalService*
|
||||
//! process open it by name to read the live controller input or inject/forge input and rumble — the
|
||||
//! same name-open vector the frame ring closed (`design/idd-push-security.md`). The DATA section is now
|
||||
//! UNNAMED with a SYSTEM-only DACL and reaches the driver exclusively as a handle this host duplicated
|
||||
//! into its WUDFHost (a duplicated handle carries the source's access, so no LS ACE is needed). The pad
|
||||
//! drivers are UMDF HID minidrivers with **no control device** (hidclass owns the stack), so unlike the
|
||||
//! frame channel there is no IOCTL to deliver the handle or learn the WUDFHost pid — hence the
|
||||
//! late-bound [`PadBootstrap`] mailbox handshake, the one *named* object left. It carries only pids and
|
||||
//! a handle VALUE (meaningless outside the target process), so tampering with it yields at worst a
|
||||
//! gamepad DoS, never a read or an injection; the empirical floor from the frame work holds here too
|
||||
//! (a LocalService token is DACL-denied `OpenProcess` on a UMDF WUDFHost for every access right).
|
||||
|
||||
use anyhow::{anyhow, bail, Context, Result};
|
||||
use pf_driver_proto::gamepad::{PadBootstrap, BOOT_MAGIC, GAMEPAD_PROTO_VERSION};
|
||||
use std::ffi::c_void;
|
||||
use std::os::windows::io::{AsRawHandle, FromRawHandle, OwnedHandle};
|
||||
use std::sync::atomic::{fence, AtomicU32, AtomicU64, Ordering};
|
||||
use std::sync::OnceLock;
|
||||
use std::time::{Duration, Instant};
|
||||
use windows::core::{w, HRESULT, HSTRING, PCWSTR};
|
||||
use windows::Win32::Devices::DeviceAndDriverInstallation::{
|
||||
CM_Get_DevNode_Status, CM_Locate_DevNodeW, CM_DEVNODE_STATUS_FLAGS, CM_LOCATE_DEVNODE_NORMAL,
|
||||
CM_PROB, CR_SUCCESS, DN_DRIVER_LOADED, DN_HAS_PROBLEM, DN_STARTED,
|
||||
};
|
||||
use windows::Win32::Devices::Enumeration::Pnp::{SwDeviceClose, HSWDEVICE};
|
||||
use windows::Win32::Foundation::{
|
||||
DuplicateHandle, GetLastError, LocalFree, SetLastError, DUPLICATE_HANDLE_OPTIONS,
|
||||
ERROR_ALREADY_EXISTS, HANDLE, HLOCAL, INVALID_HANDLE_VALUE, WIN32_ERROR,
|
||||
};
|
||||
use windows::Win32::Security::Authorization::{
|
||||
ConvertStringSecurityDescriptorToSecurityDescriptorW, SDDL_REVISION_1,
|
||||
};
|
||||
use windows::Win32::Security::{PSECURITY_DESCRIPTOR, SECURITY_ATTRIBUTES};
|
||||
use windows::Win32::System::Memory::{
|
||||
CreateFileMappingW, MapViewOfFile, UnmapViewOfFile, FILE_MAP_ALL_ACCESS,
|
||||
MEMORY_MAPPED_VIEW_ADDRESS, PAGE_READWRITE,
|
||||
};
|
||||
use windows::Win32::System::Threading::{
|
||||
GetCurrentProcess, OpenProcess, SetEvent, PROCESS_DUP_HANDLE, PROCESS_QUERY_LIMITED_INFORMATION,
|
||||
};
|
||||
|
||||
/// Least access the pad driver needs on the duplicated DATA section: it only MAPS it read/write, so
|
||||
/// `SECTION_MAP_READ | SECTION_MAP_WRITE` (== the driver's `FILE_MAP_RW`). Granted explicitly in
|
||||
/// [`PadChannel::deliver_to`] instead of `DUPLICATE_SAME_ACCESS` (least privilege for the sealed
|
||||
/// section — the driver's handle then can't take ownership / change security / delete the object).
|
||||
const SECTION_MAP_RW: u32 = 0x0004 | 0x0002;
|
||||
|
||||
/// An anonymous (pagefile-backed) shared section + its mapped read/write view. RAII: drop unmaps the
|
||||
/// view, then the [`OwnedHandle`] closes the section handle (in that order). Created either
|
||||
/// [unnamed](Self::create_unnamed) (the sealed DATA section — reachable only by handle duplication) or
|
||||
/// [named](Self::create_named) (the bootstrap mailbox the driver opens by name).
|
||||
pub(super) struct Shm {
|
||||
/// Owns the section handle (closed on drop). Also the duplication source for the sealed channel —
|
||||
/// see [`Shm::raw_handle`].
|
||||
handle: OwnedHandle,
|
||||
view: MEMORY_MAPPED_VIEW_ADDRESS,
|
||||
}
|
||||
|
||||
/// Owns an SDDL-derived `SECURITY_ATTRIBUTES` **and** the OS-allocated security descriptor its
|
||||
/// `lpSecurityDescriptor` points at (`ConvertStringSecurityDescriptorToSecurityDescriptorW`
|
||||
/// `LocalAlloc`s the descriptor). Drop `LocalFree`s it, so a `SecAttr` must outlive every
|
||||
/// `CreateFileMappingW` that borrows its `sa`: the section copies the security info at create time, so
|
||||
/// freeing after the create returns is safe — hence [`Shm::create_named`] builds one `SecAttr` before
|
||||
/// its squat-retry loop and reuses it across attempts instead of re-allocating (and re-leaking) per
|
||||
/// attempt.
|
||||
struct SecAttr {
|
||||
sa: SECURITY_ATTRIBUTES,
|
||||
psd: PSECURITY_DESCRIPTOR,
|
||||
}
|
||||
|
||||
impl Drop for SecAttr {
|
||||
fn drop(&mut self) {
|
||||
// SAFETY: `psd` is the descriptor `ConvertStringSecurityDescriptorToSecurityDescriptorW`
|
||||
// allocated for us with `LocalAlloc`; release it with the matching `LocalFree`. Every
|
||||
// `CreateFileMappingW` that borrowed `self.sa` has already returned (so has copied the
|
||||
// security info into its section object), so no live `SECURITY_ATTRIBUTES` still points here.
|
||||
unsafe {
|
||||
let _ = LocalFree(Some(HLOCAL(self.psd.0)));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Build a [`SecAttr`] from an SDDL literal — a `SECURITY_ATTRIBUTES` plus the descriptor it borrows,
|
||||
/// freed together on drop. The returned owner must outlive every `CreateFileMappingW` that borrows
|
||||
/// its `sa` (see [`SecAttr`]).
|
||||
fn sddl_sa(sddl: PCWSTR) -> Result<SecAttr> {
|
||||
let mut psd = PSECURITY_DESCRIPTOR::default();
|
||||
// SAFETY: the SDDL literal is valid; `psd` receives a `LocalAlloc`'d descriptor that `SecAttr`'s
|
||||
// `Drop` `LocalFree`s once the section create that borrows it has returned.
|
||||
unsafe {
|
||||
ConvertStringSecurityDescriptorToSecurityDescriptorW(
|
||||
sddl,
|
||||
SDDL_REVISION_1,
|
||||
&mut psd,
|
||||
None,
|
||||
)?;
|
||||
}
|
||||
Ok(SecAttr {
|
||||
sa: SECURITY_ATTRIBUTES {
|
||||
nLength: core::mem::size_of::<SECURITY_ATTRIBUTES>() as u32,
|
||||
lpSecurityDescriptor: psd.0,
|
||||
bInheritHandle: false.into(),
|
||||
},
|
||||
psd,
|
||||
})
|
||||
}
|
||||
|
||||
impl Shm {
|
||||
/// Create + zero an **unnamed** `size`-byte section, mapped read/write — the sealed DATA section.
|
||||
/// SDDL `D:P(A;;GA;;;SY)` (SYSTEM-only, protected): with no name there is nothing to enumerate,
|
||||
/// open, or squat, and the driver reaches it through a duplicated handle, which carries the
|
||||
/// source's access without re-checking the object DACL (the exact property the frame ring
|
||||
/// validated on-glass — `design/idd-push-security.md`).
|
||||
pub(super) fn create_unnamed(size: usize) -> Result<Shm> {
|
||||
let sa = sddl_sa(w!("D:P(A;;GA;;;SY)"))?;
|
||||
// `sa` owns the descriptor and lives to the end of this fn, so it outlives the create.
|
||||
Self::create_inner(&sa.sa, PCWSTR::null(), size)
|
||||
.context("create unnamed gamepad DATA section")
|
||||
}
|
||||
|
||||
/// Create + zero a **named** `size`-byte section, mapped read/write — the bootstrap mailbox. SDDL
|
||||
/// `D:(A;;GA;;;SY)(A;;GA;;;LS)`: SYSTEM (this host) + LocalService (the driver's WUDFHost opens it
|
||||
/// by name). Safe to leave name-openable because it carries nothing exploitable (see the module
|
||||
/// docs). **Squat-checked**: `Global\` names are creatable by any service holding
|
||||
/// `SeCreateGlobalPrivilege` (LocalService has it), so if the name already exists —
|
||||
/// `ERROR_ALREADY_EXISTS`, meaning `CreateFileMappingW` silently *opened* a pre-existing object we
|
||||
/// don't control — we close and retry briefly (our own driver holds the name for microseconds per
|
||||
/// poll tick), then fail loudly rather than run the handshake through an attacker-owned (or
|
||||
/// another host instance's) mailbox.
|
||||
pub(super) fn create_named(name: &HSTRING, size: usize) -> Result<Shm> {
|
||||
// Build the descriptor ONCE and reuse it across the squat-retry loop — it (and the OS
|
||||
// allocation it owns) lives to the end of this fn, so it outlives every create below.
|
||||
// `D:P` (protected) — strip any inherited ACEs so only SYSTEM + LocalService are granted,
|
||||
// matching the intent of the other named objects (security-review 2026-07-17).
|
||||
let sa = sddl_sa(w!("D:P(A;;GA;;;SY)(A;;GA;;;LS)"))?;
|
||||
for attempt in 0..5 {
|
||||
if attempt > 0 {
|
||||
std::thread::sleep(Duration::from_millis(50));
|
||||
}
|
||||
// SAFETY: clearing the thread error slot so ERROR_ALREADY_EXISTS below is unambiguous.
|
||||
unsafe { SetLastError(WIN32_ERROR(0)) };
|
||||
let shm = Self::create_inner(&sa.sa, PCWSTR(name.as_ptr()), size)
|
||||
.with_context(|| format!("create gamepad bootstrap mailbox {name}"))?;
|
||||
// SAFETY: read immediately after the create; windows-rs only touches the error slot on
|
||||
// failure, so a success here preserves CreateFileMappingW's ALREADY_EXISTS signal.
|
||||
if unsafe { GetLastError() } != ERROR_ALREADY_EXISTS {
|
||||
return Ok(shm);
|
||||
}
|
||||
// `shm` drops here → unmap + close our handle to the foreign object, then retry.
|
||||
}
|
||||
bail!(
|
||||
"bootstrap mailbox {name} already exists and stayed alive across retries — another \
|
||||
punktfunk-host instance is serving this pad index, or a local service is squatting the \
|
||||
name (gamepad DoS attempt?)"
|
||||
);
|
||||
}
|
||||
|
||||
fn create_inner(sa: &SECURITY_ATTRIBUTES, name: PCWSTR, size: usize) -> Result<Shm> {
|
||||
// SAFETY: an anonymous (pagefile-backed) section of `size` bytes with the caller's SDDL; the
|
||||
// descriptor behind `sa` outlives this call (owned by the caller's `SecAttr`, freed only once
|
||||
// every create that borrows it has returned).
|
||||
let map = unsafe {
|
||||
CreateFileMappingW(
|
||||
INVALID_HANDLE_VALUE,
|
||||
Some(sa),
|
||||
PAGE_READWRITE,
|
||||
0,
|
||||
size as u32,
|
||||
name,
|
||||
)?
|
||||
};
|
||||
// SAFETY: `map` is a fresh section handle we own; take ownership immediately so that the early
|
||||
// return below (and the eventual drop) closes it. `map` (a `Copy` `HANDLE`) stays usable for the
|
||||
// `MapViewOfFile` borrow that follows — `from_raw_handle` only copies the inner pointer.
|
||||
let handle = unsafe { OwnedHandle::from_raw_handle(map.0) };
|
||||
// SAFETY: `map` is a valid section handle; map the whole thing read/write.
|
||||
let view = unsafe { MapViewOfFile(map, FILE_MAP_ALL_ACCESS, 0, 0, size) };
|
||||
if view.Value.is_null() {
|
||||
// `handle` drops here → closes the section. No view to unmap.
|
||||
return Err(anyhow!("MapViewOfFile failed"));
|
||||
}
|
||||
// SAFETY: `view` points at `size` writable bytes (just mapped).
|
||||
unsafe { core::ptr::write_bytes(view.Value as *mut u8, 0, size) };
|
||||
Ok(Shm { handle, view })
|
||||
}
|
||||
|
||||
/// The mapped section's base pointer. Stable for the `Shm`'s lifetime (moving the `Shm` does not
|
||||
/// relocate the OS mapping — the view address is fixed by `MapViewOfFile`).
|
||||
pub(super) fn base(&self) -> *mut u8 {
|
||||
self.view.Value as *mut u8
|
||||
}
|
||||
|
||||
/// The section handle as a borrowed `HANDLE` (the sealed channel's duplication source).
|
||||
fn raw_handle(&self) -> HANDLE {
|
||||
HANDLE(self.handle.as_raw_handle())
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for Shm {
|
||||
fn drop(&mut self) {
|
||||
// SAFETY: `view` came from `MapViewOfFile`; unmap it BEFORE the `handle` field closes the
|
||||
// section (struct fields drop only after this `Drop::drop` returns).
|
||||
unsafe {
|
||||
let _ = UnmapViewOfFile(self.view);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── The sealed-channel bootstrap broker ─────────────────────────────────────────────────────────
|
||||
|
||||
/// Global delivery sequence for [`PadBootstrap::handle_seq`] — host-wide monotonic and never 0, so two
|
||||
/// consecutive pads on the same index can't hand the (persistent, out-of-band-devnode) driver the same
|
||||
/// seq twice. Starts at 1.
|
||||
static BOOT_SEQ: AtomicU32 = AtomicU32::new(1);
|
||||
|
||||
/// Hard cap on delivery attempts per pad: each attempt duplicates a handle into a WUDFHost, so a
|
||||
/// tampered mailbox flapping `driver_pid` must not mint unbounded remote handles (DoS containment).
|
||||
/// A legitimate pad needs exactly one (a driver restart within one pad lifetime is not a thing —
|
||||
/// the WUDFHost dies with the devnode).
|
||||
const MAX_DELIVERY_ATTEMPTS: u32 = 16;
|
||||
|
||||
/// One pad's sealed host↔driver channel: the unnamed DATA section (the real `XusbShm`/`PadShm`), the
|
||||
/// named bootstrap mailbox, and the delivery state machine ([`Self::pump`]) that hands the driver's
|
||||
/// WUDFHost a duplicated DATA handle once it publishes its pid. Owns both sections (RAII teardown —
|
||||
/// dropping the channel closes the mailbox, whose *name* then disappears, which is how a persistent
|
||||
/// (out-of-band-devnode) driver detects the host is gone).
|
||||
pub(super) struct PadChannel {
|
||||
data: Shm,
|
||||
boot: Shm,
|
||||
boot_name: String,
|
||||
/// Last `driver_pid` acted on (delivered or rejected) — never retry the same value, so a failed
|
||||
/// verify can't be spun into a hot loop by a static mailbox.
|
||||
last_seen_pid: u32,
|
||||
attempts: u32,
|
||||
delivered: bool,
|
||||
warned_proto: bool,
|
||||
warned_cap: bool,
|
||||
}
|
||||
|
||||
impl PadChannel {
|
||||
/// Create the unnamed DATA section (`data_size` bytes, zeroed — the caller stamps its layout and
|
||||
/// magic) plus the named bootstrap mailbox, stamped `host_proto` first and `BOOT_MAGIC` last so a
|
||||
/// driver only trusts a fully-initialized mailbox.
|
||||
pub(super) fn create(boot_name: String, data_size: usize) -> Result<PadChannel> {
|
||||
let data = Shm::create_unnamed(data_size)?;
|
||||
let boot = Shm::create_named(
|
||||
&HSTRING::from(boot_name.as_str()),
|
||||
core::mem::size_of::<PadBootstrap>(),
|
||||
)?;
|
||||
let base = boot.base();
|
||||
// SAFETY: `base` is the live, page-aligned mailbox view (>= size_of::<PadBootstrap>()); the
|
||||
// field offsets are pinned by the proto's asserts and naturally aligned, so the atomic views
|
||||
// are valid. `host_proto` is published BEFORE `magic` (Release) — a driver that observes the
|
||||
// magic (Acquire) sees the version.
|
||||
unsafe {
|
||||
(*(base.add(core::mem::offset_of!(PadBootstrap, host_proto)) as *const AtomicU32))
|
||||
.store(GAMEPAD_PROTO_VERSION, Ordering::Relaxed);
|
||||
fence(Ordering::Release);
|
||||
(*(base.add(core::mem::offset_of!(PadBootstrap, magic)) as *const AtomicU32))
|
||||
.store(BOOT_MAGIC, Ordering::Release);
|
||||
}
|
||||
Ok(PadChannel {
|
||||
data,
|
||||
boot,
|
||||
boot_name,
|
||||
last_seen_pid: 0,
|
||||
attempts: 0,
|
||||
delivered: false,
|
||||
warned_proto: false,
|
||||
warned_cap: false,
|
||||
})
|
||||
}
|
||||
|
||||
/// The DATA section's mapped base (the host side of `XusbShm`/`PadShm`).
|
||||
pub(super) fn data_base(&self) -> *mut u8 {
|
||||
self.data.base()
|
||||
}
|
||||
|
||||
/// The bootstrap mailbox name (log labelling).
|
||||
pub(super) fn boot_name(&self) -> &str {
|
||||
&self.boot_name
|
||||
}
|
||||
|
||||
/// Atomic `u32` load from a mailbox field.
|
||||
fn boot_load(&self, off: usize) -> u32 {
|
||||
// SAFETY: the mailbox view is live (owned by `self.boot`), page-aligned, and every
|
||||
// `PadBootstrap` u32 field offset is 4-aligned (proto asserts), so the atomic view is valid;
|
||||
// no reference into the shared region outlives the load.
|
||||
unsafe { (*(self.boot.base().add(off) as *const AtomicU32)).load(Ordering::Acquire) }
|
||||
}
|
||||
|
||||
/// One tick of the delivery state machine — called from the pad's regular service pump (≤4 ms
|
||||
/// cadence) and from [`Self::deliver_eager`]. Cheap when idle: two atomic loads.
|
||||
pub(super) fn pump(&mut self) {
|
||||
// Version diagnostics: the driver writes its own proto version even when it refuses to
|
||||
// publish a pid (host/driver mismatch), so the operator sees WHY the pad never attaches.
|
||||
let drv_proto = self.boot_load(core::mem::offset_of!(PadBootstrap, driver_proto));
|
||||
if drv_proto != 0 && drv_proto != GAMEPAD_PROTO_VERSION && !self.warned_proto {
|
||||
self.warned_proto = true;
|
||||
tracing::warn!(
|
||||
mailbox = %self.boot_name,
|
||||
driver_proto = drv_proto,
|
||||
host_proto = GAMEPAD_PROTO_VERSION,
|
||||
"gamepad driver/host protocol mismatch on the bootstrap mailbox — update the \
|
||||
drivers: punktfunk-host.exe driver install --gamepad"
|
||||
);
|
||||
}
|
||||
let pid = self.boot_load(core::mem::offset_of!(PadBootstrap, driver_pid));
|
||||
if pid == 0 || pid == self.last_seen_pid {
|
||||
return;
|
||||
}
|
||||
self.last_seen_pid = pid;
|
||||
if self.attempts >= MAX_DELIVERY_ATTEMPTS {
|
||||
if !self.warned_cap {
|
||||
self.warned_cap = true;
|
||||
tracing::warn!(
|
||||
mailbox = %self.boot_name,
|
||||
attempts = self.attempts,
|
||||
"gamepad channel delivery cap reached — the bootstrap mailbox keeps changing \
|
||||
its driver pid (tampering?); no further handles will be duplicated"
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
self.attempts += 1;
|
||||
match self.deliver_to(pid) {
|
||||
Ok(seq) => {
|
||||
self.delivered = true;
|
||||
tracing::info!(
|
||||
mailbox = %self.boot_name,
|
||||
wudf_pid = pid,
|
||||
seq,
|
||||
"sealed gamepad channel delivered (DATA handle duplicated into the driver's \
|
||||
WUDFHost)"
|
||||
);
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!(
|
||||
mailbox = %self.boot_name,
|
||||
pid,
|
||||
error = %format!("{e:#}"),
|
||||
"sealed gamepad channel delivery failed — will retry when the mailbox reports \
|
||||
a different driver pid"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Duplicate the DATA section into `pid`'s handle table (after verifying it is a genuine
|
||||
/// WUDFHost) and publish the handle value + owning pid, bumping `handle_seq` LAST. The driver
|
||||
/// adopts the handle by consuming the delivery; an unconsumed duplicate dies with the target
|
||||
/// process (nothing to reap — there is no fallible step after the duplication).
|
||||
fn deliver_to(&self, pid: u32) -> Result<u32> {
|
||||
// SAFETY: plain FFI; the handle (checked by `?`) is owned solely here and moved into the
|
||||
// `OwnedHandle` (single owner, closes on drop); `verify_is_wudfhost` borrows it for the
|
||||
// synchronous check and forms no lasting alias.
|
||||
let process = unsafe {
|
||||
let h = OpenProcess(
|
||||
PROCESS_DUP_HANDLE | PROCESS_QUERY_LIMITED_INFORMATION,
|
||||
false,
|
||||
pid,
|
||||
)
|
||||
.context("OpenProcess(PROCESS_DUP_HANDLE) on the mailbox-reported pid")?;
|
||||
let process = OwnedHandle::from_raw_handle(h.0 as _);
|
||||
pf_capture::verify_is_wudfhost(
|
||||
HANDLE(process.as_raw_handle()),
|
||||
pid,
|
||||
"gamepad-channel",
|
||||
)?;
|
||||
process
|
||||
};
|
||||
let mut remote = HANDLE::default();
|
||||
// SAFETY: `self.data.raw_handle()` is the live section handle this channel owns;
|
||||
// `process` is the live PROCESS_DUP_HANDLE target; `&mut remote` is a valid out-param.
|
||||
// Least privilege: the pad driver only MAPS the DATA section read/write (its `FILE_MAP_RW` =
|
||||
// `SECTION_MAP_READ | SECTION_MAP_WRITE`), so grant exactly that instead of copying our
|
||||
// full-access creator handle via `DUPLICATE_SAME_ACCESS` (Chen: don't over-grant unnamed
|
||||
// shared objects — a compromised driver's handle then can't `WRITE_DAC`/`DELETE` the section).
|
||||
unsafe {
|
||||
DuplicateHandle(
|
||||
GetCurrentProcess(),
|
||||
self.data.raw_handle(),
|
||||
HANDLE(process.as_raw_handle()),
|
||||
&mut remote,
|
||||
SECTION_MAP_RW,
|
||||
false,
|
||||
DUPLICATE_HANDLE_OPTIONS(0),
|
||||
)
|
||||
.context("DuplicateHandle(gamepad DATA section) into the driver's WUDFHost")?;
|
||||
}
|
||||
let value = remote.0 as usize as u64;
|
||||
let base = self.boot.base();
|
||||
let seq = BOOT_SEQ.fetch_add(1, Ordering::Relaxed);
|
||||
// SAFETY: live, page-aligned mailbox view; `data_handle` is 8-aligned and `handle_pid`/
|
||||
// `handle_seq` 4-aligned (proto asserts). The handle value + owning pid are published BEFORE
|
||||
// the seq (Release) — a driver that observes the new seq (Acquire) sees a complete delivery.
|
||||
unsafe {
|
||||
(*(base.add(core::mem::offset_of!(PadBootstrap, data_handle)) as *const AtomicU64))
|
||||
.store(value, Ordering::Relaxed);
|
||||
(*(base.add(core::mem::offset_of!(PadBootstrap, handle_pid)) as *const AtomicU32))
|
||||
.store(pid, Ordering::Relaxed);
|
||||
fence(Ordering::Release);
|
||||
(*(base.add(core::mem::offset_of!(PadBootstrap, handle_seq)) as *const AtomicU32))
|
||||
.store(seq, Ordering::Release);
|
||||
}
|
||||
Ok(seq)
|
||||
}
|
||||
|
||||
/// Bounded wait at pad-open: pump until the mailbox produces a driver pid we act on (delivered or
|
||||
/// rejected) or `timeout` passes. Closes the identity race for the DualShock 4 (the driver reads
|
||||
/// `device_type` from the DATA section when hidclass asks for descriptors — the channel should be
|
||||
/// attached by then); the regular service pump takes over afterwards either way.
|
||||
pub(super) fn deliver_eager(&mut self, timeout: Duration) {
|
||||
let deadline = Instant::now() + timeout;
|
||||
loop {
|
||||
self.pump();
|
||||
if self.last_seen_pid != 0 || Instant::now() >= deadline {
|
||||
if !self.delivered {
|
||||
tracing::debug!(
|
||||
mailbox = %self.boot_name,
|
||||
"eager gamepad-channel delivery window passed without an attach — the \
|
||||
service pump keeps polling (driver-attach diagnosis follows if it stays \
|
||||
silent)"
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
std::thread::sleep(Duration::from_millis(10));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Context for the `SwDeviceCreate` completion callback: an event to signal, the HRESULT it reports,
|
||||
/// and the PnP instance id PnP assigned (captured for devnode health diagnostics). Shared by every
|
||||
/// Windows companion backend (XUSB / DualSense / DS4): each `create_swdevice` builds one, hands it to
|
||||
/// `SwDeviceCreate` alongside [`sw_create_cb`], and reads [`instance_id`](Self::instance_id) once the
|
||||
/// callback has signalled.
|
||||
#[repr(C)]
|
||||
pub(super) struct SwCreateCtx {
|
||||
pub(super) event: HANDLE,
|
||||
pub(super) result: HRESULT,
|
||||
pub(super) instance_id: [u16; 128],
|
||||
}
|
||||
|
||||
/// `SwDeviceCreate` fires this once PnP has enumerated the device; stash the result and wake the
|
||||
/// creator, which blocks on the event (so there's no concurrent access to `*ctx`).
|
||||
pub(super) unsafe extern "system" fn sw_create_cb(
|
||||
_dev: HSWDEVICE,
|
||||
result: HRESULT,
|
||||
ctx: *const c_void,
|
||||
id: PCWSTR,
|
||||
) {
|
||||
if !ctx.is_null() {
|
||||
// SAFETY: ctx is the &mut SwCreateCtx the creator passed; it outlives this callback (the
|
||||
// creator blocks on the event). `id` is a NUL-terminated string for the callback's duration.
|
||||
unsafe {
|
||||
let c = ctx as *mut SwCreateCtx;
|
||||
(*c).result = result;
|
||||
if !id.is_null() {
|
||||
for i in 0..(*c).instance_id.len() - 1 {
|
||||
let ch = *id.0.add(i);
|
||||
(*c).instance_id[i] = ch;
|
||||
if ch == 0 {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
let _ = SetEvent((*c).event);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl SwCreateCtx {
|
||||
pub(super) fn instance_id(&self) -> Option<String> {
|
||||
let len = self.instance_id.iter().position(|&c| c == 0)?;
|
||||
(len > 0).then(|| String::from_utf16_lossy(&self.instance_id[..len]))
|
||||
}
|
||||
}
|
||||
|
||||
/// A `SwDeviceCreate`'d software devnode; drop removes it via `SwDeviceClose`. Replaces the manual
|
||||
/// `SwDeviceClose` each backend used to call in its `Drop`.
|
||||
pub(super) struct SwDevice(HSWDEVICE);
|
||||
|
||||
impl SwDevice {
|
||||
pub(super) fn new(hsw: HSWDEVICE) -> Self {
|
||||
SwDevice(hsw)
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for SwDevice {
|
||||
fn drop(&mut self) {
|
||||
// SAFETY: `self.0` is the handle `SwDeviceCreate` returned; `SwDeviceClose` removes the devnode.
|
||||
unsafe { SwDeviceClose(self.0) };
|
||||
}
|
||||
}
|
||||
|
||||
// ── Driver health surfacing ─────────────────────────────────────────────────────────────────────
|
||||
//
|
||||
// The gamepad drivers have no IOCTL plane (hidclass gates the stack), so the only cross-process
|
||||
// signal is the shared section itself. The drivers stamp `driver_proto` into their section once
|
||||
// attached (pf_driver_proto::gamepad::GAMEPAD_PROTO_VERSION); [`DriverAttach`] watches that field
|
||||
// from the host's regular pump and turns silence into actionable WARN/ERROR log lines — the piece
|
||||
// that used to be missing entirely: a pad could be "created" with no driver installed and nothing
|
||||
// was ever logged until the user gave up ("host doesn't see my controller" bug reports).
|
||||
|
||||
/// How long to give PnP to bind the driver + the driver to stamp the section before warning.
|
||||
const ATTACH_GRACE: Duration = Duration::from_secs(3);
|
||||
|
||||
/// Per-pad driver-attach watcher: feed it the section's `driver_proto` on every service tick; it
|
||||
/// logs the attach (INFO), a version mismatch (WARN), or — after [`ATTACH_GRACE`] of silence — one
|
||||
/// diagnosis WARN combining the driver-store check and the devnode problem code. States never
|
||||
/// repeat their log line, so the pump can call this at full rate.
|
||||
pub(super) struct DriverAttach {
|
||||
/// Driver label for log lines (`pf_xusb` / `pf_dualsense` / `pf_dualshock4`).
|
||||
driver: &'static str,
|
||||
/// The INF the driver store must hold for this driver (`pf_xusb.inf` / `pf_dualsense.inf`).
|
||||
inf: &'static str,
|
||||
/// The driver's own debug log, referenced in the diagnosis line.
|
||||
driver_log: &'static str,
|
||||
/// Bootstrap-mailbox name, for log lines (the DATA section is unnamed).
|
||||
shm_name: String,
|
||||
/// PnP instance id of the SwDeviceCreate'd devnode (`None` on the out-of-band fallback path).
|
||||
instance_id: Option<String>,
|
||||
created: Instant,
|
||||
state: AttachState,
|
||||
}
|
||||
|
||||
enum AttachState {
|
||||
Waiting,
|
||||
/// Diagnosis logged; still watching so a late attach gets its INFO line.
|
||||
Warned,
|
||||
Attached,
|
||||
}
|
||||
|
||||
impl DriverAttach {
|
||||
pub(super) fn new(
|
||||
driver: &'static str,
|
||||
inf: &'static str,
|
||||
driver_log: &'static str,
|
||||
shm_name: String,
|
||||
instance_id: Option<String>,
|
||||
) -> DriverAttach {
|
||||
DriverAttach {
|
||||
driver,
|
||||
inf,
|
||||
driver_log,
|
||||
shm_name,
|
||||
instance_id,
|
||||
created: Instant::now(),
|
||||
state: AttachState::Waiting,
|
||||
}
|
||||
}
|
||||
|
||||
/// `driver_proto` is the section field the driver stamps once attached (0 = not attached).
|
||||
pub(super) fn observe(&mut self, driver_proto: u32) {
|
||||
match self.state {
|
||||
AttachState::Attached => {}
|
||||
AttachState::Waiting | AttachState::Warned if driver_proto != 0 => {
|
||||
let late = matches!(self.state, AttachState::Warned);
|
||||
tracing::info!(
|
||||
driver = self.driver,
|
||||
shm = %self.shm_name,
|
||||
proto = driver_proto,
|
||||
late,
|
||||
"gamepad driver attached to the shared section"
|
||||
);
|
||||
if driver_proto != pf_driver_proto::gamepad::GAMEPAD_PROTO_VERSION {
|
||||
tracing::warn!(
|
||||
driver = self.driver,
|
||||
driver_proto,
|
||||
host_proto = pf_driver_proto::gamepad::GAMEPAD_PROTO_VERSION,
|
||||
"gamepad driver/host protocol mismatch — update the drivers: punktfunk-host.exe driver install --gamepad"
|
||||
);
|
||||
}
|
||||
self.state = AttachState::Attached;
|
||||
}
|
||||
AttachState::Waiting if self.created.elapsed() >= ATTACH_GRACE => {
|
||||
self.diagnose();
|
||||
self.state = AttachState::Warned;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
/// One-shot WARN with everything the host can find out about WHY the driver isn't attached:
|
||||
/// driver-store presence, the devnode's PnP status/problem code, and where to look next.
|
||||
fn diagnose(&self) {
|
||||
let store = match driver_store_has(self.inf) {
|
||||
Some(true) => "driver package present in the driver store",
|
||||
Some(false) => {
|
||||
"driver package NOT in the driver store — run: punktfunk-host.exe driver install --gamepad"
|
||||
}
|
||||
None => "driver store could not be queried (pnputil failed)",
|
||||
};
|
||||
let devnode = match &self.instance_id {
|
||||
Some(id) => devnode_status_line(id),
|
||||
None => {
|
||||
"no per-session devnode (SwDeviceCreate failed earlier — see the warning above)"
|
||||
.to_string()
|
||||
}
|
||||
};
|
||||
tracing::warn!(
|
||||
driver = self.driver,
|
||||
shm = %self.shm_name,
|
||||
grace_secs = ATTACH_GRACE.as_secs(),
|
||||
store,
|
||||
devnode = %devnode,
|
||||
driver_log = self.driver_log,
|
||||
"gamepad driver has not attached to the shared section — the virtual pad exists but no \
|
||||
driver is serving it (games will not see it); an old (pre-sealed-channel) driver also \
|
||||
reads as not-attached: update with punktfunk-host.exe driver install --gamepad \
|
||||
(driver_log is only written by debug driver builds, or with the PFXUSB_DEBUG_LOG / \
|
||||
PFDS_DEBUG_LOG system env var set + the device restarted)"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Driver-store inventory (`pnputil /enum-drivers`), lower-cased, fetched once per process — only
|
||||
/// consulted on the failure path, so the one-off subprocess cost never hits a healthy session.
|
||||
fn driver_store_inventory() -> &'static str {
|
||||
static INV: OnceLock<String> = OnceLock::new();
|
||||
INV.get_or_init(|| {
|
||||
// Resolve pnputil by full System32 path — the host runs as SYSTEM and must not trust PATH /
|
||||
// the CreateProcess search (which checks the launching EXE's own dir first), or a planted
|
||||
// `pnputil.exe` beside the host binary would run elevated (security-review 2026-07-17).
|
||||
let pnputil = std::env::var("SystemRoot")
|
||||
.map(|r| format!(r"{r}\System32\pnputil.exe"))
|
||||
.unwrap_or_else(|_| "pnputil.exe".to_string());
|
||||
std::process::Command::new(&pnputil)
|
||||
.arg("/enum-drivers")
|
||||
.output()
|
||||
.map(|o| String::from_utf8_lossy(&o.stdout).to_ascii_lowercase())
|
||||
.unwrap_or_default()
|
||||
})
|
||||
}
|
||||
|
||||
/// Whether the driver store holds `inf` (e.g. `pf_xusb.inf`). `None` = pnputil unavailable/failed.
|
||||
fn driver_store_has(inf: &str) -> Option<bool> {
|
||||
let inv = driver_store_inventory();
|
||||
if inv.is_empty() {
|
||||
return None;
|
||||
}
|
||||
Some(inv.contains(&inf.to_ascii_lowercase()))
|
||||
}
|
||||
|
||||
/// Human-readable PnP status of a devnode: driver bound/started or the CM problem code with a hint.
|
||||
fn devnode_status_line(instance_id: &str) -> String {
|
||||
let wide: Vec<u16> = instance_id
|
||||
.encode_utf16()
|
||||
.chain(std::iter::once(0))
|
||||
.collect();
|
||||
let mut devinst = 0u32;
|
||||
// SAFETY: `wide` is a valid NUL-terminated UTF-16 instance id; `devinst` receives the handle.
|
||||
let cr = unsafe {
|
||||
CM_Locate_DevNodeW(
|
||||
&mut devinst,
|
||||
PCWSTR(wide.as_ptr()),
|
||||
CM_LOCATE_DEVNODE_NORMAL,
|
||||
)
|
||||
};
|
||||
if cr != CR_SUCCESS {
|
||||
return format!(
|
||||
"devnode {instance_id} not found (CM_Locate_DevNodeW CR={})",
|
||||
cr.0
|
||||
);
|
||||
}
|
||||
let mut status = CM_DEVNODE_STATUS_FLAGS(0);
|
||||
let mut problem = CM_PROB(0);
|
||||
// SAFETY: devinst is the devnode located above; the two out-params receive status + problem.
|
||||
let cr = unsafe { CM_Get_DevNode_Status(&mut status, &mut problem, devinst, 0) };
|
||||
if cr != CR_SUCCESS {
|
||||
return format!("devnode {instance_id}: status query failed (CR={})", cr.0);
|
||||
}
|
||||
if status.0 & DN_HAS_PROBLEM.0 != 0 {
|
||||
return format!(
|
||||
"devnode {instance_id} has PnP problem code {} ({}) [status 0x{:08x}]",
|
||||
problem.0,
|
||||
cm_problem_hint(problem.0),
|
||||
status.0
|
||||
);
|
||||
}
|
||||
format!(
|
||||
"devnode {instance_id} status 0x{:08x} (driver_loaded={} started={})",
|
||||
status.0,
|
||||
status.0 & DN_DRIVER_LOADED.0 != 0,
|
||||
status.0 & DN_STARTED.0 != 0,
|
||||
)
|
||||
}
|
||||
|
||||
/// The CM_PROB_* codes a virtual-pad devnode realistically hits, with the operator-facing cause.
|
||||
fn cm_problem_hint(problem: u32) -> &'static str {
|
||||
match problem {
|
||||
1 => "not configured — no driver bound; install the drivers",
|
||||
10 => "device failed to start — driver bound but its start failed; check the driver log",
|
||||
18 => "reinstall required — re-run driver install",
|
||||
24 => "device not present/working — PnP could not start the virtual devnode",
|
||||
28 => "drivers not installed — the pf driver package is missing from the store or its certificate is not trusted",
|
||||
31 => "driver failed to load — binding found the package but loading it failed",
|
||||
39 => "driver corrupt or missing — reinstall the drivers",
|
||||
43 => "reported failure after start — check the driver log",
|
||||
52 => "driver signature rejected — certificate not in Root/TrustedPublisher, or blocked by Memory Integrity",
|
||||
_ => "see Device Manager for this code",
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,365 @@
|
||||
//! Windows virtual Xbox 360 gamepad via the punktfunk **XUSB companion** UMDF driver
|
||||
//! (`packaging/windows/drivers/pf-xusb`) — the in-tree replacement for ViGEmBus. One virtual Xbox 360
|
||||
//! controller per client pad index, visible to classic **XInput** (`XInputGetState`) with no kernel
|
||||
//! bus driver: each pad `SwDeviceCreate`s a `pf_xusb_<index>` devnode (the driver loads on it and
|
||||
//! registers `GUID_DEVINTERFACE_XUSB`) and the host pushes the XInput state into an **unnamed** shared
|
||||
//! DATA section the driver reaches over the **sealed channel** ([`PadChannel`] — handle duplicated
|
||||
//! into its WUDFHost, bootstrapped via `Global\pfxusb-boot-<index>`; see
|
||||
//! `design/gamepad-channel-sealing.md`). GameStream/Moonlight already speak the XInput conventions
|
||||
//! (low-16 button bits, sticks −32768..32767 +Y up, triggers 0..255), so the state copy is ~1:1.
|
||||
//!
|
||||
//! Rumble flows back the other way: a game writes force-feedback via `XInputSetState`, the driver
|
||||
//! parses the `SET_STATE` packet into the shared section, and [`GamepadManager::pump_rumble`] relays
|
||||
//! level changes to the client (the universal 0xCA plane), mirroring the Linux `EV_FF` read path.
|
||||
|
||||
use super::gamepad_raii::{sw_create_cb, PadChannel, SwCreateCtx};
|
||||
use crate::pad_slots::PadSlots;
|
||||
use anyhow::{anyhow, Result};
|
||||
use punktfunk_core::input::{GamepadEvent, MAX_PADS};
|
||||
use std::ffi::c_void;
|
||||
use std::sync::atomic::{fence, AtomicU32, Ordering};
|
||||
use std::time::{Duration, Instant};
|
||||
use windows::core::{w, GUID, PCWSTR};
|
||||
use windows::Win32::Devices::Enumeration::Pnp::{
|
||||
SwDeviceClose, SwDeviceCreate, HSWDEVICE, SW_DEVICE_CREATE_INFO,
|
||||
};
|
||||
use windows::Win32::Foundation::{CloseHandle, E_FAIL, WAIT_OBJECT_0};
|
||||
use windows::Win32::System::Threading::{CreateEventW, WaitForSingleObject};
|
||||
|
||||
// Shared-section layout — the single source of truth is `pf_driver_proto::gamepad::XusbShm` (offset
|
||||
// asserts pin every field; the `pf_xusb` driver maps the same struct). Derive the size/offsets/magic from
|
||||
// it so a layout change is a compile error, not a hand-synced literal (audit §6.1).
|
||||
use pf_driver_proto::gamepad::XusbShm;
|
||||
const SHM_SIZE: usize = core::mem::size_of::<XusbShm>();
|
||||
const SHM_MAGIC: u32 = pf_driver_proto::gamepad::XUSB_MAGIC; // "PFXU"
|
||||
const OFF_PACKET: usize = core::mem::offset_of!(XusbShm, packet);
|
||||
const OFF_BUTTONS: usize = core::mem::offset_of!(XusbShm, buttons);
|
||||
const OFF_LT: usize = core::mem::offset_of!(XusbShm, left_trigger);
|
||||
const OFF_RT: usize = core::mem::offset_of!(XusbShm, right_trigger);
|
||||
const OFF_LX: usize = core::mem::offset_of!(XusbShm, thumb_lx);
|
||||
const OFF_LY: usize = core::mem::offset_of!(XusbShm, thumb_ly);
|
||||
const OFF_RX: usize = core::mem::offset_of!(XusbShm, thumb_rx);
|
||||
const OFF_RY: usize = core::mem::offset_of!(XusbShm, thumb_ry);
|
||||
const OFF_RUMBLE_SEQ: usize = core::mem::offset_of!(XusbShm, rumble_seq);
|
||||
const OFF_RUMBLE: usize = core::mem::offset_of!(XusbShm, rumble_large); // large @28, small @29
|
||||
const OFF_DRIVER_PROTO: usize = core::mem::offset_of!(XusbShm, driver_proto);
|
||||
const OFF_PAD_INDEX: usize = core::mem::offset_of!(XusbShm, pad_index);
|
||||
|
||||
/// Spawn the `pf_xusb_<index>` companion devnode (hardware id `pf_xusb`, enumerator `punktfunk`). The
|
||||
/// INF (System class) binds our UMDF driver, which registers the XUSB interface. Unlike the HID pads,
|
||||
/// no USB compatible-ids are needed — XInput finds the device by the interface GUID, not VID/PID — but
|
||||
/// we still pass a deterministic non-null `pContainerId` (the null-sentinel trips an `xinput1_4`
|
||||
/// slot-skip bug). `SwDeviceClose` removes it on drop.
|
||||
fn create_swdevice(index: u8) -> Result<(HSWDEVICE, Option<String>)> {
|
||||
let hwids: Vec<u16> = "pf_xusb".encode_utf16().chain([0u16, 0u16]).collect();
|
||||
let instid: Vec<u16> = format!("pf_xusb_{index}")
|
||||
.encode_utf16()
|
||||
.chain(std::iter::once(0))
|
||||
.collect();
|
||||
let desc: Vec<u16> = "punktfunk Virtual Xbox 360 (XUSB)"
|
||||
.encode_utf16()
|
||||
.chain(std::iter::once(0))
|
||||
.collect();
|
||||
// The pad index, stamped into the device Location — the driver reads it to poll `pfxusb-boot-<index>`
|
||||
// (multi-pad). The buffer must outlive the SwDeviceCreate call (it does; we wait on the event).
|
||||
let loc: Vec<u16> = format!("{index}")
|
||||
.encode_utf16()
|
||||
.chain(std::iter::once(0))
|
||||
.collect();
|
||||
let container = GUID::from_values(0x5046_5855, 0x0000, 0x0000, [0, 0, 0, 0, 0, 0, 0, index]);
|
||||
|
||||
// SAFETY: zeroed then the fields we use are set; the buffers + container outlive the call.
|
||||
let mut info: SW_DEVICE_CREATE_INFO = unsafe { std::mem::zeroed() };
|
||||
info.cbSize = std::mem::size_of::<SW_DEVICE_CREATE_INFO>() as u32;
|
||||
info.pszInstanceId = PCWSTR(instid.as_ptr());
|
||||
info.pszzHardwareIds = PCWSTR(hwids.as_ptr());
|
||||
info.pContainerId = &container;
|
||||
info.pszDeviceDescription = PCWSTR(desc.as_ptr());
|
||||
info.pszDeviceLocation = PCWSTR(loc.as_ptr());
|
||||
info.CapabilityFlags = 0x0000_000B; // DriverRequired | SilentInstall | Removable
|
||||
|
||||
// SAFETY: a manual-reset, initially-unsignaled, unnamed event.
|
||||
let event = unsafe { CreateEventW(None, true, false, PCWSTR::null())? };
|
||||
// `result` starts as E_FAIL, NOT S_OK: if the wait below times out, a zero-initialised HRESULT
|
||||
// would read as success and mask the failure (found by the 2026-07 driver-health audit).
|
||||
let mut ctx = SwCreateCtx {
|
||||
event,
|
||||
result: E_FAIL,
|
||||
instance_id: [0; 128],
|
||||
};
|
||||
// SAFETY: info + buffers + ctx outlive the call (we wait on the event before returning).
|
||||
let hsw = match unsafe {
|
||||
SwDeviceCreate(
|
||||
w!("punktfunk"),
|
||||
w!("HTREE\\ROOT\\0"),
|
||||
&info,
|
||||
None,
|
||||
Some(sw_create_cb),
|
||||
Some(&mut ctx as *mut SwCreateCtx as *const c_void),
|
||||
)
|
||||
} {
|
||||
Ok(h) => h,
|
||||
Err(e) => {
|
||||
// SAFETY: event is valid.
|
||||
unsafe {
|
||||
let _ = CloseHandle(event);
|
||||
}
|
||||
return Err(anyhow!("SwDeviceCreate(pf_xusb) failed: {e}"));
|
||||
}
|
||||
};
|
||||
// SAFETY: event valid; block until PnP finishes enumerating, then check the callback result.
|
||||
let wait = unsafe { WaitForSingleObject(event, 10_000) };
|
||||
// SAFETY: event is valid.
|
||||
unsafe {
|
||||
let _ = CloseHandle(event);
|
||||
}
|
||||
if wait != WAIT_OBJECT_0 {
|
||||
// SAFETY: hsw is the handle SwDeviceCreate returned.
|
||||
unsafe { SwDeviceClose(hsw) };
|
||||
return Err(anyhow!(
|
||||
"SwDeviceCreate(pf_xusb) enumeration callback never fired (10s) — PnP may be wedged"
|
||||
));
|
||||
}
|
||||
if ctx.result.is_err() {
|
||||
// SAFETY: hsw is the handle SwDeviceCreate returned.
|
||||
unsafe { SwDeviceClose(hsw) };
|
||||
return Err(anyhow!(
|
||||
"SwDeviceCreate(pf_xusb) enumeration failed: {:?}",
|
||||
ctx.result
|
||||
));
|
||||
}
|
||||
Ok((hsw, ctx.instance_id()))
|
||||
}
|
||||
|
||||
/// A single virtual Xbox 360 pad: the `pf_xusb_<index>` devnode plus the sealed shared-memory channel.
|
||||
struct XusbWinPad {
|
||||
/// Owns the `pf_xusb_<index>` devnode (dropped → `SwDeviceClose`). `None` if `SwDeviceCreate` failed.
|
||||
_sw: Option<super::gamepad_raii::SwDevice>,
|
||||
/// The sealed channel: the unnamed DATA section (the `XusbShm`) + the bootstrap mailbox + the
|
||||
/// handle-delivery state machine (drop closes both sections).
|
||||
channel: PadChannel,
|
||||
/// Watches the section's `driver_proto` field and logs attach / never-attached diagnosis.
|
||||
attach: super::gamepad_raii::DriverAttach,
|
||||
packet: u32,
|
||||
last_rumble_seq: u32,
|
||||
}
|
||||
|
||||
impl XusbWinPad {
|
||||
/// Create the sealed channel (unnamed DATA section + `Global\pfxusb-boot-<index>` mailbox), stamp
|
||||
/// the pad index then the magic LAST, spawn the devnode, and eagerly deliver the DATA handle once
|
||||
/// the driver publishes its pid.
|
||||
fn open(index: u8) -> Result<XusbWinPad> {
|
||||
let boot_name = pf_driver_proto::gamepad::xusb_boot_name(index);
|
||||
let mut channel = PadChannel::create(boot_name.clone(), SHM_SIZE)?;
|
||||
let base = channel.data_base();
|
||||
// The section arrives zeroed; stamp the pad index (the driver validates it against its own
|
||||
// devnode index on attach) then the magic LAST (the driver only accepts it once magic is set).
|
||||
// SAFETY: base points at SHM_SIZE writable bytes; OFF_PAD_INDEX is in range.
|
||||
unsafe {
|
||||
std::ptr::write_unaligned(base.add(OFF_PAD_INDEX) as *mut u32, index as u32);
|
||||
std::ptr::write_unaligned(base as *mut u32, SHM_MAGIC);
|
||||
}
|
||||
// Propagate a devnode-create failure instead of swallowing it: a swallowed failure left the
|
||||
// pad with no devnode yet still reported success, so PadSlots latched a phantom pad (never
|
||||
// re-created for the session's life) and the host logged a misleading "virtual Xbox 360
|
||||
// created". Returning Err routes it through PadSlots' ERROR + capped-backoff retry — parity
|
||||
// with the Linux uinput path, which self-heals for exactly this reason.
|
||||
let (hsw, instance_id) = create_swdevice(index)?;
|
||||
let _sw = Some(super::gamepad_raii::SwDevice::new(hsw));
|
||||
// Bounded eager delivery: the driver's EvtDeviceAdd publishes its pid right away; handing it
|
||||
// the DATA handle before we return means the pad is live for the game's first XInput poll.
|
||||
// On a missing/old driver this waits out the window once and the service pump takes over.
|
||||
channel.deliver_eager(Duration::from_millis(1500));
|
||||
Ok(XusbWinPad {
|
||||
_sw,
|
||||
channel,
|
||||
attach: super::gamepad_raii::DriverAttach::new(
|
||||
"pf_xusb",
|
||||
"pf_xusb.inf",
|
||||
"C:\\Users\\Public\\pfxusb-driver.log",
|
||||
boot_name,
|
||||
instance_id,
|
||||
),
|
||||
packet: 0,
|
||||
last_rumble_seq: 0,
|
||||
})
|
||||
}
|
||||
|
||||
/// Publish the XInput state to the section and bump the packet number (XInput uses it to detect
|
||||
/// change). `buttons` is the XINPUT_GAMEPAD_* bitmap; sticks are i16, triggers u8.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn write_state(&mut self, buttons: u16, lt: u8, rt: u8, lx: i16, ly: i16, rx: i16, ry: i16) {
|
||||
self.packet = self.packet.wrapping_add(1);
|
||||
let base = self.channel.data_base();
|
||||
// SAFETY: `base` is the start of the mapped section (`SHM_SIZE` bytes, owned by `Shm`); every
|
||||
// `OFF_*` is a fixed in-range offset into it and `write_unaligned` handles the unaligned field
|
||||
// writes. Single owner (`&mut self`), so no concurrent writer races these stores. `packet` (the
|
||||
// field XInput reads to detect a new state) is published LAST: the `Release` fence orders the
|
||||
// state-body stores above before the `Release` `AtomicU32` store of `packet`, so the driver —
|
||||
// which `Acquire`-loads `packet` — never observes a bumped packet over a torn body on a
|
||||
// weakly-ordered core (ARM64). On x86-TSO both are plain stores. `OFF_PACKET` (== 4) is
|
||||
// 4-aligned off the page-aligned section base, so the `AtomicU32` view is valid (mirrors the
|
||||
// seq-fenced publish in `gamepad_raii::PadChannel::create`).
|
||||
unsafe {
|
||||
std::ptr::write_unaligned(base.add(OFF_BUTTONS) as *mut u16, buttons);
|
||||
*base.add(OFF_LT) = lt;
|
||||
*base.add(OFF_RT) = rt;
|
||||
std::ptr::write_unaligned(base.add(OFF_LX) as *mut i16, lx);
|
||||
std::ptr::write_unaligned(base.add(OFF_LY) as *mut i16, ly);
|
||||
std::ptr::write_unaligned(base.add(OFF_RX) as *mut i16, rx);
|
||||
std::ptr::write_unaligned(base.add(OFF_RY) as *mut i16, ry);
|
||||
fence(Ordering::Release);
|
||||
(*(base.add(OFF_PACKET) as *const AtomicU32)).store(self.packet, Ordering::Release);
|
||||
}
|
||||
}
|
||||
|
||||
/// Poll the section for a game's rumble (the driver bumps `rumble_seq` on each SET_STATE). Returns
|
||||
/// `(large, small)` motor levels (0..=255) when a new one arrived. Also ticks the sealed-channel
|
||||
/// delivery (a late-binding driver gets its handle here) and feeds the driver-attach health
|
||||
/// watcher (the driver stamps `driver_proto` once it maps the delivered section + per IOCTL).
|
||||
fn service(&mut self) -> Option<(u8, u8)> {
|
||||
self.channel.pump();
|
||||
let base = self.channel.data_base();
|
||||
// SAFETY: base points at SHM_SIZE bytes.
|
||||
let proto = unsafe { std::ptr::read_unaligned(base.add(OFF_DRIVER_PROTO) as *const u32) };
|
||||
self.attach.observe(proto);
|
||||
// SAFETY: base points at SHM_SIZE bytes; `OFF_RUMBLE_SEQ` (== 24) is 4-aligned off the
|
||||
// page-aligned base, so the `AtomicU32` view is valid. The driver bumps `rumble_seq` AFTER
|
||||
// writing the rumble bytes, so an `Acquire` load here orders the `rumble_large`/`rumble_small`
|
||||
// reads below after it — a fresh seq guarantees a coherent snapshot of the rumble bytes on a
|
||||
// weakly-ordered core (ARM64). On x86-TSO it is a plain load.
|
||||
let seq =
|
||||
unsafe { (*(base.add(OFF_RUMBLE_SEQ) as *const AtomicU32)).load(Ordering::Acquire) };
|
||||
if seq == self.last_rumble_seq {
|
||||
return None;
|
||||
}
|
||||
self.last_rumble_seq = seq;
|
||||
// SAFETY: rumble bytes at OFF_RUMBLE / OFF_RUMBLE+1.
|
||||
let (large, small) = unsafe { (*base.add(OFF_RUMBLE), *base.add(OFF_RUMBLE + 1)) };
|
||||
Some((large, small))
|
||||
}
|
||||
}
|
||||
|
||||
/// All virtual Xbox 360 pads of a session — the Windows analogue of the Linux uinput-xpad manager,
|
||||
/// now backed by the XUSB companion driver. Same method surface (`new`/`handle`/`pump_rumble`) the
|
||||
/// session input thread already drives.
|
||||
/// How long a non-zero rumble may stay latched with the game NOT driving the pad (no `SET_STATE`)
|
||||
/// before it is forced off. XInput vibration is level-triggered — it persists until the game sets
|
||||
/// it to zero — so a game that latches a rumble and then stops calling `XInputSetState` (a residual
|
||||
/// left at a menu / loading screen, or a plain forgotten stop) would otherwise drone to the client
|
||||
/// forever (measured: a stuck `(0,512)` resent every 500 ms for 5.5 minutes). A real controller
|
||||
/// stops when the app stops driving it; this mirrors that. It is keyed on game ACTIVITY (any
|
||||
/// `SET_STATE`, even an unchanged one), so a rumble the game keeps asserting is never cut — only an
|
||||
/// abandoned residual is. Kept above SDL's ~2 s internal rumble resend so an SDL-driven host game
|
||||
/// (which re-issues the same level every ~2 s) refreshes the activity clock before this fires.
|
||||
const RUMBLE_IDLE_TIMEOUT: Duration = Duration::from_millis(2500);
|
||||
|
||||
pub struct GamepadManager {
|
||||
slots: PadSlots<XusbWinPad>,
|
||||
last_rumble: Vec<(u8, u8)>,
|
||||
/// When the game last drove each pad (bumped `rumble_seq` via `SET_STATE`). A non-zero
|
||||
/// `last_rumble` older than [`RUMBLE_IDLE_TIMEOUT`] against this is a stale residual — see the
|
||||
/// const's docs.
|
||||
last_active: Vec<Instant>,
|
||||
}
|
||||
|
||||
impl Default for GamepadManager {
|
||||
fn default() -> GamepadManager {
|
||||
GamepadManager::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl GamepadManager {
|
||||
pub fn new() -> GamepadManager {
|
||||
GamepadManager {
|
||||
slots: PadSlots::new(
|
||||
"Xbox 360/Windows",
|
||||
"Xbox 360",
|
||||
" (install/repair: punktfunk-host.exe driver install --gamepad)",
|
||||
),
|
||||
last_rumble: vec![(0, 0); MAX_PADS],
|
||||
last_active: (0..MAX_PADS).map(|_| Instant::now()).collect(),
|
||||
}
|
||||
}
|
||||
|
||||
fn ensure(&mut self, idx: usize) {
|
||||
if self.slots.ensure(idx, XusbWinPad::open) {
|
||||
tracing::info!(
|
||||
index = idx,
|
||||
"virtual Xbox 360 created (Windows XUSB companion)"
|
||||
);
|
||||
self.last_rumble[idx] = (0, 0);
|
||||
self.last_active[idx] = Instant::now();
|
||||
}
|
||||
}
|
||||
|
||||
pub fn handle(&mut self, ev: &GamepadEvent) {
|
||||
match ev {
|
||||
GamepadEvent::Arrival { index, kind, .. } => {
|
||||
tracing::info!(index, kind, "controller arrival (Xbox 360/Windows)");
|
||||
self.ensure(*index as usize);
|
||||
}
|
||||
GamepadEvent::State(f) => {
|
||||
let idx = f.index as usize;
|
||||
if idx >= MAX_PADS {
|
||||
return;
|
||||
}
|
||||
// Unplugs: drop any allocated pad whose mask bit cleared.
|
||||
let swept = self.slots.sweep(f.active_mask);
|
||||
for i in 0..MAX_PADS {
|
||||
if swept & (1 << i) != 0 {
|
||||
self.last_rumble[i] = (0, 0);
|
||||
self.last_active[i] = Instant::now();
|
||||
}
|
||||
}
|
||||
if f.active_mask & (1 << idx) == 0 {
|
||||
return;
|
||||
}
|
||||
self.ensure(idx);
|
||||
if let Some(pad) = self.slots.get_mut(idx) {
|
||||
pad.write_state(
|
||||
(f.buttons & 0xffff) as u16,
|
||||
f.left_trigger,
|
||||
f.right_trigger,
|
||||
f.ls_x,
|
||||
f.ls_y,
|
||||
f.rs_x,
|
||||
f.rs_y,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Relay any changed rumble level to the client. XUSB motors are 0..255; the wire carries
|
||||
/// 0..65535, so scale by 257. `large` (low-frequency) → the datagram's `low`, `small`
|
||||
/// (high-frequency) → `high` — matching the other backends.
|
||||
pub fn pump_rumble(&mut self, mut send: impl FnMut(u16, u16, u16)) {
|
||||
for (i, pad) in self.slots.iter_mut() {
|
||||
if let Some((large, small)) = pad.service() {
|
||||
// The game drove the pad this poll (SET_STATE bumped the seq) — refresh the
|
||||
// activity clock even when the level is unchanged, so a rumble it keeps asserting
|
||||
// never trips the stale-residual timeout below.
|
||||
self.last_active[i] = Instant::now();
|
||||
if self.last_rumble[i] != (large, small) {
|
||||
self.last_rumble[i] = (large, small);
|
||||
send(i as u16, large as u16 * 257, small as u16 * 257);
|
||||
}
|
||||
} else if self.last_rumble[i] != (0, 0)
|
||||
&& self.last_active[i].elapsed() >= RUMBLE_IDLE_TIMEOUT
|
||||
{
|
||||
// A non-zero rumble is latched but the game has not driven the pad for
|
||||
// RUMBLE_IDLE_TIMEOUT — a residual it forgot to stop. Force it off (and forward
|
||||
// the zero) so the resend loop stops droning it to the client. See the const docs.
|
||||
tracing::info!(
|
||||
index = i,
|
||||
prev_low = self.last_rumble[i].0 as u16 * 257,
|
||||
prev_high = self.last_rumble[i].1 as u16 * 257,
|
||||
"rumble: stale residual (game stopped driving the pad) — forcing off"
|
||||
);
|
||||
self.last_rumble[i] = (0, 0);
|
||||
send(i as u16, 0, 0);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,355 @@
|
||||
//! Resident virtual HID mouse on Windows via the UMDF minidriver (`packaging/windows/drivers/pf-mouse`).
|
||||
//!
|
||||
//! **Why**: with no pointing device attached (a headless streaming box — no dongle), win32k reports
|
||||
//! the cursor as absent (`GetSystemMetrics(SM_MOUSEPRESENT)` = 0) and DWM never composites a cursor
|
||||
//! into the pf-vdisplay frame — the streamed desktop has an invisible pointer even though
|
||||
//! `SendInput` moves it. Keeping ONE virtual HID mouse devnode alive for the host's lifetime makes
|
||||
//! Windows always consider a pointer present and draw the cursor — the Sunshine/Parsec-class fix,
|
||||
//! with zero client changes. Injection stays [`super::sendinput`]; the report path here is
|
||||
//! exercised by `punktfunk-host vmouse-spike` (on-glass validation) and is the future
|
||||
//! higher-fidelity injection route.
|
||||
//!
|
||||
//! Transport is the **sealed pad channel** verbatim ([`PadChannel`],
|
||||
//! `design/gamepad-channel-sealing.md`): an unnamed 64-B `MouseShm` DATA section the host
|
||||
//! duplicates into the driver's WUDFHost, bootstrapped via the named `Global\pfmouse-boot-0`
|
||||
//! mailbox. The devnode is `SwDeviceCreate`'d like a pad but held for the PROCESS lifetime (the
|
||||
//! [`ensure_resident`] thread never drops it), so the pointer survives across sessions; it
|
||||
//! disappears with the host service, which is exactly when nobody is streaming.
|
||||
|
||||
use super::dualsense_windows::{create_swdevice, SwDeviceProfile};
|
||||
use super::gamepad_raii::{DriverAttach, PadChannel};
|
||||
use anyhow::Result;
|
||||
use pf_driver_proto::mouse::{input_report, mouse_boot_name, MouseShm, MOUSE_MAGIC};
|
||||
use std::sync::atomic::{AtomicBool, AtomicU32, Ordering};
|
||||
use std::sync::{Condvar, Mutex};
|
||||
use std::time::Duration;
|
||||
use windows::Win32::Foundation::POINT;
|
||||
use windows::Win32::UI::WindowsAndMessaging::GetCursorPos;
|
||||
|
||||
const SHM_SIZE: usize = core::mem::size_of::<MouseShm>();
|
||||
const OFF_IN_SEQ: usize = core::mem::offset_of!(MouseShm, in_seq);
|
||||
const OFF_REPORT: usize = core::mem::offset_of!(MouseShm, report);
|
||||
const OFF_DRIVER_PROTO: usize = core::mem::offset_of!(MouseShm, driver_proto);
|
||||
const OFF_DRIVER_HEARTBEAT: usize = core::mem::offset_of!(MouseShm, driver_heartbeat);
|
||||
const OFF_PAD_INDEX: usize = core::mem::offset_of!(MouseShm, pad_index);
|
||||
|
||||
/// The one resident virtual mouse: the `SwDeviceCreate`'d `pf_mouse_0` devnode (the pf-mouse HID
|
||||
/// minidriver loads on it → Windows counts a pointer present) plus the sealed shared-memory
|
||||
/// channel. Dropping it removes the devnode — [`ensure_resident`] therefore never drops it.
|
||||
pub struct VirtualMouse {
|
||||
/// Devnode RAII (`SwDeviceClose` on drop). `None` falls back to an out-of-band devnode.
|
||||
_sw: Option<super::gamepad_raii::SwDevice>,
|
||||
channel: PadChannel,
|
||||
attach: DriverAttach,
|
||||
seq: u32,
|
||||
}
|
||||
|
||||
impl VirtualMouse {
|
||||
/// Create the sealed channel (unnamed DATA section + `Global\pfmouse-boot-0` mailbox), stamp
|
||||
/// the index + the magic LAST, then spawn the devnode and eagerly deliver the DATA handle.
|
||||
pub fn open() -> Result<VirtualMouse> {
|
||||
let boot_name = mouse_boot_name(0);
|
||||
let mut channel = PadChannel::create(boot_name.clone(), SHM_SIZE)?;
|
||||
let base = channel.data_base();
|
||||
// SAFETY: base points at SHM_SIZE writable bytes; the OFF_* offsets are in range. Index
|
||||
// first, magic LAST — the same publish order the pads use.
|
||||
unsafe {
|
||||
std::ptr::write_unaligned(base.add(OFF_PAD_INDEX) as *mut u32, 0u32);
|
||||
std::ptr::write_unaligned(base as *mut u32, MOUSE_MAGIC);
|
||||
}
|
||||
let (hsw, instance_id) = match create_swdevice(&SwDeviceProfile {
|
||||
instance: "pf_mouse_0",
|
||||
container_tag: 0x5046_4D4F, // "PFMO" — never grouped with a pad's container
|
||||
container_index: 0,
|
||||
hwid: "pf_mouse",
|
||||
// An obviously-virtual identity (PF:MO). The synthesized USB bus tokens are inert for
|
||||
// a mouse (nothing fingerprints them); reusing the shared profile keeps one code path.
|
||||
usb_vid_pid: "VID_5046&PID_4D4F",
|
||||
usb_mi: None,
|
||||
description: "punktfunk Virtual Mouse",
|
||||
}) {
|
||||
Ok((h, i)) => (Some(h), i),
|
||||
Err(e) => {
|
||||
tracing::warn!(error = %format!("{e:#}"), "SwDeviceCreate failed; falling back to an out-of-band pf_mouse devnode");
|
||||
(None, None)
|
||||
}
|
||||
};
|
||||
let _sw = hsw.map(super::gamepad_raii::SwDevice::new);
|
||||
channel.deliver_eager(Duration::from_millis(1500));
|
||||
Ok(VirtualMouse {
|
||||
_sw,
|
||||
channel,
|
||||
attach: DriverAttach::new(
|
||||
"pf_mouse",
|
||||
"pf_mouse.inf",
|
||||
"C:\\Users\\Public\\pfmouse-driver.log",
|
||||
boot_name,
|
||||
instance_id,
|
||||
),
|
||||
seq: 0,
|
||||
})
|
||||
}
|
||||
|
||||
/// Publish an input report (5-bit buttons, absolute 15-bit x/y, wheel/pan deltas) and bump
|
||||
/// `in_seq` (Release) — the driver's timer completes a pended `READ_REPORT` with it. Unused by
|
||||
/// sessions today (`SendInput` injects); the spike drives it, and a future fidelity mode will.
|
||||
pub fn send_report(&mut self, buttons: u8, x: u16, y: u16, wheel: i8, pan: i8) {
|
||||
let r = input_report(buttons, x, y, wheel, pan);
|
||||
self.seq = self.seq.wrapping_add(1).max(1); // never publish seq 0 (= "nothing yet")
|
||||
let base = self.channel.data_base();
|
||||
// SAFETY: base points at SHM_SIZE bytes; the report slot is OFF_REPORT..+8 and OFF_IN_SEQ
|
||||
// (== 4) is 4-aligned off the page-aligned base, so the AtomicU32 view is valid. The report
|
||||
// bytes are published BEFORE the seq (Release) — the driver's Acquire load of `in_seq`
|
||||
// therefore observes the matching report.
|
||||
unsafe {
|
||||
std::ptr::copy_nonoverlapping(r.as_ptr(), base.add(OFF_REPORT), r.len());
|
||||
(*(base.add(OFF_IN_SEQ) as *const AtomicU32)).store(self.seq, Ordering::Release);
|
||||
}
|
||||
}
|
||||
|
||||
/// One service tick: pump the sealed-channel delivery and feed the driver-attach health
|
||||
/// watcher (the driver's 8 ms timer stamps `driver_proto` while it has the section mapped).
|
||||
pub fn service(&mut self) {
|
||||
self.channel.pump();
|
||||
self.attach.observe(self.driver_proto());
|
||||
}
|
||||
|
||||
fn driver_proto(&self) -> u32 {
|
||||
// SAFETY: base points at SHM_SIZE bytes; OFF_DRIVER_PROTO is in range.
|
||||
unsafe {
|
||||
std::ptr::read_unaligned(self.channel.data_base().add(OFF_DRIVER_PROTO) as *const u32)
|
||||
}
|
||||
}
|
||||
|
||||
fn driver_heartbeat(&self) -> u32 {
|
||||
// SAFETY: base points at SHM_SIZE bytes; OFF_DRIVER_HEARTBEAT is in range.
|
||||
unsafe {
|
||||
std::ptr::read_unaligned(
|
||||
self.channel.data_base().add(OFF_DRIVER_HEARTBEAT) as *const u32
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// One pending compose-kick aim, desktop coordinates: the target display's rect plus the
|
||||
/// virtual-desktop bounds to normalize against (both from CCD, so they describe the CONSOLE's
|
||||
/// layout whatever session this process is in). Newest-wins single slot — kicks are idempotent
|
||||
/// damage nudges, queueing them would only multiply pointer blips.
|
||||
struct KickAim {
|
||||
rect: (i32, i32, i32, i32),
|
||||
bounds: (i32, i32, i32, i32),
|
||||
}
|
||||
|
||||
struct KickSlot {
|
||||
slot: Mutex<Option<KickAim>>,
|
||||
wake: Condvar,
|
||||
}
|
||||
|
||||
static KICK: KickSlot = KickSlot {
|
||||
slot: Mutex::new(None),
|
||||
wake: Condvar::new(),
|
||||
};
|
||||
|
||||
/// True while the keeper's mouse is open AND the pf-mouse driver is attached (its 8 ms timer
|
||||
/// stamps `driver_proto`) — the only state in which a kick's reports actually reach win32k.
|
||||
static MOUSE_READY: AtomicBool = AtomicBool::new(false);
|
||||
|
||||
/// Request a pointer jiggle on the given display through the resident virtual mouse — the
|
||||
/// COMPOSE KICK's reliable arm. A report from a HID device is REAL input to win32k: it wakes a
|
||||
/// powered-off display subsystem (lid-closed / display idle-off / modern standby), resets idle
|
||||
/// timers, counts as user presence, and is delivered regardless of the calling process's session
|
||||
/// or the active desktop — every condition under which the `SendInput` kick is silently impotent
|
||||
/// (wrong session → wrong input queue; secure desktop → blocked; display-off → nothing to
|
||||
/// damage). Asynchronous: the keeper thread (which owns the one process-wide mouse) executes it
|
||||
/// within its tick. Returns `false` when the resident mouse isn't up (opted out, driver not
|
||||
/// installed, not yet attached) — the caller falls back to `SendInput`.
|
||||
pub(crate) fn hid_kick(rect: (i32, i32, i32, i32), bounds: (i32, i32, i32, i32)) -> bool {
|
||||
if !MOUSE_READY.load(Ordering::Relaxed) {
|
||||
return false;
|
||||
}
|
||||
*KICK.slot.lock().unwrap() = Some(KickAim { rect, bounds });
|
||||
KICK.wake.notify_one();
|
||||
true
|
||||
}
|
||||
|
||||
/// Execute one compose kick on the keeper thread: park the pointer at the target rect's center,
|
||||
/// dwell one composition interval, wiggle ~2 px, then put it back where it was. Every report is
|
||||
/// device-level input (see [`hid_kick`]). The dwell is load-bearing (the Stage-W3 on-glass
|
||||
/// finding, same as the SendInput jump path): DWM samples the cursor position at the next vsync
|
||||
/// tick, so a sub-tick round trip composes nothing. The gaps also respect the driver's 8 ms
|
||||
/// report timer — back-to-back writes into the single report slot would coalesce.
|
||||
///
|
||||
/// The restore is best-effort via `GetCursorPos`: in a wrong-session host it describes the wrong
|
||||
/// session's pointer, so the console pointer is instead left near the target's center — which is
|
||||
/// the streamed display, exactly where the pointer is about to be useful.
|
||||
fn perform_kick(m: &mut VirtualMouse, aim: KickAim) {
|
||||
let (bx, by, bw, bh) = aim.bounds;
|
||||
if bw <= 0 || bh <= 0 {
|
||||
return;
|
||||
}
|
||||
// Field-log which kick arm fired (the SendInput arm logs in kick_dwm_compose) — a lid-closed
|
||||
// repro should show this line followed by the driver's first acquired frame.
|
||||
tracing::debug!(
|
||||
rect = ?aim.rect,
|
||||
bounds = ?aim.bounds,
|
||||
"HID compose kick — parking the pointer on the target display (display wake + damage)"
|
||||
);
|
||||
let map = |px: i32, py: i32| -> (u16, u16) {
|
||||
let nx = ((px - bx).clamp(0, bw - 1) as i64 * 0x7FFF) / i64::from(bw - 1).max(1);
|
||||
let ny = ((py - by).clamp(0, bh - 1) as i64 * 0x7FFF) / i64::from(bh - 1).max(1);
|
||||
(nx as u16, ny as u16)
|
||||
};
|
||||
let mut p = POINT::default();
|
||||
// SAFETY: plain FFI; `p` is a valid out-param for this synchronous call.
|
||||
let orig = unsafe { GetCursorPos(&mut p) }
|
||||
.is_ok()
|
||||
.then_some((p.x, p.y));
|
||||
let (rx, ry, rw, rh) = aim.rect;
|
||||
let (cx, cy) = map(rx + rw / 2, ry + rh / 2);
|
||||
// ~2 desktop pixels in HID units, at least 1 — the wiggle must actually move the pointer.
|
||||
let dx = ((2 * 0x7FFF) / bw.max(1)).max(1) as u16;
|
||||
m.send_report(0, cx, cy, 0, 0);
|
||||
std::thread::sleep(Duration::from_millis(35));
|
||||
m.send_report(0, cx.saturating_add(dx).min(0x7FFF), cy, 0, 0);
|
||||
std::thread::sleep(Duration::from_millis(35));
|
||||
match orig {
|
||||
Some((ox, oy)) => {
|
||||
let (ox, oy) = map(ox, oy);
|
||||
m.send_report(0, ox, oy, 0, 0);
|
||||
}
|
||||
None => m.send_report(0, cx, cy, 0, 0),
|
||||
}
|
||||
}
|
||||
|
||||
/// Make sure the resident virtual mouse exists (idempotent, best-effort). Called whenever an
|
||||
/// [`InjectorService`](crate::InjectorService) starts — multiple services (native +
|
||||
/// GameStream) share the ONE process-wide mouse, guarded here. Spawns a keeper thread that owns
|
||||
/// the devnode for the process lifetime and pumps the channel at a slow tick (delivery is eager at
|
||||
/// open; the pump only handles a late WUDFHost + feeds the attach diagnostics).
|
||||
///
|
||||
/// `PUNKTFUNK_NO_VIRTUAL_MOUSE=1` opts out (diagnostics, or an operator who objects to a virtual
|
||||
/// pointer device).
|
||||
pub(crate) fn ensure_resident() {
|
||||
use std::sync::OnceLock;
|
||||
static STARTED: OnceLock<()> = OnceLock::new();
|
||||
STARTED.get_or_init(|| {
|
||||
if std::env::var_os("PUNKTFUNK_NO_VIRTUAL_MOUSE").is_some_and(|v| v != "0") {
|
||||
tracing::info!(
|
||||
"virtual HID mouse disabled (PUNKTFUNK_NO_VIRTUAL_MOUSE) — with no physical \
|
||||
pointer attached, Windows will not draw a cursor into the stream"
|
||||
);
|
||||
return;
|
||||
}
|
||||
// Hand the capture crate its HID compose-kick hook (the one-way-edge inversion: pf-capture
|
||||
// never reaches back into inject). Registered exactly when the resident mouse is being
|
||||
// brought up; until the driver actually attaches, `hid_kick` reports not-ready and the
|
||||
// kick falls back to SendInput.
|
||||
let _ = pf_capture::HID_COMPOSE_KICK.set(hid_kick);
|
||||
if let Err(e) = std::thread::Builder::new()
|
||||
.name("punktfunk-vmouse".into())
|
||||
.spawn(keeper_thread)
|
||||
{
|
||||
tracing::warn!(error = %e, "virtual-mouse keeper thread spawn failed");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// Open-with-retry, then hold + pump forever. Open only realistically fails on a mailbox squat
|
||||
/// (another punktfunk-host instance) — retry slowly; a missing/failed DRIVER is not an open
|
||||
/// failure (the devnode exists but nothing binds), which [`DriverAttach`] diagnoses via the pump.
|
||||
/// Each tick also publishes kick-readiness ([`MOUSE_READY`]) and executes at most one pending
|
||||
/// compose kick ([`hid_kick`]) — the condvar wait keeps kick latency at "immediately", not "next
|
||||
/// 250 ms tick", while an idle keeper still only wakes 4×/s.
|
||||
fn keeper_thread() {
|
||||
loop {
|
||||
match VirtualMouse::open() {
|
||||
Ok(mut m) => {
|
||||
tracing::info!(
|
||||
"resident virtual HID mouse created (pf_mouse — keeps SM_MOUSEPRESENT true \
|
||||
so DWM composites the cursor on headless hosts)"
|
||||
);
|
||||
loop {
|
||||
m.service();
|
||||
MOUSE_READY.store(m.driver_proto() != 0, Ordering::Relaxed);
|
||||
let (mut slot, _timeout) = KICK
|
||||
.wake
|
||||
.wait_timeout_while(
|
||||
KICK.slot.lock().unwrap(),
|
||||
Duration::from_millis(250),
|
||||
|k| k.is_none(),
|
||||
)
|
||||
.unwrap();
|
||||
let aim = slot.take();
|
||||
drop(slot);
|
||||
if let Some(aim) = aim {
|
||||
if m.driver_proto() != 0 {
|
||||
perform_kick(&mut m, aim);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!(
|
||||
error = %format!("{e:#}"),
|
||||
"virtual HID mouse open failed — retrying in 60s (headless hosts stream an \
|
||||
invisible cursor until it exists)"
|
||||
);
|
||||
std::thread::sleep(Duration::from_secs(60));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `vmouse-spike` (dev validation): hold the virtual mouse and drive the REAL cursor through the
|
||||
/// HID report path — proves the full chain (SwDeviceCreate → INF bind → mshidumdf → mouhid →
|
||||
/// win32k) on-glass. Run with the host service STOPPED (the resident mouse owns the mailbox name
|
||||
/// otherwise). Verify while it holds: `Get-PnpDevice` shows the pf_mouse devnode + a HID child,
|
||||
/// `GetSystemMetrics(SM_MOUSEPRESENT)` = 1 with no physical mouse, and the cursor sweeps a
|
||||
/// horizontal line mid-screen.
|
||||
pub fn spike_hold(secs: u64) -> Result<()> {
|
||||
let mut m = VirtualMouse::open()?;
|
||||
println!("virtual HID mouse devnode up (5046:4D4F) — waiting for the driver to attach…");
|
||||
let deadline = std::time::Instant::now() + Duration::from_secs(10);
|
||||
while m.driver_proto() == 0 && std::time::Instant::now() < deadline {
|
||||
m.service();
|
||||
std::thread::sleep(Duration::from_millis(50));
|
||||
}
|
||||
if m.driver_proto() == 0 {
|
||||
println!(
|
||||
"driver never attached (10s). Install it: punktfunk-host.exe driver install --gamepad \
|
||||
--dir <stage> (pf_mouse.inf ships with the gamepad drivers); see the WARN above."
|
||||
);
|
||||
} else {
|
||||
println!(
|
||||
"driver attached (proto {}). Sweeping the cursor for {secs}s — watch the glass: the \
|
||||
pointer should glide left↔right across mid-screen; wheel ticks every second.",
|
||||
m.driver_proto()
|
||||
);
|
||||
}
|
||||
let t0 = std::time::Instant::now();
|
||||
let mut i: u64 = 0;
|
||||
let beat_before = m.driver_heartbeat();
|
||||
while t0.elapsed() < Duration::from_secs(secs) {
|
||||
// Triangle-wave X sweep over the middle 3/4 of the axis, fixed mid-screen Y; one wheel
|
||||
// tick per second so scroll delivery is visible too.
|
||||
let phase = (i % 240) as i32; // 240 steps × 16 ms ≈ 4 s per round trip
|
||||
let tri = if phase < 120 { phase } else { 240 - phase };
|
||||
let x = 4096 + (tri as u32 * (24576 / 120)) as u16;
|
||||
let wheel: i8 = if i % 60 == 0 { 1 } else { 0 };
|
||||
m.send_report(0, x, 0x4000, wheel, 0);
|
||||
m.service();
|
||||
i += 1;
|
||||
std::thread::sleep(Duration::from_millis(16));
|
||||
}
|
||||
let beat = m.driver_heartbeat();
|
||||
println!(
|
||||
"vmouse-spike: done (driver heartbeat advanced {} ticks — {}). Devnode removed on exit.",
|
||||
beat.wrapping_sub(beat_before),
|
||||
if beat != beat_before {
|
||||
"driver alive"
|
||||
} else {
|
||||
"driver NOT ticking"
|
||||
}
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,461 @@
|
||||
//! Windows input injection via `SendInput` (Win32 KeyboardAndMouse) — the Windows analogue of
|
||||
//! [`super::wlr`]: absolute mouse normalized to the virtual desktop, relative mouse for games,
|
||||
//! scancode keyboard, scroll, buttons. Survives UAC/lock desktop switches with Sunshine's
|
||||
//! retry-on-failure model: the thread stays bound to its desktop and only reattaches
|
||||
//! (`OpenInputDesktop`/`SetThreadDesktop`) when `SendInput` reports a short write (the input
|
||||
//! desktop switched) — no per-event reattach overhead.
|
||||
//!
|
||||
//! **Keyboard conventions** (see [`crate::KEY_FLAG_SEMANTIC_VK`]): first-party punktfunk
|
||||
//! clients send **US-positional** VKs (the physical key's US-layout VK — layout-independent by
|
||||
//! construction, the mirror of the Linux host's `vk_to_evdev`), resolved here through the fixed
|
||||
//! [`positional_vk_to_scan`] table. GameStream/Moonlight clients send **layout-semantic** VKs
|
||||
//! (Sunshine's model), resolved under the foreground app's layout. Never resolve a positional VK
|
||||
//! through a layout: this thread runs in the SYSTEM service, whose layout is unrelated to the
|
||||
//! user's, and any layout re-reads a *position* as a *character* — on a German host that is
|
||||
//! exactly the y↔z swap / ü-on-ö scramble.
|
||||
|
||||
// Every `unsafe` block in this file carries a `// SAFETY:` proof; enforce it.
|
||||
#![deny(clippy::undocumented_unsafe_blocks)]
|
||||
|
||||
use anyhow::Result;
|
||||
use punktfunk_core::input::{InputEvent, InputKind};
|
||||
use std::mem::size_of;
|
||||
use windows::Win32::System::StationsAndDesktops::{
|
||||
CloseDesktop, OpenInputDesktop, SetThreadDesktop, DESKTOP_ACCESS_FLAGS, DESKTOP_CONTROL_FLAGS,
|
||||
HDESK,
|
||||
};
|
||||
use windows::Win32::UI::Input::KeyboardAndMouse::{
|
||||
GetKeyboardLayout, MapVirtualKeyExW, SendInput, HKL, INPUT, INPUT_0, INPUT_KEYBOARD,
|
||||
INPUT_MOUSE, KEYBDINPUT, KEYEVENTF_EXTENDEDKEY, KEYEVENTF_KEYUP, KEYEVENTF_SCANCODE,
|
||||
MAPVK_VK_TO_VSC_EX, MOUSEEVENTF_ABSOLUTE, MOUSEEVENTF_HWHEEL, MOUSEEVENTF_LEFTDOWN,
|
||||
MOUSEEVENTF_LEFTUP, MOUSEEVENTF_MIDDLEDOWN, MOUSEEVENTF_MIDDLEUP, MOUSEEVENTF_MOVE,
|
||||
MOUSEEVENTF_RIGHTDOWN, MOUSEEVENTF_RIGHTUP, MOUSEEVENTF_VIRTUALDESK, MOUSEEVENTF_WHEEL,
|
||||
MOUSEEVENTF_XDOWN, MOUSEEVENTF_XUP, MOUSEINPUT, VIRTUAL_KEY,
|
||||
};
|
||||
use windows::Win32::UI::WindowsAndMessaging::{
|
||||
GetForegroundWindow, GetSystemMetrics, GetWindowThreadProcessId, SM_CXVIRTUALSCREEN,
|
||||
SM_CYVIRTUALSCREEN, SM_XVIRTUALSCREEN, SM_YVIRTUALSCREEN,
|
||||
};
|
||||
|
||||
use super::InputInjector;
|
||||
|
||||
const ABS_MAX: f64 = 65535.0; // SendInput absolute coords are 0..65535 over the chosen surface.
|
||||
const GENERIC_ALL: u32 = 0x1000_0000;
|
||||
const XBUTTON1: u32 = 0x0001;
|
||||
const XBUTTON2: u32 = 0x0002;
|
||||
|
||||
pub struct SendInputInjector {
|
||||
desktop: Option<HDESK>,
|
||||
}
|
||||
|
||||
// SAFETY: `SendInputInjector` holds only an `Option<HDESK>` (a desktop handle). The host creates
|
||||
// and drives it from a single dedicated injector thread; the handle is opened, rebound, and closed
|
||||
// on whichever thread owns the value, and the type is not `Sync`, so there is never concurrent
|
||||
// access. A desktop `HDESK` is not thread-affine for ownership (`CloseDesktop` works from any
|
||||
// thread; `SetThreadDesktop` rebinds the current thread), so transferring ownership via `Send` is
|
||||
// sound.
|
||||
unsafe impl Send for SendInputInjector {}
|
||||
|
||||
impl SendInputInjector {
|
||||
pub fn open() -> Result<Self> {
|
||||
let mut me = Self { desktop: None };
|
||||
me.reattach_input_desktop(); // best-effort
|
||||
tracing::info!("SendInput injector ready (Win32 KeyboardAndMouse)");
|
||||
Ok(me)
|
||||
}
|
||||
|
||||
/// Bind this thread to the desktop currently receiving input. UAC / lock screen / Ctrl-Alt-Del
|
||||
/// swap the input desktop; `SendInput` silently no-ops unless our thread is on it.
|
||||
fn reattach_input_desktop(&mut self) {
|
||||
// SAFETY: `OpenInputDesktop`/`SetThreadDesktop`/`CloseDesktop` are FFI calls passed only
|
||||
// by-value args (constant desktop flags, a `bool`, an access mask). `OpenInputDesktop`
|
||||
// yields an owned `HDESK` only on `Ok`; we then either install it with `SetThreadDesktop`
|
||||
// (closing the previously-owned handle exactly once) or close the fresh handle on failure —
|
||||
// so every handle is closed exactly once and none is used after close. `SetThreadDesktop`
|
||||
// only rebinds this calling thread, which is where the injector runs.
|
||||
unsafe {
|
||||
match OpenInputDesktop(
|
||||
DESKTOP_CONTROL_FLAGS(0),
|
||||
false,
|
||||
DESKTOP_ACCESS_FLAGS(GENERIC_ALL),
|
||||
) {
|
||||
Ok(h) => {
|
||||
if SetThreadDesktop(h).is_ok() {
|
||||
if let Some(old) = self.desktop.replace(h) {
|
||||
let _ = CloseDesktop(old);
|
||||
}
|
||||
} else {
|
||||
let _ = CloseDesktop(h);
|
||||
}
|
||||
}
|
||||
Err(_) => { /* not privileged enough for the secure desktop; stay put */ }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Inject with Sunshine's retry-on-failure model: the thread stays bound to whatever desktop it
|
||||
/// last attached to (no per-event `OpenInputDesktop`/`SetThreadDesktop` — two syscalls saved on
|
||||
/// every mouse move), and only when `SendInput` reports a short write (0 = the input desktop
|
||||
/// switched out from under us, e.g. into UAC/lock) do we reattach to the now-current input desktop
|
||||
/// and retry once. This serves both the normal and secure desktops with no steady-state overhead.
|
||||
fn send(&mut self, inputs: &[INPUT]) -> Result<()> {
|
||||
// SAFETY: `inputs` is a live `&[INPUT]` slice that outlives this synchronous `SendInput`
|
||||
// call; `size_of::<INPUT>()` is the exact per-element stride Win32 requires as `cbSize`. The
|
||||
// call only reads the array (one event per element) and returns the count injected.
|
||||
let n = unsafe { SendInput(inputs, size_of::<INPUT>() as i32) };
|
||||
if n as usize == inputs.len() {
|
||||
return Ok(());
|
||||
}
|
||||
// Short write → the input desktop likely changed. Reattach + retry once.
|
||||
self.reattach_input_desktop();
|
||||
// SAFETY: same as the first `SendInput` — `inputs` is the identical live slice outliving the
|
||||
// call and `cbSize == size_of::<INPUT>()`; only re-issued after reattaching the input desktop.
|
||||
let n = unsafe { SendInput(inputs, size_of::<INPUT>() as i32) };
|
||||
if n as usize != inputs.len() {
|
||||
anyhow::bail!(
|
||||
"SendInput injected {n}/{} events (blocked desktop?)",
|
||||
inputs.len()
|
||||
);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for SendInputInjector {
|
||||
fn drop(&mut self) {
|
||||
if let Some(h) = self.desktop.take() {
|
||||
// SAFETY: `h` is the `HDESK` this injector owned (moved out of `self.desktop`);
|
||||
// `CloseDesktop` runs once here in `Drop` on that still-valid handle, with no later use —
|
||||
// no double close.
|
||||
unsafe {
|
||||
let _ = CloseDesktop(h);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl InputInjector for SendInputInjector {
|
||||
fn inject(&mut self, event: &InputEvent) -> Result<()> {
|
||||
// No per-event desktop reattach — `send` reattaches lazily only on a short write (desktop
|
||||
// switch). The injector is bound to the input desktop at open() and follows switches on demand.
|
||||
match event.kind {
|
||||
InputKind::MouseMove => {
|
||||
let mi = MOUSEINPUT {
|
||||
dx: event.x,
|
||||
dy: event.y,
|
||||
mouseData: 0,
|
||||
dwFlags: MOUSEEVENTF_MOVE,
|
||||
time: 0,
|
||||
dwExtraInfo: 0,
|
||||
};
|
||||
self.send(&[mouse(mi)])
|
||||
}
|
||||
InputKind::MouseMoveAbs => {
|
||||
let w = (event.flags >> 16) & 0xffff;
|
||||
let h = event.flags & 0xffff;
|
||||
if w == 0 || h == 0 {
|
||||
return Ok(()); // contract: drop zero extent
|
||||
}
|
||||
let (_vx, _vy, vw, vh) = virtual_desktop_rect();
|
||||
// One virtual output spanning the virtual desktop: map client (0..w,0..h) -> 0..65535.
|
||||
let cx = (event.x.clamp(0, w as i32)) as f64 / w as f64;
|
||||
let cy = (event.y.clamp(0, h as i32)) as f64 / h as f64;
|
||||
let ax = (cx * ABS_MAX).round() as i32;
|
||||
let ay = (cy * ABS_MAX).round() as i32;
|
||||
let _ = (vw, vh); // virtual-desktop rect reserved for multi-output mapping
|
||||
let mi = MOUSEINPUT {
|
||||
dx: ax,
|
||||
dy: ay,
|
||||
mouseData: 0,
|
||||
dwFlags: MOUSEEVENTF_MOVE | MOUSEEVENTF_ABSOLUTE | MOUSEEVENTF_VIRTUALDESK,
|
||||
time: 0,
|
||||
dwExtraInfo: 0,
|
||||
};
|
||||
self.send(&[mouse(mi)])
|
||||
}
|
||||
InputKind::MouseButtonDown | InputKind::MouseButtonUp => {
|
||||
let down = event.kind == InputKind::MouseButtonDown;
|
||||
let (flag, data) = match event.code {
|
||||
1 => (
|
||||
if down {
|
||||
MOUSEEVENTF_LEFTDOWN
|
||||
} else {
|
||||
MOUSEEVENTF_LEFTUP
|
||||
},
|
||||
0u32,
|
||||
),
|
||||
2 => (
|
||||
if down {
|
||||
MOUSEEVENTF_MIDDLEDOWN
|
||||
} else {
|
||||
MOUSEEVENTF_MIDDLEUP
|
||||
},
|
||||
0,
|
||||
),
|
||||
3 => (
|
||||
if down {
|
||||
MOUSEEVENTF_RIGHTDOWN
|
||||
} else {
|
||||
MOUSEEVENTF_RIGHTUP
|
||||
},
|
||||
0,
|
||||
),
|
||||
4 => (
|
||||
if down {
|
||||
MOUSEEVENTF_XDOWN
|
||||
} else {
|
||||
MOUSEEVENTF_XUP
|
||||
},
|
||||
XBUTTON1,
|
||||
),
|
||||
5 => (
|
||||
if down {
|
||||
MOUSEEVENTF_XDOWN
|
||||
} else {
|
||||
MOUSEEVENTF_XUP
|
||||
},
|
||||
XBUTTON2,
|
||||
),
|
||||
_ => return Ok(()),
|
||||
};
|
||||
let mi = MOUSEINPUT {
|
||||
dx: 0,
|
||||
dy: 0,
|
||||
mouseData: data,
|
||||
dwFlags: flag,
|
||||
time: 0,
|
||||
dwExtraInfo: 0,
|
||||
};
|
||||
self.send(&[mouse(mi)])
|
||||
}
|
||||
InputKind::MouseScroll => {
|
||||
// GameStream WHEEL_DELTA(120) units. Windows WHEEL positive=up (matches GameStream —
|
||||
// no flip, unlike Wayland); HWHEEL positive=right (matches). x is 120-scaled already.
|
||||
let horizontal = event.code == 1;
|
||||
let mi = MOUSEINPUT {
|
||||
dx: 0,
|
||||
dy: 0,
|
||||
mouseData: event.x as u32, // signed wheel delta reinterpreted as DWORD
|
||||
dwFlags: if horizontal {
|
||||
MOUSEEVENTF_HWHEEL
|
||||
} else {
|
||||
MOUSEEVENTF_WHEEL
|
||||
},
|
||||
time: 0,
|
||||
dwExtraInfo: 0,
|
||||
};
|
||||
self.send(&[mouse(mi)])
|
||||
}
|
||||
InputKind::KeyDown | InputKind::KeyUp => {
|
||||
let down = event.kind == InputKind::KeyDown;
|
||||
let vk = (event.code & 0xff) as u16;
|
||||
let semantic = (event.flags & crate::KEY_FLAG_SEMANTIC_VK) != 0;
|
||||
// Positional wire VKs (first-party clients) resolve through the fixed US table —
|
||||
// never through a layout (module docs). The table covers only the layout-VARIANT
|
||||
// typing area; everything else (F-row, nav, numpad, modifiers) is layout-invariant
|
||||
// and falls through to `MapVirtualKeyExW` (same result under any layout, and it
|
||||
// keeps the proven extended-bit handling). Semantic VKs (Moonlight) skip the table
|
||||
// and resolve under the FOREGROUND app's layout — the layout the receiving app
|
||||
// will decode our scancode with (Sunshine's model; the service thread's own layout
|
||||
// is not the user's).
|
||||
let table = if semantic {
|
||||
None
|
||||
} else {
|
||||
positional_vk_to_scan(vk)
|
||||
};
|
||||
let (scan, extended) = match table {
|
||||
Some(scan) => (scan, forced_extended(vk)), // typing area: never E0-extended
|
||||
None => {
|
||||
let hkl = if semantic { foreground_hkl() } else { None };
|
||||
// SAFETY: `MapVirtualKeyExW` is a pure value translation (VK → scancode);
|
||||
// all three args are by-value (`u32`, the `MAPVK_VK_TO_VSC_EX` map-type
|
||||
// constant, an optional `HKL` handle used only as a lookup key). It
|
||||
// dereferences no pointer and returns a `u32` — FFI-`unsafe` only.
|
||||
let sc_ex = unsafe { MapVirtualKeyExW(vk as u32, MAPVK_VK_TO_VSC_EX, hkl) };
|
||||
if sc_ex == 0 {
|
||||
return Ok(()); // unmappable -> drop
|
||||
}
|
||||
(
|
||||
(sc_ex & 0xff) as u16,
|
||||
(sc_ex & 0xe000) == 0xe000 || forced_extended(vk),
|
||||
)
|
||||
}
|
||||
};
|
||||
let mut flags = KEYEVENTF_SCANCODE;
|
||||
if extended {
|
||||
flags |= KEYEVENTF_EXTENDEDKEY;
|
||||
}
|
||||
if !down {
|
||||
flags |= KEYEVENTF_KEYUP;
|
||||
}
|
||||
let ki = KEYBDINPUT {
|
||||
wVk: VIRTUAL_KEY(0),
|
||||
wScan: scan,
|
||||
dwFlags: flags,
|
||||
time: 0,
|
||||
dwExtraInfo: 0,
|
||||
};
|
||||
self.send(&[key(ki)])
|
||||
}
|
||||
// Gamepad goes through the XUSB backend. Touch: no SendInput equivalent -> no-op.
|
||||
InputKind::GamepadButton
|
||||
| InputKind::GamepadAxis
|
||||
| InputKind::GamepadState
|
||||
| InputKind::GamepadRemove
|
||||
| InputKind::GamepadArrival
|
||||
| InputKind::TouchDown
|
||||
| InputKind::TouchMove
|
||||
| InputKind::TouchUp => Ok(()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn mouse(mi: MOUSEINPUT) -> INPUT {
|
||||
INPUT {
|
||||
r#type: INPUT_MOUSE,
|
||||
Anonymous: INPUT_0 { mi },
|
||||
}
|
||||
}
|
||||
|
||||
fn key(ki: KEYBDINPUT) -> INPUT {
|
||||
INPUT {
|
||||
r#type: INPUT_KEYBOARD,
|
||||
Anonymous: INPUT_0 { ki },
|
||||
}
|
||||
}
|
||||
|
||||
fn virtual_desktop_rect() -> (i32, i32, i32, i32) {
|
||||
// SAFETY: each `GetSystemMetrics` takes a single by-value `SYSTEM_METRICS_INDEX` constant and
|
||||
// returns an `i32`; it dereferences no pointer and has no side effects — FFI-`unsafe` only.
|
||||
unsafe {
|
||||
(
|
||||
GetSystemMetrics(SM_XVIRTUALSCREEN),
|
||||
GetSystemMetrics(SM_YVIRTUALSCREEN),
|
||||
GetSystemMetrics(SM_CXVIRTUALSCREEN),
|
||||
GetSystemMetrics(SM_CYVIRTUALSCREEN),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// VKs Windows wants flagged extended even when the scancode high bits aren't set: the editing
|
||||
// cluster (Ins/Del/Home/End/PgUp/PgDn = 0x21..0x28, 0x2D, 0x2E), the Win keys (0x5B/0x5C/0x5D),
|
||||
// RCtrl (0xA3), RAlt (0xA5), Pause (0x90). MAPVK_VK_TO_VSC_EX already encodes E0 for most; this is a
|
||||
// thin safety net.
|
||||
fn forced_extended(vk: u16) -> bool {
|
||||
matches!(
|
||||
vk,
|
||||
0x21..=0x28 | 0x2D | 0x2E | 0x5B | 0x5C | 0x5D | 0xA3 | 0xA5 | 0x90
|
||||
)
|
||||
}
|
||||
|
||||
/// US-positional VK → set-1 make scancode for the layout-**variant** typing area (letters, the
|
||||
/// digit row, OEM punctuation, the ISO 102nd key). The exact mirror of the Linux host's
|
||||
/// `crate::vk_to_evdev` — for these keys the evdev code IS the set-1 scancode — and of
|
||||
/// every first-party client's capture table, so the positional round trip is
|
||||
/// identity-by-construction. Layout-invariant keys are deliberately absent (the
|
||||
/// `MapVirtualKeyExW` fallback resolves them identically under any layout, with its proven
|
||||
/// extended-key handling). All listed keys are plain make codes — never E0-extended.
|
||||
fn positional_vk_to_scan(vk: u16) -> Option<u16> {
|
||||
Some(match vk {
|
||||
0x30 => 0x0B, // VK_0
|
||||
0x31..=0x39 => vk - 0x31 + 0x02, // VK_1..VK_9 → 0x02..0x0A
|
||||
0x41 => 0x1E, // A
|
||||
0x42 => 0x30, // B
|
||||
0x43 => 0x2E, // C
|
||||
0x44 => 0x20, // D
|
||||
0x45 => 0x12, // E
|
||||
0x46 => 0x21, // F
|
||||
0x47 => 0x22, // G
|
||||
0x48 => 0x23, // H
|
||||
0x49 => 0x17, // I
|
||||
0x4A => 0x24, // J
|
||||
0x4B => 0x25, // K
|
||||
0x4C => 0x26, // L
|
||||
0x4D => 0x32, // M
|
||||
0x4E => 0x31, // N
|
||||
0x4F => 0x18, // O
|
||||
0x50 => 0x19, // P
|
||||
0x51 => 0x10, // Q
|
||||
0x52 => 0x13, // R
|
||||
0x53 => 0x1F, // S
|
||||
0x54 => 0x14, // T
|
||||
0x55 => 0x16, // U
|
||||
0x56 => 0x2F, // V
|
||||
0x57 => 0x11, // W
|
||||
0x58 => 0x2D, // X
|
||||
0x59 => 0x15, // Y (US position — a QWERTZ host renders it as Z)
|
||||
0x5A => 0x2C, // Z (US position)
|
||||
0xBA => 0x27, // VK_OEM_1 ;: (DE: ö)
|
||||
0xBB => 0x0D, // VK_OEM_PLUS =+
|
||||
0xBC => 0x33, // VK_OEM_COMMA ,<
|
||||
0xBD => 0x0C, // VK_OEM_MINUS -_ (DE: ß)
|
||||
0xBE => 0x34, // VK_OEM_PERIOD .>
|
||||
0xBF => 0x35, // VK_OEM_2 /?
|
||||
0xC0 => 0x29, // VK_OEM_3 `~ (DE: ^)
|
||||
0xDB => 0x1A, // VK_OEM_4 [{ (DE: ü)
|
||||
0xDC => 0x2B, // VK_OEM_5 \|
|
||||
0xDD => 0x1B, // VK_OEM_6 ]}
|
||||
0xDE => 0x28, // VK_OEM_7 '" (DE: ä)
|
||||
0xE2 => 0x56, // VK_OEM_102 <>| (ISO key next to left shift)
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
|
||||
/// The keyboard layout of the thread owning the foreground window — the layout the app receiving
|
||||
/// our injected scancodes will decode them under (Sunshine's model for semantic Moonlight VKs).
|
||||
/// `None` when there is no foreground window (secure desktop, transient) — the caller then falls
|
||||
/// back to the current thread's layout, today's behavior.
|
||||
fn foreground_hkl() -> Option<HKL> {
|
||||
// SAFETY: three read-only queries. `GetForegroundWindow` takes nothing and returns a possibly
|
||||
// null `HWND` (checked). `GetWindowThreadProcessId` reads the window's owning thread id (the
|
||||
// process-id out-param is `None`, allowed). `GetKeyboardLayout` maps a thread id to its input
|
||||
// locale by value. No pointer we own is dereferenced; a stale/foreign `tid` yields a null HKL,
|
||||
// which is filtered.
|
||||
unsafe {
|
||||
let hwnd = GetForegroundWindow();
|
||||
if hwnd.is_invalid() {
|
||||
return None;
|
||||
}
|
||||
let tid = GetWindowThreadProcessId(hwnd, None);
|
||||
let hkl = GetKeyboardLayout(tid);
|
||||
(!hkl.is_invalid()).then_some(hkl)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// The positional table must mirror the Linux host's `vk_to_evdev` exactly — for the typing
|
||||
/// area the evdev code IS the set-1 scancode, so any divergence would make the same wire VK
|
||||
/// land on different physical keys on the two hosts.
|
||||
#[test]
|
||||
fn positional_table_mirrors_linux_vk_to_evdev() {
|
||||
let mut checked = 0;
|
||||
for vk in 0x01..=0xFEu16 {
|
||||
if let Some(scan) = positional_vk_to_scan(vk) {
|
||||
assert_eq!(
|
||||
Some(scan),
|
||||
crate::vk_to_evdev(vk as u8),
|
||||
"vk 0x{vk:02X}: sendinput scancode diverges from vk_to_evdev"
|
||||
);
|
||||
checked += 1;
|
||||
}
|
||||
}
|
||||
assert_eq!(checked, 48, "typing-area coverage changed unexpectedly");
|
||||
}
|
||||
|
||||
/// The German-scramble regression pins: the US-position VKs the first-party clients send for
|
||||
/// the physical Y/Z/ö/ü keys must resolve to those physical positions, not through a layout.
|
||||
#[test]
|
||||
fn positional_pins_for_the_qwertz_scramble() {
|
||||
assert_eq!(positional_vk_to_scan(0x59), Some(0x15)); // VK_Y → US-Y position (QWERTZ: Z key)
|
||||
assert_eq!(positional_vk_to_scan(0x5A), Some(0x2C)); // VK_Z → US-Z position (QWERTZ: Y key)
|
||||
assert_eq!(positional_vk_to_scan(0xBA), Some(0x27)); // VK_OEM_1 → ;: position (QWERTZ: ö)
|
||||
assert_eq!(positional_vk_to_scan(0xDB), Some(0x1A)); // VK_OEM_4 → [{ position (QWERTZ: ü)
|
||||
// Layout-invariant keys stay out of the table (resolved via MapVirtualKeyExW).
|
||||
assert_eq!(positional_vk_to_scan(0x70), None); // VK_F1
|
||||
assert_eq!(positional_vk_to_scan(0x0D), None); // VK_RETURN
|
||||
assert_eq!(positional_vk_to_scan(0xA0), None); // VK_LSHIFT
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,233 @@
|
||||
//! Virtual Steam Deck controller on Windows via the UMDF minidriver — the Windows analogue of
|
||||
//! the Linux UHID Deck ([`super::steam_controller`]'s `SteamProto`), sharing its whole codec
|
||||
//! ([`super::steam_proto`]: the byte-exact `ID_CONTROLLER_DECK_STATE` serializer, the
|
||||
//! `XInput`/rich mappers, the `0xEB` rumble parser).
|
||||
//!
|
||||
//! Transport = the sealed shared-memory channel + a `SwDeviceCreate` devnode (device-type 3),
|
||||
//! like the PS pads — with the promotion lever the N4 spike proved: the synthesized USB
|
||||
//! hardware ids carry **`&MI_02`** (the Deck's wired controller interface), which hidclass
|
||||
//! mirrors into the HID child and hidapi/Steam parse as `bInterfaceNumber`. Steam Input then
|
||||
//! claims the pad exactly like a physical wired Deck (`!! Steam controller device opened`,
|
||||
//! XInput slot reserved — observed live on `.173`), so games get native Deck glyphs +
|
||||
//! trackpads + gyro + back grips through Steam's own remapping.
|
||||
//!
|
||||
//! Feedback: Steam drives Deck rumble (`0xEB`) and trackpad haptic pulses (`0x8F`) via
|
||||
//! SET_FEATURE on the unnumbered report; the driver republishes those into the section's
|
||||
//! output slot (report-id-0 prefixed), where [`parse_steam_output`] reads the exact wire shape
|
||||
//! the Linux path sees. No gamepad-mode entry pulse here — that gate lives in the Linux
|
||||
//! kernel's evdev parser; Steam-on-Windows reads the raw reports directly.
|
||||
|
||||
use super::dualsense_windows::{
|
||||
create_swdevice, SwDeviceProfile, OFF_DEVTYPE, OFF_DRIVER_PROTO, OFF_INPUT, OFF_OUTPUT,
|
||||
OFF_OUT_SEQ, OFF_PAD_INDEX, SHM_MAGIC, SHM_SIZE,
|
||||
};
|
||||
use super::gamepad_raii::PadChannel;
|
||||
use super::steam_proto::{
|
||||
neutral_deck_report, parse_steam_output, serialize_deck_state, SteamState, STEAM_REPORT_LEN,
|
||||
};
|
||||
use crate::uhid_manager::{PadFeedback, PadProto, UhidManager};
|
||||
use anyhow::Result;
|
||||
use punktfunk_core::quic::RichInput;
|
||||
use std::time::Duration;
|
||||
|
||||
/// A single virtual Steam Deck: the `SwDeviceCreate`'d `pf_deck_<index>` devnode plus the sealed
|
||||
/// shared-memory channel. Dropping it removes the devnode and closes both sections.
|
||||
/// `pub`: the type appears as `type Pad` in the `PadProto` impl (a public trait).
|
||||
pub struct DeckWinPad {
|
||||
/// Per-session devnode from SwDeviceCreate, when it succeeds (RAII — `SwDeviceClose` on drop).
|
||||
_sw: Option<super::gamepad_raii::SwDevice>,
|
||||
/// The sealed channel: unnamed DATA section (`PadShm`) + bootstrap mailbox + handle delivery.
|
||||
channel: PadChannel,
|
||||
/// Watches the section's `driver_proto` field and logs attach / never-attached diagnosis.
|
||||
attach: super::gamepad_raii::DriverAttach,
|
||||
seq: u32,
|
||||
last_out_seq: u32,
|
||||
}
|
||||
|
||||
impl DeckWinPad {
|
||||
/// Create the sealed channel, stamp `device_type = Steam Deck` FIRST + the pad index + the
|
||||
/// neutral Deck frame + the magic LAST, then spawn the `pf_deck_<index>` devnode with the
|
||||
/// `MI_02` USB identity Steam's promotion gate requires.
|
||||
fn open(index: u8) -> Result<DeckWinPad> {
|
||||
let boot_name = pf_driver_proto::gamepad::pad_boot_name(index);
|
||||
let mut channel = PadChannel::create(boot_name.clone(), SHM_SIZE)?;
|
||||
let base = channel.data_base();
|
||||
// SAFETY: base points at SHM_SIZE writable bytes; the OFF_* offsets are in range.
|
||||
unsafe {
|
||||
*base.add(OFF_DEVTYPE) = pf_driver_proto::gamepad::DEVTYPE_STEAMDECK;
|
||||
std::ptr::write_unaligned(base.add(OFF_PAD_INDEX) as *mut u32, index as u32);
|
||||
std::ptr::write_unaligned(
|
||||
base.add(OFF_INPUT) as *mut [u8; STEAM_REPORT_LEN],
|
||||
neutral_deck_report(),
|
||||
);
|
||||
std::ptr::write_unaligned(base as *mut u32, SHM_MAGIC);
|
||||
}
|
||||
let inst = format!("pf_deck_{index}");
|
||||
let (hsw, instance_id) = match create_swdevice(&SwDeviceProfile {
|
||||
instance: &inst,
|
||||
container_tag: 0x5046_4453, // "PFDS"
|
||||
container_index: index,
|
||||
hwid: "pf_steamdeck",
|
||||
usb_vid_pid: "VID_28DE&PID_1205",
|
||||
// The wired Deck controller interface — WITHOUT this the HID child carries no MI_
|
||||
// token, hidapi reports interface 0, and Steam never claims the pad (the N4
|
||||
// spike's run-1 failure).
|
||||
usb_mi: Some(2),
|
||||
description: "punktfunk Virtual Steam Deck",
|
||||
}) {
|
||||
Ok((h, i)) => (Some(h), i),
|
||||
Err(e) => {
|
||||
tracing::warn!(error = %format!("{e:#}"), "SwDeviceCreate failed; Steam Deck devnode unavailable");
|
||||
(None, None)
|
||||
}
|
||||
};
|
||||
let _sw = hsw.map(super::gamepad_raii::SwDevice::new);
|
||||
// Bounded eager delivery — the driver must read `device_type = 3` before hidclass asks
|
||||
// it for descriptors, or the pad would enumerate with the default DualSense identity.
|
||||
channel.deliver_eager(Duration::from_millis(1500));
|
||||
Ok(DeckWinPad {
|
||||
_sw,
|
||||
channel,
|
||||
attach: super::gamepad_raii::DriverAttach::new(
|
||||
"pf_steamdeck",
|
||||
"pf_dualsense.inf", // one driver package serves every identity
|
||||
"C:\\Users\\Public\\pfds-driver.log",
|
||||
boot_name,
|
||||
instance_id,
|
||||
),
|
||||
seq: 0,
|
||||
last_out_seq: 0,
|
||||
})
|
||||
}
|
||||
|
||||
/// Serialize `st` into the Deck state frame and publish it to the section's input slot.
|
||||
fn write_state(&mut self, st: &SteamState) {
|
||||
self.seq = self.seq.wrapping_add(1);
|
||||
let mut r = [0u8; STEAM_REPORT_LEN];
|
||||
serialize_deck_state(&mut r, st, self.seq);
|
||||
// SAFETY: base points at SHM_SIZE bytes; input slot is OFF_INPUT..OFF_INPUT+64.
|
||||
unsafe {
|
||||
std::ptr::copy_nonoverlapping(
|
||||
r.as_ptr(),
|
||||
self.channel.data_base().add(OFF_INPUT),
|
||||
r.len(),
|
||||
)
|
||||
};
|
||||
}
|
||||
|
||||
/// Poll the section's output slot; parse a newly-published Steam command (`0xEB` rumble /
|
||||
/// `0x8F` haptic pulse — republished by the driver off SET_FEATURE) into feedback. Also
|
||||
/// ticks the sealed-channel delivery and the driver-attach health watcher.
|
||||
fn service(&mut self) -> Option<(u16, u16)> {
|
||||
self.channel.pump();
|
||||
// SAFETY: base points at SHM_SIZE bytes.
|
||||
let proto = unsafe {
|
||||
std::ptr::read_unaligned(self.channel.data_base().add(OFF_DRIVER_PROTO) as *const u32)
|
||||
};
|
||||
self.attach.observe(proto);
|
||||
// SAFETY: base points at SHM_SIZE bytes.
|
||||
let seq = unsafe {
|
||||
std::ptr::read_unaligned(self.channel.data_base().add(OFF_OUT_SEQ) as *const u32)
|
||||
};
|
||||
if seq == self.last_out_seq {
|
||||
return None;
|
||||
}
|
||||
self.last_out_seq = seq;
|
||||
let mut out = [0u8; 64];
|
||||
// SAFETY: output slot is OFF_OUTPUT..OFF_OUTPUT+64 within the section.
|
||||
unsafe {
|
||||
std::ptr::copy_nonoverlapping(
|
||||
self.channel.data_base().add(OFF_OUTPUT),
|
||||
out.as_mut_ptr(),
|
||||
64,
|
||||
)
|
||||
};
|
||||
parse_steam_output(&out).rumble
|
||||
}
|
||||
}
|
||||
|
||||
/// The Windows-Deck half of the shared stateful manager (see [`PadProto`]): the sealed-channel
|
||||
/// open under the promoted Deck identity, the same [`SteamState`] mappers as the Linux backend,
|
||||
/// and the section feedback poll. Lifecycle (slot table, unplug sweep, heartbeat, rumble dedup)
|
||||
/// lives in [`UhidManager`].
|
||||
#[derive(Default)]
|
||||
pub struct DeckWinProto;
|
||||
|
||||
impl PadProto for DeckWinProto {
|
||||
type Pad = DeckWinPad;
|
||||
type State = SteamState;
|
||||
const LABEL: &'static str = "Steam Deck/Windows";
|
||||
const DEVICE: &'static str = "Steam Deck";
|
||||
const CREATE_HINT: &'static str =
|
||||
" (install/repair: punktfunk-host.exe driver install --gamepad)";
|
||||
|
||||
fn open(&mut self, idx: u8) -> Result<DeckWinPad> {
|
||||
let p = DeckWinPad::open(idx)?;
|
||||
tracing::info!(
|
||||
index = idx,
|
||||
"virtual Steam Deck created (Windows UMDF shm channel, MI_02 promoted identity)"
|
||||
);
|
||||
Ok(p)
|
||||
}
|
||||
|
||||
fn neutral(&self) -> SteamState {
|
||||
SteamState::neutral()
|
||||
}
|
||||
|
||||
/// Merge buttons/sticks/triggers, preserving the rich-plane fields (trackpads + motion +
|
||||
/// pad clicks arrive separately and must survive a button-only frame) — identical to the
|
||||
/// Linux `SteamProto::merge_frame`.
|
||||
fn merge_frame(
|
||||
&self,
|
||||
prev: &SteamState,
|
||||
f: &punktfunk_core::input::GamepadFrame,
|
||||
) -> SteamState {
|
||||
use super::steam_proto::btn;
|
||||
let mut s = SteamState::from_gamepad(
|
||||
f.buttons,
|
||||
f.ls_x,
|
||||
f.ls_y,
|
||||
f.rs_x,
|
||||
f.rs_y,
|
||||
f.left_trigger,
|
||||
f.right_trigger,
|
||||
);
|
||||
s.rpad_x = prev.rpad_x;
|
||||
s.rpad_y = prev.rpad_y;
|
||||
s.lpad_x = prev.lpad_x;
|
||||
s.lpad_y = prev.lpad_y;
|
||||
s.gyro = prev.gyro;
|
||||
s.accel = prev.accel;
|
||||
s.buttons |= prev.buttons & (btn::RPAD_TOUCH | btn::LPAD_TOUCH);
|
||||
s.lpad_click = prev.lpad_click;
|
||||
s.rpad_click = prev.rpad_click;
|
||||
s
|
||||
}
|
||||
|
||||
fn apply_rich(&self, st: &mut SteamState, rich: RichInput) {
|
||||
st.apply_rich(rich);
|
||||
}
|
||||
|
||||
fn write_state(&self, pad: &mut DeckWinPad, st: &SteamState) {
|
||||
pad.write_state(st);
|
||||
}
|
||||
|
||||
/// Poll the section for Steam's feedback: motor rumble on the universal 0xCA plane. The
|
||||
/// Deck has no rich host→client feedback plane (no lightbar / adaptive triggers), so
|
||||
/// `hidout` stays empty — parity with the Linux backend.
|
||||
fn service(&self, pad: &mut DeckWinPad, _idx: u8) -> PadFeedback {
|
||||
// The Deck poll returns `Some` exactly when a fresh output report landed (a seq bump), so
|
||||
// its presence is the game-activity signal, even when the rumble level is unchanged.
|
||||
let rumble = pad.service();
|
||||
PadFeedback {
|
||||
rumble,
|
||||
hidout: Vec::new(),
|
||||
game_drove: Some(rumble.is_some()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// All virtual Steam Deck pads of a Windows session — the analogue of the Linux
|
||||
/// `SteamControllerManager`, with the same method surface (via the shared [`UhidManager`]) as
|
||||
/// the other Windows pad managers.
|
||||
pub type SteamDeckWindowsManager = UhidManager<DeckWinProto>;
|
||||
Reference in New Issue
Block a user