diff --git a/Cargo.lock b/Cargo.lock index 1ec1a380..04dcbda0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2893,6 +2893,7 @@ dependencies = [ "ureq", "wasapi", "windows 0.62.2 (git+https://github.com/microsoft/windows-rs?rev=acb5a1a7441033d9312b16842af02eb0c2b403dc)", + "winreg", ] [[package]] diff --git a/clients/android/native/src/feedback.rs b/clients/android/native/src/feedback.rs index 6833666e..4e4225be 100644 --- a/clients/android/native/src/feedback.rs +++ b/clients/android/native/src/feedback.rs @@ -156,6 +156,11 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeNextHidout( out[3..n].copy_from_slice(&data); n } + HidOutput::AudioCtl { .. } => { + // DS5 pad-audio routing/volumes — no Android replay path yet (the 0xD1 sample + // plane isn't rendered here either); drop it like TrackpadHaptic. + return -1; + } }; n as jint }) diff --git a/clients/session/src/main.rs b/clients/session/src/main.rs index 5926534b..1f90c24b 100644 --- a/clients/session/src/main.rs +++ b/clients/session/src/main.rs @@ -188,6 +188,12 @@ mod session_main { if !settings.forward_pad.is_empty() { gamepad.set_pinned(Some(settings.forward_pad.clone())); } + // Pad-audio prefs to OUR gamepad service (same reasoning as the pin above): tier-A + // slots declare their render caps at open time, which happens on attach — after this. + gamepad.set_pad_audio_prefs( + settings.pad_haptics, + pf_client_core::pad_audio::speaker_active(&settings.pad_speaker), + ); let mode = Mode { width: if settings.width == 0 { native.width @@ -291,6 +297,11 @@ mod session_main { cursor_forward: settings.mouse_mode() == trust::MouseMode::Desktop, mic_enabled: settings.mic_enabled, echo_cancel: settings.echo_cancel, + // Pad audio (0xD1): the DualSense haptics/speaker render settings. The gamepad + // service learns the same prefs below so tier-A slots declare their render caps + // at open; the session pump gates CLIENT_CAP_PAD_AUDIO + the renderer on these. + pad_haptics: settings.pad_haptics, + pad_speaker: settings.pad_speaker.clone(), clipboard, // The Settings preference (auto → VAAPI where it exists; the presenter // demotes to software on boxes whose Vulkan can't import the dmabufs). diff --git a/crates/pf-client-core/Cargo.toml b/crates/pf-client-core/Cargo.toml index 3ba1fa7e..40212ef7 100644 --- a/crates/pf-client-core/Cargo.toml +++ b/crates/pf-client-core/Cargo.toml @@ -57,6 +57,10 @@ sdl3 = { version = "0.18", features = ["hidapi"] } [target.'cfg(windows)'.dependencies] wasapi = "0.23" +# Pad-audio correlation (pad_audio.rs): the HID devnode's ContainerID and a render endpoint's +# stamped PKEY_Device_ContainerId both live in the registry — read-only, which sidesteps COM +# property stores entirely (the same version the host pins). +winreg = "0.56" sdl3 = { version = "0.18", features = ["hidapi", "build-from-source"] } # D3D11VA decode (video_d3d11.rs): device/adapter selection, DXVA probes, and the shared # NT-handle hand-off ring. Same pinned rev as clients/windows so the workspace builds ONE diff --git a/crates/pf-client-core/src/gamepad.rs b/crates/pf-client-core/src/gamepad.rs index 1b26b84a..a2f54faa 100644 --- a/crates/pf-client-core/src/gamepad.rs +++ b/crates/pf-client-core/src/gamepad.rs @@ -336,6 +336,9 @@ enum Ctl { Detach, Pin(Option), KindOverride(GamepadPref), + /// Which pad-audio streams the session's settings want rendered (bit0 = haptics, bit1 = + /// speaker) — the settings half of the per-pad tier-A capability declared at slot open. + PadAudioPrefs(u8), MenuMode(bool), MenuRumble(MenuPulse), } @@ -482,6 +485,18 @@ impl GamepadService { let _ = self.ctl.send(Ctl::KindOverride(pref)); } + /// Declare which pad-audio streams this session's settings want rendered (`haptics` = + /// [`Settings::pad_haptics`](crate::trust::Settings::pad_haptics), `speaker` = + /// `pad_speaker == "pad"` via [`crate::pad_audio::speaker_active`]). Drives the per-pad + /// tier-A capability bits declared to the core at slot open — a WIRED DualSense/Edge + /// declares exactly these; every other pad declares 0. Call before [`Self::attach`], + /// like [`Self::set_kind_override`]: slots declare at open time. Defaults to "nothing" + /// for an embedder that never calls it, keeping the wire bytes exactly as before. + pub fn set_pad_audio_prefs(&self, haptics: bool, speaker: bool) { + let bits = (haptics as u8) | ((speaker as u8) << 1); + let _ = self.ctl.send(Ctl::PadAudioPrefs(bits)); + } + pub fn attach(&self, connector: Arc) { let _ = self.ctl.send(Ctl::Attach(connector)); } @@ -611,6 +626,11 @@ fn axis_value(axis: sdl3::gamepad::Axis, v: i16) -> (u32, i32) { struct Ds5Feedback; impl Ds5Feedback { + /// The audio-control region (`ucHeadphoneVolume`…`ucAudioMuteBits`, struct offsets 4..=9). + /// The 47-byte effect struct is the USB report 0x02 minus its report-id byte, so struct + /// offset 4 = report byte 5 (the same −1 shift that maps report offset 11 to + /// [`Self::RIGHT_TRIGGER`] = 10 in [`trigger_packet`](Self::trigger_packet)). + const AUDIO: usize = 4; const RIGHT_TRIGGER: usize = 10; const LEFT_TRIGGER: usize = 21; const PAD_LIGHTS: usize = 43; @@ -644,6 +664,29 @@ impl Ds5Feedback { p[Self::PAD_LIGHTS] = bits & 0x1F; p } + + /// The one-shot tier-A activation packet — the SDL disable-bit trap undone. `p[0]` + /// (`ucEnableBits1`) bit0 = "enable rumble emulation" and bit1 = "disable audio haptics" + /// (SDL_hidapi_ps5.c); SDL sets BOTH whenever its rumble path runs, which mutes the very + /// voice coils the 0xD1 haptics stream drives. Per SDL's own comment — "Leaving emulated + /// rumble bits off will restore audio haptics" — a packet with those bits CLEARED (and no + /// other valid flag, so nothing else is touched) puts the pad back on audio haptics. + fn audio_haptics_packet() -> [u8; 47] { + [0u8; 47] + } + + /// Fold a host [`HidOutput::AudioCtl`] into an effects packet: `raw` is DS5 output report + /// `0x02` bytes 5..=10 verbatim → struct offsets 4..=9 ([`Self::AUDIO`] — headphone/ + /// speaker/mic volumes + routing), and `p[0]` re-asserts the report's audio-valid flags + /// (`flags` bits1..4 = report `flag0` bits 4..7). `flags` bit0 (haptics-select, `flag0` + /// bit1 = SDL's "disable audio haptics") is deliberately NOT replayed: bits 0/1 stay + /// clear so the pad's audio haptics stay live (see [`audio_haptics_packet`]). + fn audio_ctl_packet(flags: u8, raw: &[u8; 6]) -> [u8; 47] { + let mut p = [0u8; 47]; + p[0] = (flags & 0x1E) << 3; + p[Self::AUDIO..Self::AUDIO + 6].copy_from_slice(raw); + p + } } /// One forwarded controller during an attached session: the open SDL handle, its stable wire @@ -677,6 +720,14 @@ struct Slot { /// close lift a click held across detach/unplug. held_clicks: [bool; 2], last_accel: [i16; 3], + /// Pad-audio render capabilities declared for this slot (bit0 = haptics, bit1 = speaker + /// — the [`NativeClient::set_pad_audio_caps`] bits). Nonzero only for a tier-A pad (a + /// WIRED DualSense/Edge, see [`crate::pad_audio::is_tier_a_ds5`]) under matching + /// settings; bit0 set additionally suppresses wire rumble for this slot (the SDL + /// disable-bit trap — see [`Worker::render_feedback`]). + audio_caps: u8, + /// The wire-rumble-suppressed notice fired for this slot (log once, not per command). + rumble_suppressed_logged: bool, } impl Slot { @@ -692,6 +743,8 @@ impl Slot { surface_last: [(0, 0, false); 2], held_clicks: [false; 2], last_accel: [0; 3], + audio_caps: 0, + rumble_suppressed_logged: false, } } @@ -725,6 +778,10 @@ struct Worker { /// `Auto` = per-pad detection. Applied at slot open to the kind DECLARED to the host, never /// to [`Slot::pref`] — the local feedback paths must keep reading the physical pad. kind_override: GamepadPref, + /// Pad-audio streams the session's settings want rendered (bit0 = haptics, bit1 = + /// speaker — [`GamepadService::set_pad_audio_prefs`]). `0` (the default) until an embedder + /// declares some: tier-A detection then never runs and every arrival stays caps-less. + pad_audio_prefs: u8, attached: Option>, /// Raises the UI escape signal; the escape chord fires it once per press. escape_tx: async_channel::Sender<()>, @@ -925,11 +982,18 @@ impl Worker { Ok(pad) => { let mut slot = Slot::new(id, index, pref, pad); Self::set_slot_sensors(&mut slot, true); + slot.audio_caps = self.pad_audio_caps_for(id, &slot.pad); // Declare this pad's kind BEFORE any of its input, so the host builds a matching // virtual device (mixed types — pad 0 a DualSense, pad 1 an Xbox pad). The core // re-sends it a few times against datagram loss; an older host ignores it and // uses the session-default kind. if let Some(c) = &self.attached { + // Pad-audio render caps go in FIRST — the core ORs them into this (and + // every re-sent) arrival's flags bits 8/9 toward a capable host. ALWAYS + // set (0 for non-tier-A): wire indices are reused within a connection, so + // a tier-A slot that closes must not leave its bits behind for the next + // pad on the same index (the set_rumble_quirks rule). + c.set_pad_audio_caps(index, slot.audio_caps); send( c, InputKind::GamepadArrival, @@ -952,6 +1016,27 @@ impl Worker { }; c.set_rumble_quirks(index as u16, quirks); } + if slot.audio_caps != 0 { + if slot.audio_caps & 0x01 != 0 { + // Tier-A haptics activation: the SDL disable-bit trap. SDL's DS5 + // driver sets ucEnableBits1 0x01|0x02 ("enable rumble emulation" + + // "disable audio haptics") whenever its rumble path runs — which + // would MUTE the voice coils the 0xD1 stream drives. One effects + // packet with those bits CLEARED puts the pad back on audio haptics + // ("Leaving emulated rumble bits off will restore audio haptics" — + // SDL_hidapi_ps5.c); wire rumble for this slot is suppressed in + // render_feedback so SDL never re-arms them. + let _ = slot.pad.send_effect(&Ds5Feedback::audio_haptics_packet()); + } + // Hand the pad to the session's renderer worker. Windows correlation + // needs the HID interface path; Linux matches the sink by signature. + crate::pad_audio::register_tier_a(index, slot.pad.path()); + tracing::info!( + index, + caps = slot.audio_caps, + "tier-A DualSense: pad-audio render caps declared" + ); + } tracing::info!( id, index, @@ -965,6 +1050,35 @@ impl Worker { } } + /// This pad's pad-audio render capabilities (the bits [`NativeClient::set_pad_audio_caps`] + /// takes): the settings prefs for a tier-A pad — a physical DualSense/Edge (by VID:PID, + /// never the DECLARED kind: the stream renders on the controller in the user's hands) on + /// a WIRED connection — and `0` for everything else (tier B/C are out of scope). Wired + /// comes from `SDL_GetGamepadConnectionState`; when SDL answers Unknown, the pad's 4-ch + /// audio sibling existing is the fallback signal (Bluetooth exposes no audio device). + fn pad_audio_caps_for(&self, id: u32, pad: &sdl3::gamepad::Gamepad) -> u8 { + if self.pad_audio_prefs == 0 { + return 0; // nothing wanted — skip the (possibly probing) wired check entirely + } + let jid = sdl3::sys::joystick::SDL_JoystickID(id); + let vid = self.subsystem.vendor_for_id(jid).unwrap_or(0); + let pid = self.subsystem.product_for_id(jid).unwrap_or(0); + if !crate::pad_audio::is_tier_a_ds5(vid, pid, true) { + return 0; // not a DualSense/Edge — no wired check needed + } + use sdl3::joystick::ConnectionState; + let wired = match pad.connection_state() { + Ok(ConnectionState::Wired) => true, + Ok(ConnectionState::Wireless) => false, + _ => crate::pad_audio::wired_audio_sibling(pad.path().as_deref()), + }; + if crate::pad_audio::is_tier_a_ds5(vid, pid, wired) { + self.pad_audio_prefs + } else { + 0 + } + } + /// Flush a slot's held wire state (so nothing sticks down host-side) and drop it — closing /// the SDL handle. The flush only emits wire events, so it is safe even when the device is /// already gone (unplug). @@ -981,6 +1095,11 @@ impl Worker { send(&c, InputKind::GamepadRemove, 0, 0, self.slots[i].index); } let slot = self.slots.remove(i); + if slot.audio_caps != 0 { + // Take the pad back from the pad-audio renderer (its device-gone path then + // re-correlates — and finds nothing until a tier-A pad registers again). + crate::pad_audio::unregister_tier_a(slot.index); + } tracing::info!( id = slot.id, index = slot.index, @@ -1269,6 +1388,7 @@ impl Worker { self.refresh_active(); } Ok(Ctl::KindOverride(pref)) => self.kind_override = pref, + Ok(Ctl::PadAudioPrefs(bits)) => self.pad_audio_prefs = bits & 0x03, Ok(Ctl::MenuMode(on)) => { self.menu_mode = on; if on { @@ -1540,6 +1660,20 @@ impl Worker { // first; the physical silence backstop is in `close_slot_at`). while let Ok(cmd) = connector.next_rumble_command(Duration::ZERO) { if let Some(slot) = self.slots.iter_mut().find(|s| s.index as u16 == cmd.pad) { + // The SDL disable-bit trap: ANY SDL rumble write sets ucEnableBits1 + // 0x01|0x02, muting the very voice coils the 0xD1 haptics stream drives — + // so a slot with tier-A haptics active never issues wire rumble (the stream + // carries the feedback; the game's rumble is in its haptics mix). + if slot.audio_caps & 0x01 != 0 { + if !slot.rumble_suppressed_logged { + slot.rumble_suppressed_logged = true; + tracing::info!( + pad = slot.index, + "wire rumble suppressed — the pad-audio haptics stream carries feedback" + ); + } + continue; + } Self::issue_rumble(slot, cmd.low, cmd.high, cmd.backstop_ms); } } @@ -1572,6 +1706,17 @@ impl Worker { .pad .send_effect(&Ds5Feedback::trigger_packet(which, effect)); } + // The audio-control region of a DS5 output report a game wrote host-side + // (volumes + routing; the SAMPLES ride 0xD1) — folded back into the physical + // pad's effects packet, but only where a tier-A renderer is actually live + // (`audio_caps`): replaying speaker volumes at a pad whose audio device + // nothing streams to would just mute/blast a future session's start state. + // Non-tier-A pads keep dropping it (the pre-pad-audio behaviour). + HidOutput::AudioCtl { flags, raw, .. } if is_ds && slot.audio_caps != 0 => { + let _ = slot + .pad + .send_effect(&Ds5Feedback::audio_ctl_packet(flags, &raw)); + } _ => {} } } @@ -1586,6 +1731,8 @@ fn hidout_pad(h: &HidOutput) -> u8 { | HidOutput::Trigger { pad, .. } | HidOutput::TrackpadHaptic { pad, .. } | HidOutput::HidRaw { pad, .. } => *pad, + // AudioCtl's pad is u16 on the wire; the index space is 0..MAX_PADS end to end. + HidOutput::AudioCtl { pad, .. } => *pad as u8, } } @@ -1609,6 +1756,7 @@ impl Worker { order: Vec::new(), pinned: None, kind_override: GamepadPref::Auto, + pad_audio_prefs: 0, attached: None, escape_tx, disconnect_tx, @@ -1944,5 +2092,43 @@ mod slot_tests { }), 6 ); + // AudioCtl's wire pad is u16; the index space is 0..MAX_PADS end to end. + assert_eq!( + hidout_pad(&HidOutput::AudioCtl { + pad: 7, + flags: 0, + raw: [0; 6] + }), + 7 + ); + } + + /// The AudioCtl fold: the 6 raw bytes (DS5 report 0x02 bytes 5..=10) land at effect-struct + /// offsets 4..=9, the report's audio-valid flags (AudioCtl.flags bits1..4) come back as + /// p[0] bits 4..7, and the rumble-emulation / disable-audio-haptics bits (p[0] bits 0/1) + /// stay CLEAR — setting either would mute the voice coils the 0xD1 stream drives. + #[test] + fn audio_ctl_folds_report_bytes_into_effect_offsets() { + let raw = [0x50, 0x60, 0x70, 0x05, 0x11, 0x22]; + // flags 0b1_0111: haptics-select (bit0) + audio-valid bits 1/2/4 of the condensed form. + let p = Ds5Feedback::audio_ctl_packet(0b1_0111, &raw); + assert_eq!(&p[4..10], &raw, "report bytes 5..=10 → struct 4..=9"); + // bits1..4 (0b1011) → flag0 bits 4..7. + assert_eq!(p[0], 0b1011_0000); + assert_eq!( + p[0] & 0x03, + 0, + "haptics-select must NOT replay into p[0] bits 0/1" + ); + // Nothing else is touched: no trigger/LED enable bits, no stray bytes. + assert!(p[1..4].iter().all(|&b| b == 0)); + assert!(p[10..].iter().all(|&b| b == 0)); + // No audio-valid flags condenses to no enable bits (raw still carried verbatim). + let p = Ds5Feedback::audio_ctl_packet(0b0_0001, &raw); + assert_eq!(p[0], 0); + assert_eq!(&p[4..10], &raw); + // The tier-A activation packet is the all-clear: every enable bit off — per + // SDL_hidapi_ps5.c, leaving the emulated-rumble bits off restores audio haptics. + assert_eq!(Ds5Feedback::audio_haptics_packet(), [0u8; 47]); } } diff --git a/crates/pf-client-core/src/lib.rs b/crates/pf-client-core/src/lib.rs index 6a147114..40e575c2 100644 --- a/crates/pf-client-core/src/lib.rs +++ b/crates/pf-client-core/src/lib.rs @@ -47,6 +47,11 @@ pub mod os; // Client settings profiles: the override catalog + the one connect-time resolver // (design/client-settings-profiles.md §4). Sits beside `trust`, which owns the host records // the bindings live on. +// Pad audio (the 0xD1 plane): DualSense voice-coil haptics + speaker rendered on the wired +// physical pad's own 4-ch audio device — correlation, the per-session renderer worker, and +// the tier-A pad registry the gamepad worker feeds it through. +#[cfg(any(target_os = "linux", windows))] +pub mod pad_audio; #[cfg(any(target_os = "linux", windows))] pub mod profiles; #[cfg(any(target_os = "linux", windows))] diff --git a/crates/pf-client-core/src/pad_audio.rs b/crates/pf-client-core/src/pad_audio.rs new file mode 100644 index 00000000..a5222b04 --- /dev/null +++ b/crates/pf-client-core/src/pad_audio.rs @@ -0,0 +1,1220 @@ +//! Pad audio (the 0xD1 plane): render the host's per-gamepad DualSense streams — voice-coil +//! haptics (kind 0, the BACK channel pair) and the built-in speaker (kind 1, the FRONT pair) — +//! into a USB-connected physical DualSense's own 4-channel audio device. +//! +//! Tier A only (v1): a WIRED DualSense / DualSense Edge — Bluetooth exposes no audio device, so +//! wired is what makes the 4-ch sibling exist at all. The gamepad worker detects tier A at slot +//! open ([`crate::gamepad`]) and declares the pad's render capabilities to the core +//! ([`punktfunk_core::client::NativeClient::set_pad_audio_caps`]), which rides them on the +//! arrival (flags bits 8/9) toward a `HOST_CAP_PAD_AUDIO` host; the host then emits 0xD1 for +//! exactly those pads. This module owns everything after that: +//! +//! - **Correlation** pad ↔ audio device. Windows: the SDL HID interface path → the devnode's +//! `ContainerID` (registry) → the active eRender endpoint whose stamped +//! `PKEY_Device_ContainerId` matches AND whose device format has 4 channels. Linux: the +//! PipeWire sink whose name/description carries the DualSense signature (one physical DS5 in +//! v1 — first match wins). +//! - **The renderer worker** ([`spawn`]): drains [`NativeClient::next_pad_audio`] (the plane's +//! single consumer), Opus-decodes per (pad, kind) with seq-gap PLC (the session audio path's +//! [`AudioGapTracker`] discipline), interleaves both pairs into one 4-ch stream +//! (speaker → channels 0/1, haptics → 2/3 — the DS5 device's own layout), and plays it on the +//! correlated device: WASAPI shared/event-driven on Windows, a PipeWire playback stream with +//! `target.object` on Linux — both with the session players' 3-quantum prime/cap/re-prime +//! ring policy at a SMALLER floor (haptics are felt latency). The output device is opened +//! lazily on the first arriving frame and re-correlated with backoff when it goes away. +//! +//! The `Settings` side: `pad_haptics` (bool) and `pad_speaker` (`"pad"`/`"mix"`/`"off"`) gate +//! the `CLIENT_CAP_PAD_AUDIO` advertisement and the per-pad capability bits; `"mix"` (fold the +//! speaker into the main stream audio) is a declared TODO and renders as `"off"`. + +use punktfunk_core::audio::AudioGapTracker; +use punktfunk_core::client::NativeClient; +use punktfunk_core::quic::{PAD_AUDIO_KIND_HAPTICS, PAD_AUDIO_KIND_SPEAKER}; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::{Arc, Mutex}; +use std::time::{Duration, Instant}; + +/// The render layout: 4 interleaved f32 channels — speaker FL/FR on 0/1, haptics (rear pair +/// on the wire layout, the voice coils on the device) on 2/3. +const PAD_CHANNELS: usize = 4; + +/// Mixer depth bound (frames @48 kHz — 100 ms). Only guards a wedged/absent output; the live +/// latency bound is the platform ring policy's cap. +const MAX_BUFFER_FRAMES: usize = 4800; + +/// Device (re)correlation backoff bounds: a missing DualSense audio device is polled at +/// [`RETRY_MIN`] doubling to [`RETRY_MAX`] — correlation enumerates the audio graph, so it must +/// not run per frame. +const RETRY_MIN: Duration = Duration::from_secs(1); +const RETRY_MAX: Duration = Duration::from_secs(8); + +// ---- settings vocabulary -------------------------------------------------------------------- + +/// Whether the `pad_speaker` setting asks for a renderer: `"pad"` = the physical pad's speaker +/// (the only implemented target). `"mix"` — fold the speaker stream into the main session +/// audio — is a declared TODO: it logs once and renders as `"off"` so the setting name can ship +/// before the mixer leg does. Anything else (including `"off"`) = no renderer. +pub fn speaker_active(mode: &str) -> bool { + match mode { + "pad" => true, + "mix" => { + static ONCE: std::sync::Once = std::sync::Once::new(); + ONCE.call_once(|| { + tracing::info!( + "pad_speaker=\"mix\" is not implemented yet (TODO: fold the DS5 speaker \ + stream into the main session audio) — treating it as \"off\"" + ); + }); + false + } + _ => false, + } +} + +// ---- tier-A detection ----------------------------------------------------------------------- + +/// Tier A = a physical DualSense (`054C:0CE6`) or DualSense Edge (`054C:0DF2`) on a WIRED +/// connection — only USB exposes the pad's 4-ch audio device (Bluetooth pads are tier B/C, +/// out of scope here). Pure so the policy is testable; the wired signal comes from +/// `SDL_GetGamepadConnectionState`, falling back to the audio-sibling probe +/// ([`wired_audio_sibling`]) when SDL answers Unknown. +pub(crate) fn is_tier_a_ds5(vid: u16, pid: u16, wired: bool) -> bool { + vid == 0x054C && matches!(pid, 0x0CE6 | 0x0DF2) && wired +} + +/// The wired fallback when SDL cannot say (`ConnectionState::Unknown`): does the pad's 4-ch +/// audio sibling exist? A Bluetooth DS5 exposes no audio device, so a resolvable device IS the +/// wired signal. Linux ignores the HID path (the sink match is signature-based); Windows +/// resolves the path's container against the render endpoints. +#[cfg(target_os = "linux")] +pub(crate) fn wired_audio_sibling(_hid_path: Option<&str>) -> bool { + crate::audio::devices() + .map(|(sinks, _)| sinks.iter().any(|d| is_ds5_sink(&d.name, &d.description))) + .unwrap_or(false) +} + +#[cfg(windows)] +pub(crate) fn wired_audio_sibling(hid_path: Option<&str>) -> bool { + hid_path.is_some_and(|p| correlate_pad_endpoint(p).is_ok()) +} + +// ---- tier-A pad registry (gamepad worker → renderer worker) --------------------------------- + +/// One tier-A pad the gamepad worker holds open: its wire index and (Windows correlation) the +/// SDL HID device path. Registered at slot open, dropped at slot close. +struct TierAPad { + index: u8, + /// Read by the Windows correlation only — Linux matches the sink by signature. + #[cfg_attr(not(windows), allow(dead_code))] + hid_path: Option, +} + +/// The tier-A pads currently open, shared between the gamepad worker (writer, at slot +/// open/close) and the session renderer worker (reader, at device correlation). A process-wide +/// static because the two workers meet nowhere else: the gamepad service is app-lifetime, the +/// renderer is per-session. +static TIER_A_PADS: Mutex> = Mutex::new(Vec::new()); + +/// Gamepad worker: a tier-A slot opened on wire index `index` (idempotent per index). +pub(crate) fn register_tier_a(index: u8, hid_path: Option) { + let mut pads = TIER_A_PADS.lock().unwrap(); + pads.retain(|p| p.index != index); + pads.push(TierAPad { index, hid_path }); +} + +/// Gamepad worker: the tier-A slot on `index` closed (no-op for non-tier-A indices). +pub(crate) fn unregister_tier_a(index: u8) { + TIER_A_PADS.lock().unwrap().retain(|p| p.index != index); +} + +/// The first registered tier-A pad's HID path (v1 renders one physical DS5). +#[cfg(windows)] +fn first_tier_a_hid_path() -> Option { + TIER_A_PADS + .lock() + .unwrap() + .first() + .and_then(|p| p.hid_path.clone()) +} + +// ---- correlation: Linux (PipeWire sink signature) ------------------------------------------- + +/// Does this PipeWire sink look like a wired DualSense's audio device? The ALSA node name +/// carries the USB vendor string (`Sony_Interactive_Entertainment`), descriptions carry the +/// product (`DualSense`), and the kernel's fallback device name is `Wireless Controller` — +/// any of the three identifies the pad's sink. +#[cfg(any(target_os = "linux", test))] +pub(crate) fn is_ds5_sink(name: &str, description: &str) -> bool { + let hit = |s: &str| { + s.contains("Sony_Interactive_Entertainment") + || s.contains("DualSense") + || s.starts_with("Wireless Controller") + }; + hit(name) || hit(description) +} + +/// First-match pick over an enumerated sink list (v1 supports ONE physical DS5; more than one +/// match logs once and keeps the first). +#[cfg(target_os = "linux")] +fn find_ds5_sink(sinks: &[crate::audio::AudioDevice]) -> Option { + let mut matches = sinks + .iter() + .filter(|d| is_ds5_sink(&d.name, &d.description)); + let first = matches.next()?.clone(); + if matches.next().is_some() { + static ONCE: std::sync::Once = std::sync::Once::new(); + ONCE.call_once(|| { + tracing::info!( + sink = %first.name, + "multiple DualSense audio sinks — v1 renders one physical DS5, using the first" + ); + }); + } + Some(first) +} + +// ---- correlation: Windows (HID container → render endpoint) --------------------------------- + +/// One enumerated render endpoint reduced to what the matcher needs. Pure data so the container +/// match is unit-testable off-box. +#[cfg(any(windows, test))] +pub(crate) struct EndpointCandidate { + /// The `IMMDevice` endpoint id (`{0.0.0.00000000}.{…}`) — what WASAPI's device targeting + /// takes. + pub(crate) id: String, + /// The endpoint's `PKEY_Device_ContainerId` as a braced lowercase GUID string; `None` when + /// unreadable. + pub(crate) container: Option, + /// Channel count of the endpoint's device format. + pub(crate) channels: u16, +} + +/// The endpoint belonging to the pad: container match AND a 4-channel device format (the DS5 +/// audio function is the only 4-ch endpoint in its container — the mix-format gate keeps a +/// hypothetical sibling stereo endpoint from winning). +#[cfg(any(windows, test))] +pub(crate) fn pick_pad_endpoint<'a>( + endpoints: &'a [EndpointCandidate], + container: &str, +) -> Option<&'a EndpointCandidate> { + endpoints.iter().find(|e| { + e.channels == 4 + && e.container + .as_deref() + .is_some_and(|c| c.eq_ignore_ascii_case(container)) + }) +} + +/// A device INSTANCE id from a device INTERFACE path: strip the `\\?\` (or `\\.\`) prefix, +/// the `#` separators become `\`, and the trailing `{interface-class-guid}` segment drops — +/// `\\?\HID#VID_054C&PID_0CE6#8&2de&0&0000#{4d1e55b2-…}` → `HID\VID_054C&PID_0CE6\8&2de&0&0000`. +/// That instance id is the devnode's key under `HKLM\SYSTEM\CurrentControlSet\Enum`, where its +/// `ContainerID` lives. +#[cfg(any(windows, test))] +pub(crate) fn hid_instance_from_interface_path(path: &str) -> Option { + let p = path + .strip_prefix(r"\\?\") + .or_else(|| path.strip_prefix(r"\\.\")) + .unwrap_or(path); + let mut segs: Vec<&str> = p.split('#').collect(); + if let Some(last) = segs.last() { + if last.starts_with('{') && last.ends_with('}') { + segs.pop(); + } + } + if segs.len() != 3 || segs.iter().any(|s| s.is_empty()) { + return None; + } + Some(segs.join("\\")) +} + +/// Parse a serialized `VT_CLSID` PROPVARIANT registry blob (the on-disk shape of the MMDevices +/// property store: 8-byte header `[vt, 0, 0, 0, 1, 0, 0, 0]`, then the GUID in registry byte +/// order) into a braced lowercase GUID string. `None` for anything else. +#[cfg(any(windows, test))] +pub(crate) fn container_guid_from_blob(bytes: &[u8]) -> Option { + const VT_CLSID: u8 = 0x48; + if bytes.len() < 24 || bytes[0] != VT_CLSID { + return None; + } + let g = &bytes[8..24]; + let d1 = u32::from_le_bytes([g[0], g[1], g[2], g[3]]); + let d2 = u16::from_le_bytes([g[4], g[5]]); + let d3 = u16::from_le_bytes([g[6], g[7]]); + Some(format!( + "{{{:08x}-{:04x}-{:04x}-{:02x}{:02x}-{:02x}{:02x}{:02x}{:02x}{:02x}{:02x}}}", + d1, d2, d3, g[8], g[9], g[10], g[11], g[12], g[13], g[14], g[15] + )) +} + +/// Resolve the SDL HID interface path to the matching 4-ch render endpoint id — the Windows +/// correlation chain: interface path → instance id → devnode `ContainerID` (registry) → the +/// active eRender endpoint whose stamped `PKEY_Device_ContainerId` matches with a 4-channel +/// device format. Registry-only for the property reads (the MMDevices ACL denies writes, never +/// reads); the endpoint enumeration runs on its own MTA thread like [`crate::audio::devices`]. +#[cfg(windows)] +pub(crate) fn correlate_pad_endpoint(hid_path: &str) -> anyhow::Result { + use anyhow::Context; + let instance = hid_instance_from_interface_path(hid_path) + .with_context(|| format!("unrecognised HID interface path shape: {hid_path}"))?; + let container = hid_container_id(&instance) + .with_context(|| format!("no ContainerID on devnode {instance}"))?; + let endpoints = render_endpoints()?; + pick_pad_endpoint(&endpoints, &container) + .map(|e| e.id.clone()) + .with_context(|| { + format!( + "no active 4-ch render endpoint in container {container} \ + ({} endpoints inspected)", + endpoints.len() + ) + }) +} + +/// The devnode's `ContainerID` value (a braced GUID string) under +/// `HKLM\SYSTEM\CurrentControlSet\Enum\`. +#[cfg(windows)] +fn hid_container_id(instance: &str) -> anyhow::Result { + use anyhow::Context; + let key = winreg::RegKey::predef(winreg::enums::HKEY_LOCAL_MACHINE) + .open_subkey(format!(r"SYSTEM\CurrentControlSet\Enum\{instance}")) + .with_context(|| format!(r"open Enum\{instance}"))?; + key.get_value::("ContainerID") + .context("read ContainerID") +} + +/// Enumerate the active eRender endpoints with the container + channel facts the matcher needs. +/// Its own short-lived MTA thread (the caller may sit in an STA — the [`crate::audio::devices`] +/// discipline); one broken endpoint must not hide the rest. +#[cfg(windows)] +fn render_endpoints() -> anyhow::Result> { + use anyhow::{anyhow, Context}; + std::thread::Builder::new() + .name("pf-pad-audio-enum".into()) + .spawn(|| -> anyhow::Result> { + wasapi::initialize_mta() + .ok() + .context("CoInitializeEx (MTA)")?; + let enumerator = wasapi::DeviceEnumerator::new().context("DeviceEnumerator")?; + let coll = enumerator + .get_device_collection(&wasapi::Direction::Render) + .context("render endpoint collection")?; + let mut out = Vec::new(); + for i in 0..coll.get_nbr_devices().context("endpoint count")? { + let Ok(dev) = coll.get_device_at_index(i) else { + continue; + }; + let Ok(id) = dev.get_id() else { + continue; + }; + let channels = dev + .get_device_format() + .map(|f| f.get_nchannels()) + .unwrap_or(0); + out.push(EndpointCandidate { + container: endpoint_container_id(&id), + id, + channels, + }); + } + Ok(out) + }) + .context("spawn pad-audio enumeration thread")? + .join() + .map_err(|_| anyhow!("pad-audio enumeration thread panicked"))? +} + +/// The endpoint's stamped `PKEY_Device_ContainerId`, read from its MMDevices property store in +/// the registry (`…\MMDevices\Audio\Render\{ep-guid}\Properties`, value +/// `"{8c7ed206-3f8a-4827-b3ab-ae9e1faefc6c},2"`, a serialized VT_CLSID blob). +#[cfg(windows)] +fn endpoint_container_id(endpoint_id: &str) -> Option { + let guid = endpoint_id.rfind('{').map(|i| &endpoint_id[i..])?; + let key = winreg::RegKey::predef(winreg::enums::HKEY_LOCAL_MACHINE) + .open_subkey(format!( + r"SOFTWARE\Microsoft\Windows\CurrentVersion\MMDevices\Audio\Render\{guid}\Properties" + )) + .ok()?; + let v = key + .get_raw_value("{8c7ed206-3f8a-4827-b3ab-ae9e1faefc6c},2") + .ok()?; + container_guid_from_blob(&v.bytes) +} + +// ---- the 4-channel mixer -------------------------------------------------------------------- + +/// Interleave the two independent stereo streams into one 4-ch frame stream: speaker +/// ([`PAD_AUDIO_KIND_SPEAKER`]) on channels 0/1, haptics ([`PAD_AUDIO_KIND_HAPTICS`]) on 2/3. +/// Each kind has its own write cursor (they arrive on different cadences — 10 ms vs 5 ms); +/// [`pop`](Self::pop) emits every frame the FURTHER-ahead kind has filled, with the lagging +/// (or absent) kind's pair reading zeros — so a haptics-only session plays voice-coil audio +/// with a silent speaker pair and vice versa. Pure logic (unit-tested); pacing and latency +/// bounds live in the platform ring downstream. +pub(crate) struct QuadMixer { + /// Interleaved 4-ch samples; the front is the next frame to output. Length is always + /// `ready_frames() * 4` (pushes zero-extend for their own cursor only). + ring: std::collections::VecDeque, + /// Per-kind write cursor in FRAMES relative to the ring front (`[haptics, speaker]` — + /// indexed by the wire `kind`). + written: [usize; 2], +} + +impl QuadMixer { + pub(crate) fn new() -> QuadMixer { + QuadMixer { + ring: std::collections::VecDeque::new(), + written: [0; 2], + } + } + + /// Write one decoded stereo chunk (interleaved L/R) for `kind` at that kind's cursor, + /// zero-extending the ring as needed. Depth is bounded by [`MAX_BUFFER_FRAMES`] — overflow + /// drops the oldest frames (both kinds shift together, so the interleave never skews). + pub(crate) fn push(&mut self, kind: u8, stereo: &[f32]) { + let k = (kind as usize).min(1); + let off = if kind == PAD_AUDIO_KIND_SPEAKER { 0 } else { 2 }; + let frames = stereo.len() / 2; + let base = self.written[k]; + let need = (base + frames) * PAD_CHANNELS; + if self.ring.len() < need { + self.ring.resize(need, 0.0); + } + for (i, fr) in stereo.chunks_exact(2).enumerate() { + let at = (base + i) * PAD_CHANNELS + off; + self.ring[at] = fr[0]; + self.ring[at + 1] = fr[1]; + } + self.written[k] = base + frames; + let over = self.ready_frames().saturating_sub(MAX_BUFFER_FRAMES); + if over > 0 { + self.drop_front(over); + } + } + + /// Frames ready to output: the further-ahead kind's cursor (the other pair reads zeros). + pub(crate) fn ready_frames(&self) -> usize { + self.written[0].max(self.written[1]) + } + + /// Append every ready frame (interleaved 4-ch) to `out`; returns the frame count. Both + /// cursors move back together, so a kind that lagged simply resumes at the new front. + pub(crate) fn pop(&mut self, out: &mut Vec) -> usize { + let frames = self.ready_frames(); + let n = frames * PAD_CHANNELS; + debug_assert_eq!(self.ring.len(), n); + out.extend(self.ring.drain(..n.min(self.ring.len()))); + for w in &mut self.written { + *w = w.saturating_sub(frames); + } + frames + } + + /// Throw the ready frames away (no output device right now). + pub(crate) fn discard(&mut self) { + let f = self.ready_frames(); + self.drop_front(f); + } + + fn drop_front(&mut self, frames: usize) { + let n = (frames * PAD_CHANNELS).min(self.ring.len()); + self.ring.drain(..n); + let f = n / PAD_CHANNELS; + for w in &mut self.written { + *w = w.saturating_sub(f); + } + } +} + +// ---- decode + PLC --------------------------------------------------------------------------- + +/// Per-(pad, kind) decode state: a stereo 48 kHz Opus decoder, the seq-gap tracker, and the +/// last decoded frame size (the PLC synthesis unit — session.rs's audio-thread discipline). +struct KindStream { + dec: opus::Decoder, + gaps: AudioGapTracker, + frame_samples: usize, +} + +/// How many concealment frames to synthesize before decoding `seq`: the tracker's capped gap +/// count — but 0 until a first frame decoded (`frame_samples == 0`; there is nothing to size +/// the PLC from). The tracker is ALWAYS fed, so a pre-first-frame gap can't replay later as a +/// phantom gap. Pure (unit-tested). +fn plc_frames(gaps: &mut AudioGapTracker, seq: u32, frame_samples: usize) -> u32 { + let missing = gaps.missing_before(seq); + if frame_samples == 0 { + 0 + } else { + missing + } +} + +// ---- the renderer worker -------------------------------------------------------------------- + +/// Spawn the pad-audio renderer thread — the 0xD1 plane's single consumer, started by the +/// session pump whenever the settings could render (`pad_haptics` / `pad_speaker == "pad"`). +/// The output device is opened LAZILY on the first arriving frame: frames only flow once a +/// tier-A pad declared render caps on its arrival, so a session without a wired DualSense +/// costs one idle 10 ms poll loop and never touches the audio graph. Exits on the session +/// stop flag (join it like the audio thread) or the plane closing. +pub(crate) fn spawn( + connector: Arc, + stop: Arc, + haptics: bool, + speaker: bool, +) -> Option> { + std::thread::Builder::new() + .name("pf-pad-audio".into()) + .spawn(move || run(&connector, &stop, haptics, speaker)) + .map_err(|e| tracing::warn!(error = %e, "pad-audio thread failed to start")) + .ok() +} + +fn run(connector: &NativeClient, stop: &AtomicBool, haptics: bool, speaker: bool) { + // Per-kind decode state for the ONE rendered pad (v1: the first pad that streams; the + // spec's per-(pad, kind) fan-out degenerates to per-kind once the pad is latched). + let mut streams: [Option; 2] = [None, None]; + let mut mixer = QuadMixer::new(); + let mut pcm = vec![0f32; 5760 * 2]; // scratch: max Opus frame (120 ms) × stereo + let mut out: Option = None; + let mut active_pad: Option = None; + let mut other_pad_logged = false; + let mut open_fail_logged = false; + let mut retry_at = Instant::now(); + let mut backoff = RETRY_MIN; + while !stop.load(Ordering::SeqCst) { + let Some(f) = connector.next_pad_audio(Duration::from_millis(10)) else { + if connector.is_session_ended() { + break; + } + continue; + }; + // The host only emits kinds the arrival declared, but the settings gate is re-checked + // here so a stale host can never force an undeclared renderer. + if f.kind > 1 + || (f.kind == PAD_AUDIO_KIND_HAPTICS && !haptics) + || (f.kind == PAD_AUDIO_KIND_SPEAKER && !speaker) + { + continue; + } + // v1 renders ONE physical DualSense: latch the first streaming pad, drop the rest. + match active_pad { + None => active_pad = Some(f.pad), + Some(p) if p != f.pad => { + if !other_pad_logged { + other_pad_logged = true; + tracing::info!( + rendered = p, + ignored = f.pad, + "pad audio from a second pad — v1 renders one physical DualSense" + ); + } + continue; + } + _ => {} + } + let k = f.kind as usize; + if streams[k].is_none() { + match opus::Decoder::new(48_000, opus::Channels::Stereo) { + Ok(dec) => { + streams[k] = Some(KindStream { + dec, + gaps: AudioGapTracker::new(), + frame_samples: 0, + }) + } + Err(e) => { + tracing::warn!(error = %e, kind = f.kind, "pad-audio opus decoder failed"); + continue; + } + } + } + let st = streams[k].as_mut().expect("inserted above"); + // Conceal lost packets (a seq gap) with libopus PLC before decoding the arrival — + // the session audio thread's exact discipline. A frozen seq (the host paused the + // stream) produces no packets at all, which is silence by construction. + for _ in 0..plc_frames(&mut st.gaps, f.seq, st.frame_samples) { + let n = st.frame_samples * 2; + if let Ok(samples) = st.dec.decode_float(&[], &mut pcm[..n], false) { + mixer.push(f.kind, &pcm[..samples * 2]); + } + } + if !f.opus.is_empty() { + match st.dec.decode_float(&f.opus, &mut pcm, false) { + Ok(samples) => { + st.frame_samples = samples; + mixer.push(f.kind, &pcm[..samples * 2]); + } + Err(e) => tracing::debug!(error = %e, kind = f.kind, "pad-audio opus decode"), + } + } + // Output: open lazily (frames flowing prove a tier-A pad exists), drop + re-correlate + // with backoff when the device goes away (USB unplug kills the sink/endpoint). + if out.as_ref().is_some_and(PadOut::finished) { + tracing::info!("pad-audio output ended (device gone?) — re-correlating"); + out = None; + retry_at = Instant::now() + backoff; + backoff = (backoff * 2).min(RETRY_MAX); + } + if out.is_none() && Instant::now() >= retry_at { + match PadOut::open() { + Ok(o) => { + tracing::info!("pad-audio output opened on the DualSense audio device"); + out = Some(o); + backoff = RETRY_MIN; + open_fail_logged = false; + } + Err(e) => { + if !open_fail_logged { + open_fail_logged = true; + tracing::warn!( + error = %format!("{e:#}"), + "no DualSense audio device — pad audio parked (retrying with backoff)" + ); + } + retry_at = Instant::now() + backoff; + backoff = (backoff * 2).min(RETRY_MAX); + } + } + } + match &out { + Some(o) => { + let mut chunk = o.take_buffer(); + if mixer.pop(&mut chunk) > 0 { + o.push(chunk); + } + } + None => mixer.discard(), + } + } + tracing::debug!("pad-audio pull thread exited"); +} + +// ---- platform output: Linux (PipeWire) ------------------------------------------------------ + +/// The platform output half: a dedicated device thread fed interleaved 4-ch f32 chunks over a +/// bounded channel with a recycle pool (the `AudioPlayer` shape), targeting the correlated +/// DualSense device. `finished()` is the device-gone signal — the worker drops the handle and +/// re-correlates with backoff. +#[cfg(target_os = "linux")] +struct PadOut { + pcm_tx: std::sync::mpsc::SyncSender>, + recycle_rx: std::sync::mpsc::Receiver>, + quit_tx: pipewire::channel::Sender<()>, + thread: Option>, +} + +#[cfg(target_os = "linux")] +impl PadOut { + /// Correlate (sink signature match) and open the PipeWire playback stream on it. + fn open() -> anyhow::Result { + use anyhow::Context; + let (sinks, _) = crate::audio::devices().context("enumerate sinks")?; + let sink = + find_ds5_sink(&sinks).ok_or_else(|| anyhow::anyhow!("no DualSense sink found"))?; + tracing::info!(sink = %sink.name, description = %sink.description, "pad-audio sink matched"); + // 64 × 5 ms of slack between the renderer worker and the PipeWire loop, with the + // recycle pool keeping the steady state allocation-free (the AudioPlayer shape). + let (pcm_tx, pcm_rx) = std::sync::mpsc::sync_channel::>(64); + let (recycle_tx, recycle_rx) = std::sync::mpsc::sync_channel::>(64); + let (quit_tx, quit_rx) = pipewire::channel::channel::<()>(); + let target = sink.name; + let thread = std::thread::Builder::new() + .name("pf-pad-audio-out".into()) + .spawn(move || { + if let Err(e) = pad_pw_thread(pcm_rx, recycle_tx, quit_rx, target) { + tracing::warn!(error = %format!("{e:#}"), "pad-audio playback thread ended"); + } + }) + .context("spawn pad-audio playback thread")?; + Ok(PadOut { + pcm_tx, + recycle_rx, + quit_tx, + thread: Some(thread), + }) + } + + fn take_buffer(&self) -> Vec { + self.recycle_rx.try_recv().unwrap_or_default() + } + + fn push(&self, pcm: Vec) { + let _ = self.pcm_tx.try_send(pcm); // never block the renderer; drops are concealed + } + + fn finished(&self) -> bool { + self.thread.as_ref().is_none_or(|t| t.is_finished()) + } +} + +#[cfg(target_os = "linux")] +impl Drop for PadOut { + fn drop(&mut self) { + let _ = self.quit_tx.send(()); + if let Some(t) = self.thread.take() { + let _ = t.join(); + } + } +} + +/// The PipeWire playback thread on the DualSense sink: 4 channels positioned FL FR RL RR (the +/// pad's speaker pair + the voice-coil pair), a 5 ms quantum, and the session player's +/// adaptive ring policy at a SMALLER floor — haptics are felt latency, so the prime target is +/// 3 quanta bounded to [240, 2400] frames instead of the main player's [720, 9600]. +#[cfg(target_os = "linux")] +fn pad_pw_thread( + pcm_rx: std::sync::mpsc::Receiver>, + recycle_tx: std::sync::mpsc::SyncSender>, + quit_rx: pipewire::channel::Receiver<()>, + target: String, +) -> anyhow::Result<()> { + use anyhow::Context; + use pipewire as pw; + use pw::{properties::properties, spa}; + use spa::param::audio::{AudioFormat, AudioInfoRaw}; + use spa::pod::Pod; + + static PW_INIT: std::sync::Once = std::sync::Once::new(); + PW_INIT.call_once(pw::init); + + let mainloop = pw::main_loop::MainLoopRc::new(None).context("pw MainLoop")?; + let context = pw::context::ContextRc::new(&mainloop, None).context("pw Context")?; + let core = context + .connect_rc(None) + .context("pw connect (is PipeWire running in this session?)")?; + + let _quit_guard = quit_rx.attach(mainloop.loop_(), { + let mainloop = mainloop.clone(); + move |_| mainloop.quit() + }); + + let props = properties! { + *pw::keys::MEDIA_TYPE => "Audio", + *pw::keys::MEDIA_CATEGORY => "Playback", + *pw::keys::MEDIA_ROLE => "Game", + *pw::keys::NODE_NAME => "punktfunk-pad-audio", + *pw::keys::NODE_DESCRIPTION => "Punktfunk Pad Audio", + // ~5 ms quantum (one haptics Opus frame) keeps the ring — and the felt latency — small. + *pw::keys::NODE_LATENCY => "240/48000", + // The correlated DualSense sink (raw key — the `keys::TARGET_OBJECT` constant is + // feature-gated on a newer libpipewire than we require; the wire name is stable). + "target.object" => target.as_str(), + // The pad unplugging must END this stream (the worker re-correlates), not let the + // session manager re-route 4-ch haptics onto the desktop speakers. + "node.dont-reconnect" => "true", + }; + let stream = + pw::stream::StreamBox::new(&core, "punktfunk-pad-audio", props).context("pw Stream")?; + + struct PadPlayData { + rx: std::sync::mpsc::Receiver>, + recycle: std::sync::mpsc::SyncSender>, + ring: std::collections::VecDeque, + primed: bool, + } + let ud = PadPlayData { + rx: pcm_rx, + recycle: recycle_tx, + ring: std::collections::VecDeque::new(), + primed: false, + }; + + let _listener = stream + .add_local_listener_with_user_data(ud) + .state_changed({ + let mainloop = mainloop.clone(); + move |_s, _ud, old, new| { + tracing::debug!(?old, ?new, "pipewire pad-audio stream state"); + // Device gone (USB unplug) with dont-reconnect: the stream errors out — end + // the thread so the worker's `finished()` check re-correlates with backoff. + if matches!(new, pw::stream::StreamState::Error(_)) { + mainloop.quit(); + } + } + }) + .process(|stream, ud| { + let outcome = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + let Some(mut buffer) = stream.dequeue_buffer() else { + return; + }; + while let Ok(mut chunk) = ud.rx.try_recv() { + ud.ring.extend(chunk.iter().copied()); + chunk.clear(); + let _ = ud.recycle.try_send(chunk); + } + let stride = 4 * PAD_CHANNELS; // F32LE interleaved + let datas = buffer.datas_mut(); + if datas.is_empty() { + return; + } + let data = &mut datas[0]; + let want_frames = data.data().map(|s| s.len() / stride).unwrap_or(0); + let want = want_frames * PAD_CHANNELS; + + // The adaptive jitter buffer at the pad floor: prime to ~3 quanta within + // [240, 2400] frames, cap ~1 quantum of slack beyond, re-prime after a drain. + let target = (3 * want).clamp(240 * PAD_CHANNELS, 2400 * PAD_CHANNELS); + while ud.ring.len() > target.max(want) + want { + ud.ring.pop_front(); + } + if !ud.primed && ud.ring.len() >= target { + ud.primed = true; + } + + let n_frames = if let Some(slice) = data.data() { + for k in 0..want { + let s = if ud.primed { + ud.ring.pop_front().unwrap_or(0.0) + } else { + 0.0 + }; + let off = k * 4; + slice[off..off + 4].copy_from_slice(&s.to_le_bytes()); + } + want_frames + } else { + 0 + }; + if ud.ring.is_empty() { + ud.primed = false; + } + let chunk = data.chunk_mut(); + *chunk.offset_mut() = 0; + *chunk.stride_mut() = stride as _; + *chunk.size_mut() = (stride * n_frames) as _; + })); + if outcome.is_err() { + tracing::error!("panic in pipewire pad-audio callback"); + } + }) + .register() + .context("register pad-audio listener")?; + + let mut info = AudioInfoRaw::new(); + info.set_format(AudioFormat::F32LE); + info.set_rate(48_000); + info.set_channels(PAD_CHANNELS as u32); + // FL FR RL RR (SPA ids 3 4 12 13): the front pair is the pad's speaker, the rear pair the + // voice coils — the DS5 device's own channel order, identity-routed (no remix wanted). + let mut positions = [0u32; 64]; + positions[..4].copy_from_slice(&[3, 4, 12, 13]); + info.set_position(positions); + let obj = pw::spa::pod::Object { + type_: pw::spa::utils::SpaTypes::ObjectParamFormat.as_raw(), + id: pw::spa::param::ParamType::EnumFormat.as_raw(), + properties: info.into(), + }; + let values: Vec = pw::spa::pod::serialize::PodSerializer::serialize( + std::io::Cursor::new(Vec::new()), + &pw::spa::pod::Value::Object(obj), + ) + .context("serialize pad format pod")? + .0 + .into_inner(); + let mut params = [Pod::from_bytes(&values).context("pad pod from bytes")?]; + + stream + .connect( + spa::utils::Direction::Output, + None, + pw::stream::StreamFlags::AUTOCONNECT | pw::stream::StreamFlags::MAP_BUFFERS, + &mut params, + ) + .context("pw pad stream connect")?; + + mainloop.run(); + tracing::debug!("pipewire pad-audio loop exited"); + Ok(()) +} + +// ---- platform output: Windows (WASAPI) ------------------------------------------------------ + +#[cfg(windows)] +struct PadOut { + pcm_tx: std::sync::mpsc::SyncSender>, + recycle_rx: std::sync::mpsc::Receiver>, + stop: Arc, + thread: Option>, +} + +#[cfg(windows)] +impl PadOut { + /// Correlate (HID container → endpoint id) and open a shared event-driven render stream ON + /// that endpoint (`audio_wasapi::render_thread`'s shape — autoconvert, default period). + fn open() -> anyhow::Result { + use anyhow::{anyhow, Context}; + let hid_path = + first_tier_a_hid_path().ok_or_else(|| anyhow!("no tier-A pad registered"))?; + let endpoint = correlate_pad_endpoint(&hid_path)?; + tracing::info!(endpoint = %endpoint, "pad-audio endpoint correlated"); + let (pcm_tx, pcm_rx) = std::sync::mpsc::sync_channel::>(64); + let (recycle_tx, recycle_rx) = std::sync::mpsc::sync_channel::>(64); + let (ready_tx, ready_rx) = std::sync::mpsc::sync_channel::>(1); + let stop = Arc::new(AtomicBool::new(false)); + let stop_t = stop.clone(); + let thread = std::thread::Builder::new() + .name("pf-pad-audio-out".into()) + .spawn(move || { + if let Err(e) = pad_render_thread(pcm_rx, recycle_tx, stop_t, ready_tx, &endpoint) { + tracing::warn!(error = %format!("{e:#}"), "pad-audio render thread ended"); + } + }) + .context("spawn pad-audio render thread")?; + match ready_rx.recv_timeout(Duration::from_secs(3)) { + Ok(Ok(())) => Ok(PadOut { + pcm_tx, + recycle_rx, + stop, + thread: Some(thread), + }), + Ok(Err(e)) => Err(e), + Err(_) => Err(anyhow!("pad-audio render init timed out")), + } + } + + fn take_buffer(&self) -> Vec { + self.recycle_rx.try_recv().unwrap_or_default() + } + + fn push(&self, pcm: Vec) { + let _ = self.pcm_tx.try_send(pcm); // never block the renderer; drops are concealed + } + + fn finished(&self) -> bool { + self.thread.as_ref().is_none_or(|t| t.is_finished()) + } +} + +#[cfg(windows)] +impl Drop for PadOut { + fn drop(&mut self) { + self.stop.store(true, Ordering::SeqCst); + if let Some(t) = self.thread.take() { + let _ = t.join(); + } + } +} + +/// The WASAPI render thread ON the correlated endpoint: shared event-driven, autoconvert, 4 ch +/// f32 masked FL|FR|BL|BR (0x33 — the DS5 endpoint's own layout, so the map is identity), and +/// the session player's ring policy at the pad floor ([240, 2400] frames instead of +/// [720, 9600] — haptics are felt latency). Any device error (unplug) ends the thread; the +/// worker's `finished()` check re-correlates with backoff. +#[cfg(windows)] +fn pad_render_thread( + pcm_rx: std::sync::mpsc::Receiver>, + recycle_tx: std::sync::mpsc::SyncSender>, + stop: Arc, + ready: std::sync::mpsc::SyncSender>, + endpoint_id: &str, +) -> anyhow::Result<()> { + use anyhow::{anyhow, Context}; + use wasapi::{Direction, SampleType, StreamMode, WaveFormat}; + if let Err(e) = wasapi::initialize_mta() + .ok() + .context("CoInitializeEx (MTA)") + { + let _ = ready.send(Err(e)); + return Ok(()); + } + let res = (|| -> anyhow::Result<()> { + const BLOCK_ALIGN: usize = PAD_CHANNELS * 4; // f32 interleaved + let enumerator = wasapi::DeviceEnumerator::new().context("DeviceEnumerator")?; + let device = enumerator + .get_device(endpoint_id) + .map_err(|e| anyhow!("correlated endpoint not found: {e}"))?; + let mut audio_client = device.get_iaudioclient().context("IAudioClient")?; + // FL|FR|BL|BR: front pair = the pad's speaker, back pair = the voice coils. + let desired = WaveFormat::new(32, 32, &SampleType::Float, 48_000, PAD_CHANNELS, Some(0x33)); + let (default_period, _min_period) = + audio_client.get_device_period().context("device period")?; + let mode = StreamMode::EventsShared { + autoconvert: true, + buffer_duration_hns: default_period, + }; + audio_client + .initialize_client(&desired, &Direction::Render, &mode) + .context("initialize pad render client")?; + let h_event = audio_client.set_get_eventhandle().context("event handle")?; + let render_client = audio_client + .get_audiorenderclient() + .context("IAudioRenderClient")?; + audio_client + .start_stream() + .context("start pad render stream")?; + let _ = ready.send(Ok(())); + + // The adaptive jitter buffer in f32-byte units, at the pad floor (see the module doc). + let mut ring: std::collections::VecDeque = std::collections::VecDeque::new(); + let mut primed = false; + let mut out = Vec::new(); + + while !stop.load(Ordering::Relaxed) { + if h_event.wait_for_event(100).is_err() { + continue; + } + while let Ok(mut chunk) = pcm_rx.try_recv() { + for s in chunk.iter() { + ring.extend(s.to_le_bytes()); + } + chunk.clear(); + let _ = recycle_tx.try_send(chunk); + } + let avail_frames = audio_client + .get_available_space_in_frames() + .context("available space")? as usize; + if avail_frames == 0 { + continue; + } + let want_bytes = avail_frames * BLOCK_ALIGN; + + // Prime to ~3 quanta within [240, 2400] frames; cap ~1 quantum of slack beyond; + // instant re-prime on a genuine drain. + let target = (3 * want_bytes).clamp(240 * BLOCK_ALIGN, 2400 * BLOCK_ALIGN); + let cap = target.max(want_bytes) + want_bytes; + if ring.len() > cap { + ring.drain(..ring.len() - cap); + } + if !primed && ring.len() >= target { + primed = true; + } + + out.clear(); + out.resize(want_bytes, 0); + if primed { + let n = ring.len().min(want_bytes); + for (dst, b) in out.iter_mut().zip(ring.drain(..n)) { + *dst = b; + } + } + if ring.is_empty() { + primed = false; + } + render_client + .write_to_device(avail_frames, &out, None) + .context("write_to_device")?; + } + audio_client.stop_stream().ok(); + Ok(()) + })(); + if let Err(ref e) = res { + let _ = ready.send(Err(anyhow::anyhow!("{e:#}"))); + } + res +} + +// ---- tests ---------------------------------------------------------------------------------- + +#[cfg(test)] +mod tests { + use super::*; + + /// The speaker mode gate: only `"pad"` renders today; `"mix"` is the declared TODO and + /// reads as off; unknown values (a future store, a typo) fail safe to off. + #[test] + fn speaker_mode_gates() { + assert!(speaker_active("pad")); + assert!(!speaker_active("off")); + assert!(!speaker_active("mix")); // TODO leg — off until the mixer exists + assert!(!speaker_active("")); + assert!(!speaker_active("Pad")); // stored names are lowercase; anything else is off + } + + /// Tier A needs all three facts: the DualSense/Edge VID:PID and a wired connection. + #[test] + fn tier_a_is_wired_ds5_or_edge_only() { + assert!(is_tier_a_ds5(0x054C, 0x0CE6, true)); // DualSense + assert!(is_tier_a_ds5(0x054C, 0x0DF2, true)); // DualSense Edge + assert!(!is_tier_a_ds5(0x054C, 0x0CE6, false)); // Bluetooth → no audio device + assert!(!is_tier_a_ds5(0x054C, 0x05C4, true)); // DualShock 4 + assert!(!is_tier_a_ds5(0x045E, 0x0CE6, true)); // wrong vendor, right product id + assert!(!is_tier_a_ds5(0x28DE, 0x1205, true)); // Steam Deck + } + + /// The Linux sink signature: USB vendor string in the node name, product in the + /// description, or the kernel's bare "Wireless Controller" fallback — and nothing else. + #[test] + fn ds5_sink_signature_matching() { + assert!(is_ds5_sink( + "alsa_output.usb-Sony_Interactive_Entertainment_Wireless_Controller-00.analog-stereo", + "Wireless Controller Analog Stereo" + )); + assert!(is_ds5_sink( + "alsa_output.usb-054c_0ce6-00", + "DualSense Wireless Controller" + )); + assert!(is_ds5_sink("Wireless Controller", "")); + assert!(is_ds5_sink("", "Wireless Controller Audio")); + assert!(!is_ds5_sink( + "alsa_output.pci-0000_0a_00.4.analog-stereo", + "Built-in Audio Analog Stereo" + )); + // "Wireless Controller" must LEAD the string — a headset description mentioning + // "... for Wireless Controller" is not the pad. + assert!(!is_ds5_sink("headset", "Adapter for Wireless Controller")); + } + + /// The Windows container matcher: container equality (case-insensitive — registry GUIDs + /// come in both cases) AND the 4-channel format gate. + #[test] + fn endpoint_pick_needs_container_and_four_channels() { + let cands = [ + EndpointCandidate { + id: "{0.0.0.00000000}.{aaaa}".into(), + container: Some("{11111111-2222-3333-4444-555555555555}".into()), + channels: 2, // right container, stereo — not the pad function + }, + EndpointCandidate { + id: "{0.0.0.00000000}.{bbbb}".into(), + container: Some("{99999999-2222-3333-4444-555555555555}".into()), + channels: 4, // 4-ch but another container + }, + EndpointCandidate { + id: "{0.0.0.00000000}.{cccc}".into(), + container: Some("{11111111-2222-3333-4444-555555555555}".into()), + channels: 4, // the pad + }, + EndpointCandidate { + id: "{0.0.0.00000000}.{dddd}".into(), + container: None, + channels: 4, + }, + ]; + let hit = pick_pad_endpoint(&cands, "{11111111-2222-3333-4444-555555555555}").unwrap(); + assert_eq!(hit.id, "{0.0.0.00000000}.{cccc}"); + // Case-insensitive (Enum stores uppercase, MMDevices lowercase). + let hit = pick_pad_endpoint( + &cands, + "{11111111-2222-3333-4444-555555555555}" + .to_uppercase() + .as_str(), + ) + .unwrap(); + assert_eq!(hit.id, "{0.0.0.00000000}.{cccc}"); + assert!(pick_pad_endpoint(&cands, "{00000000-0000-0000-0000-000000000000}").is_none()); + } + + /// Interface path → instance id: prefix stripped, `#` → `\`, interface-class GUID dropped; + /// garbage shapes are rejected rather than mis-keyed into the registry. + #[test] + fn hid_interface_path_to_instance_id() { + assert_eq!( + hid_instance_from_interface_path( + r"\\?\HID#VID_054C&PID_0CE6#8&2de99099&0&0000#{4d1e55b2-f16f-11cf-88cb-001111000030}" + ) + .as_deref(), + Some(r"HID\VID_054C&PID_0CE6\8&2de99099&0&0000") + ); + // hidapi paths come lowercase and sometimes without the class GUID — both parse. + assert_eq!( + hid_instance_from_interface_path(r"\\?\hid#vid_054c&pid_0df2#7&1a2b3c4d&1&0000") + .as_deref(), + Some(r"hid\vid_054c&pid_0df2\7&1a2b3c4d&1&0000") + ); + for bad in ["", "/dev/hidraw3", r"\\?\HID#VID_054C", "a#b#c#d#e"] { + assert_eq!( + hid_instance_from_interface_path(bad), + None, + "{bad:?} parsed" + ); + } + } + + /// The serialized VT_CLSID blob (8-byte PROPVARIANT header + registry-order GUID) parses to + /// the braced string; short/foreign blobs don't. + #[test] + fn container_blob_parses_vt_clsid() { + // {11223344-5566-7788-99aa-bbccddeeff00}: data1/2/3 little-endian on disk. + let mut blob = vec![0x48, 0, 0, 0, 1, 0, 0, 0]; + blob.extend_from_slice(&[ + 0x44, 0x33, 0x22, 0x11, 0x66, 0x55, 0x88, 0x77, 0x99, 0xAA, 0xBB, 0xCC, 0xDD, 0xEE, + 0xFF, 0x00, + ]); + assert_eq!( + container_guid_from_blob(&blob).as_deref(), + Some("{11223344-5566-7788-99aa-bbccddeeff00}") + ); + assert_eq!(container_guid_from_blob(&blob[..20]), None); // truncated + let mut wrong_vt = blob.clone(); + wrong_vt[0] = 0x41; // VT_BLOB — a format value, not a container + assert_eq!(container_guid_from_blob(&wrong_vt), None); + } + + /// The 4-ch interleave: speaker frames land on channels 0/1 at the speaker cursor, haptics + /// on 2/3 at theirs, and the pop emits exactly the further-ahead kind's frame count with + /// the lagging kind's tail zeroed. + #[test] + fn mixer_interleaves_kinds_into_quad_frames() { + let mut m = QuadMixer::new(); + // 2 speaker frames, 1 haptics frame. + m.push(PAD_AUDIO_KIND_SPEAKER, &[1.0, 2.0, 3.0, 4.0]); + m.push(PAD_AUDIO_KIND_HAPTICS, &[5.0, 6.0]); + assert_eq!(m.ready_frames(), 2); + let mut out = Vec::new(); + assert_eq!(m.pop(&mut out), 2); + assert_eq!(out, vec![1.0, 2.0, 5.0, 6.0, 3.0, 4.0, 0.0, 0.0]); + assert_eq!(m.ready_frames(), 0); + // After the pop both cursors are back at the front: the next haptics frame starts a + // fresh quad frame with a silent speaker pair. + m.push(PAD_AUDIO_KIND_HAPTICS, &[7.0, 8.0]); + let mut out = Vec::new(); + assert_eq!(m.pop(&mut out), 1); + assert_eq!(out, vec![0.0, 0.0, 7.0, 8.0]); + } + + /// A single flowing kind never conjures data on the other pair (kind 1 → 0/1, kind 0 → 2/3). + #[test] + fn mixer_missing_kind_stays_zero() { + let mut m = QuadMixer::new(); + m.push(PAD_AUDIO_KIND_HAPTICS, &[0.5, -0.5, 0.25, -0.25]); + let mut out = Vec::new(); + assert_eq!(m.pop(&mut out), 2); + assert_eq!(out, vec![0.0, 0.0, 0.5, -0.5, 0.0, 0.0, 0.25, -0.25]); + let mut m = QuadMixer::new(); + m.push(PAD_AUDIO_KIND_SPEAKER, &[0.5, -0.5]); + let mut out = Vec::new(); + assert_eq!(m.pop(&mut out), 1); + assert_eq!(out, vec![0.5, -0.5, 0.0, 0.0]); + } + + /// The depth bound: a wedged output can't grow the mixer past [`MAX_BUFFER_FRAMES`]; the + /// oldest frames drop and both cursors shift together (no interleave skew). + #[test] + fn mixer_caps_depth_dropping_oldest() { + let mut m = QuadMixer::new(); + let chunk = vec![1.0f32; 480 * 2]; // 480 frames per push + for _ in 0..12 { + m.push(PAD_AUDIO_KIND_HAPTICS, &chunk); // 5760 frames pushed + } + assert_eq!(m.ready_frames(), MAX_BUFFER_FRAMES); + // A late speaker push still lands at ITS cursor (0 after the drops) — front of ring. + m.push(PAD_AUDIO_KIND_SPEAKER, &[9.0, 9.0]); + let mut out = Vec::new(); + m.pop(&mut out); + assert_eq!(&out[..4], &[9.0, 9.0, 1.0, 1.0]); + // `discard` empties without output. + m.push(PAD_AUDIO_KIND_HAPTICS, &chunk); + m.discard(); + assert_eq!(m.ready_frames(), 0); + } + + /// Seq-gap PLC counting mirrors the session audio thread: nothing for the first packet or + /// in-order flow, the exact gap for a loss, the tracker's cap for a burst — and 0 before a + /// first decode (`frame_samples == 0`) while STILL consuming the gap (no phantom replay). + #[test] + fn plc_counts_gaps_like_the_session_audio_path() { + let mut gaps = AudioGapTracker::new(); + assert_eq!(plc_frames(&mut gaps, 0, 480), 0); // first packet + assert_eq!(plc_frames(&mut gaps, 1, 480), 0); // in-order + assert_eq!(plc_frames(&mut gaps, 5, 480), 3); // 2,3,4 lost + assert_eq!(plc_frames(&mut gaps, 5, 480), 0); // duplicate + assert_eq!(plc_frames(&mut gaps, 4, 480), 0); // reorder — nothing to conceal + assert_eq!(plc_frames(&mut gaps, 1000, 480), 10); // burst, capped (MAX_CONCEAL_PACKETS) + // Before the first decode there is no frame size to synthesize from — but the tracker + // must still advance, or this gap would replay against the next packet. + let mut gaps = AudioGapTracker::new(); + assert_eq!(plc_frames(&mut gaps, 7, 0), 0); + assert_eq!(plc_frames(&mut gaps, 12, 0), 0); // gap consumed silently + assert_eq!(plc_frames(&mut gaps, 13, 480), 0); // in-order once decoding starts + } +} diff --git a/crates/pf-client-core/src/session.rs b/crates/pf-client-core/src/session.rs index cf2ba70f..e0643b87 100644 --- a/crates/pf-client-core/src/session.rs +++ b/crates/pf-client-core/src/session.rs @@ -44,6 +44,14 @@ pub struct SessionParams { /// Run the uplink through the platform's echo cancellation ([`Settings::echo_cancel`]). /// Ignored when `mic_enabled` is false; `PUNKTFUNK_NO_AEC=1` overrides it off. pub echo_cancel: bool, + /// Render the host's per-pad DualSense voice-coil haptics stream (0xD1 kind 0) on a wired + /// physical DualSense ([`crate::trust::Settings::pad_haptics`]). With `pad_speaker` it + /// gates the `CLIENT_CAP_PAD_AUDIO` advertisement and the pad-audio renderer thread. + pub pad_haptics: bool, + /// Where the DualSense built-in-speaker stream (0xD1 kind 1) goes: `"pad"` | `"mix"` | + /// `"off"` ([`crate::trust::Settings::pad_speaker`]; `"mix"` is a TODO that renders as + /// off — see [`crate::pad_audio::speaker_active`]). + pub pad_speaker: String, /// Share the clipboard with this host (the per-host `KnownHost::clipboard_sync`). The /// bridge additionally needs the host to advertise `HOST_CAP_CLIPBOARD`. pub clipboard: bool, @@ -356,6 +364,11 @@ fn pump( ); } } + // Pad audio (0xD1): advertise only when the settings could render a stream — the per-pad + // tier-A detection at slot open (gamepad.rs) still decides which pads declare render caps + // on their arrivals, so this bit alone changes nothing without a wired DualSense. + let pad_speaker_on = crate::pad_audio::speaker_active(¶ms.pad_speaker); + let pad_audio_on = params.pad_haptics || pad_speaker_on; let connector = match NativeClient::connect( ¶ms.host, params.port, @@ -379,6 +392,11 @@ fn pump( 0 }) | (if params.phase_lock { punktfunk_core::quic::CLIENT_CAP_PHASE_LOCK + } else { + 0 + // PAD_AUDIO: the embedder can render per-pad DualSense haptics/speaker (see above). + }) | (if pad_audio_on { + punktfunk_core::quic::CLIENT_CAP_PAD_AUDIO } else { 0 }), @@ -481,6 +499,20 @@ fn pump( // app-lifetime service's job (the UI attaches it on Connected). Audio runs on its own // thread (one puller per plane), blocking on the audio queue like the Apple client. let audio_thread = spawn_audio(connector.clone(), stop.clone()); + // Pad audio (0xD1): its own drain thread (that plane's single consumer), spawned whenever + // the settings could render. The output device is opened LAZILY once frames actually + // arrive — which only happens after a tier-A pad declared render caps on its arrival — so + // a session without a wired DualSense costs one idle 10 ms poll loop. + let pad_audio_thread = pad_audio_on + .then(|| { + crate::pad_audio::spawn( + connector.clone(), + stop.clone(), + params.pad_haptics, + pad_speaker_on, + ) + }) + .flatten(); // The shared clipboard (design/clipboard-and-file-transfer.md §5): its own thread, since // `next_clip` blocks and the OS clipboard calls can wait on other apps. Returns straight // away when the host has no clipboard capability, so spawning is unconditional. @@ -1046,6 +1078,9 @@ fn pump( if let Some(t) = audio_thread { let _ = t.join(); // exits within its 100 ms pull timeout once `stop` is set } + if let Some(t) = pad_audio_thread { + let _ = t.join(); // exits within its 10 ms pull timeout once `stop` is set + } if let Some(t) = clipboard_thread { let _ = t.join(); // exits within its next_clip wait once `stop` is set } diff --git a/crates/pf-client-core/src/trust.rs b/crates/pf-client-core/src/trust.rs index 3841586c..dd6eb964 100644 --- a/crates/pf-client-core/src/trust.rs +++ b/crates/pf-client-core/src/trust.rs @@ -912,6 +912,21 @@ pub struct Settings { /// `PUNKTFUNK_AUDIO_SOURCE`). #[serde(default)] pub mic_device: String, + /// Render the host's per-pad DualSense voice-coil haptics stream (the 0xD1 plane, kind 0) + /// on a WIRED physical DualSense's own audio device (tier A — Bluetooth pads expose no + /// audio device). Gates the `CLIENT_CAP_PAD_AUDIO` advertisement and the per-pad arrival + /// capability bit; wire rumble is suppressed for a pad whose haptics stream is live (the + /// stream carries the feedback — see `gamepad.rs`, the SDL disable-bit trap). Default ON: + /// the capable-and-agreed negotiation means it changes nothing without a capable host AND + /// a wired DS5. `default` so pre-existing stores load with it on. + #[serde(default = "default_true")] + pub pad_haptics: bool, + /// Where the DualSense built-in-speaker stream (0xD1 kind 1) is rendered: `"pad"` (default + /// — the physical pad's own speaker), `"mix"` (fold it into the main stream audio — a + /// declared TODO that renders as `"off"` today; see `pad_audio::speaker_active`), or + /// `"off"`. `default` so pre-existing stores load as `"pad"`. + #[serde(default = "default_pad_speaker")] + pub pad_speaker: String, /// Match-window resolution policy (design/midstream-resolution-resize.md D1): the /// stream mode follows the session window — the connect asks for the window's pixel /// size and a mid-session resize renegotiates the host's virtual display + encoder @@ -943,6 +958,10 @@ fn default_true() -> bool { true } +fn default_pad_speaker() -> String { + "pad".into() +} + impl Settings { /// The stats-overlay tier, resolving pre-tier stores: an old `show_stats = false` /// reads as Off, everything else as Normal (≈ what the pre-tier overlay showed). @@ -1015,6 +1034,8 @@ impl Default for Settings { invert_scroll: false, speaker_device: String::new(), mic_device: String::new(), + pad_haptics: true, + pad_speaker: "pad".into(), match_window: false, last_window_w: 0, last_window_h: 0, diff --git a/crates/pf-inject/src/inject/hidout_dedup.rs b/crates/pf-inject/src/inject/hidout_dedup.rs index 5e87a80b..c4fa458e 100644 --- a/crates/pf-inject/src/inject/hidout_dedup.rs +++ b/crates/pf-inject/src/inject/hidout_dedup.rs @@ -10,14 +10,20 @@ use punktfunk_core::quic::HidOutput; /// bundles rumble + lightbar + player-LEDs + adaptive-triggers into one report, so a pad that is /// merely *rumbling* re-sends its (unchanged) lightbar / LED / trigger state on every output report. /// The managers already dedup rumble; this does the same for the rich [`HidOutput`] feedback so the -/// 0xCD plane carries only genuine changes. State (`Led` / `PlayerLeds` / `Trigger`) is deduped by -/// value; a one-shot `TrackpadHaptic` pulse is always forwarded (each pulse must fire). +/// 0xCD plane carries only genuine changes. State (`Led` / `PlayerLeds` / `Trigger` / `AudioCtl`) +/// is deduped by value; a one-shot `TrackpadHaptic` pulse is always forwarded (each pulse must +/// fire). #[derive(Clone, Default)] pub struct HidoutDedup { led: Option<(u8, u8, u8)>, player_leds: Option, /// Last-forwarded adaptive-trigger effect per side: `[0]` = L2, `[1]` = R2. trigger: [Option>; 2], + /// Last-forwarded audio-control state (`flags` + the raw volume/routing bytes). + audio_ctl: Option<(u8, [u8; 6])>, + /// Once-per-pad-lifetime field-diagnosis flag: set after the first forwarded `AudioCtl` + /// carrying the haptics-select bit was logged (cleared with the rest on (re)plug). + haptics_select_logged: bool, } impl HidoutDedup { @@ -60,6 +66,25 @@ impl HidoutDedup { } // One-shot haptic pulse (Steam voice-coil) — state-less, always fires. HidOutput::TrackpadHaptic { .. } => true, + HidOutput::AudioCtl { pad, flags, raw } => { + let v = Some((*flags, *raw)); + if self.audio_ctl == v { + false + } else { + // Field-diagnosis signal, once per pad lifetime: a title driving the DS5's + // audio haptics (not plain rumble emulation, whose all-zero audio region + // never reaches here) — the trace that tells "the game does audio haptics" + // apart from "the client just doesn't render them". + if flags & 0x01 != 0 && !self.haptics_select_logged { + self.haptics_select_logged = true; + tracing::info!( + "DS5 title asserted haptics-select (audio haptics) pad={pad}" + ); + } + self.audio_ctl = v; + true + } + } // Raw as-is passthrough reports must NEVER dedup: the physical device's firmware // watchdogs RELY on identical periodic refreshes (Triton rumble re-sent every ~40 ms // against a ~50 ms safety timeout, lizard-off every ~3 s) — dropping a repeat would @@ -123,4 +148,28 @@ mod tests { assert!(d.should_forward(&pl(0b101))); assert!(d.should_forward(&trig(0, 2))); } + + /// `AudioCtl` dedups by value like the other state kinds: an identical repeat (every output + /// report re-sends the unchanged audio region) is dropped, a flags-only or raw-only change + /// forwards again, and `clear` re-arms — including the once-per-pad haptics-select log flag. + #[test] + fn audio_ctl_dedups_by_value() { + let mut d = HidoutDedup::default(); + let audio = |flags, vol| HidOutput::AudioCtl { + pad: 0, + flags, + raw: [vol, 0, 0, 0, 0, 0], + }; + // Identical twice → exactly one emission. + assert!(d.should_forward(&audio(0x17, 0x50))); + assert!(!d.should_forward(&audio(0x17, 0x50))); + // Either half changing (flags, or the raw region) forwards again. + assert!(d.should_forward(&audio(0x16, 0x50))); + assert!(d.should_forward(&audio(0x16, 0x60))); + // The other kinds' state is untouched by audio traffic. + assert!(d.should_forward(&HidOutput::PlayerLeds { pad: 0, bits: 1 })); + // `clear` (pad re-plug) re-arms the value dedup. + d.clear(); + assert!(d.should_forward(&audio(0x16, 0x60))); + } } diff --git a/crates/pf-inject/src/inject/proto/dualsense_proto.rs b/crates/pf-inject/src/inject/proto/dualsense_proto.rs index 32852914..3af54bf7 100644 --- a/crates/pf-inject/src/inject/proto/dualsense_proto.rs +++ b/crates/pf-inject/src/inject/proto/dualsense_proto.rs @@ -481,7 +481,8 @@ pub struct DsFeedback { /// Parse a DualSense USB output report (`0x02`) into a [`DsFeedback`]. The byte layout below is /// the USB DualSense common report; only the well-understood fields (motor rumble, lightbar RGB, -/// player LEDs) are surfaced — adaptive-trigger blocks are forwarded raw for the client. +/// player LEDs) are surfaced — adaptive-trigger blocks and the audio-control region are +/// forwarded raw for the client. /// /// Every field is gated on the report's valid-flags (`valid_flag0` at data[1], `valid_flag1` /// at data[2]) — writers only set the bits for fields they mean to change (the rest is zeroed), @@ -540,6 +541,21 @@ pub fn parse_ds_output(pad: u8, data: &[u8], fb: &mut DsFeedback) { }); } } + // The audio-control region (bytes 5..=10: headphone/speaker/mic volumes + routing), for the + // pad-audio path. The wire flags condense the report's audio bits: bit0 = haptics-select + // (flag0 BIT1 — set on every SDL rumble write too, which is why it alone never triggers an + // emission), bits1..4 = flag0 bits 4..7 (the audio-valid flags gating the region). Emitted + // whenever an audio-valid flag is present or the region carries data; downstream dedup + // ([`crate::hidout_dedup`]) reduces the per-report repeats to genuine changes. + let raw: [u8; 6] = data[5..11].try_into().unwrap(); + if flag0 & 0xF0 != 0 || raw != [0u8; 6] { + let flags = ((flag0 >> 1) & 0x01) | ((flag0 >> 3) & 0x1E); + fb.hidout.push(HidOutput::AudioCtl { + pad: pad.into(), + flags, + raw, + }); + } } #[cfg(test)] @@ -842,6 +858,48 @@ mod tests { assert_eq!(*DUALSENSE_EDGE_RDESC.last().unwrap(), 0xC0); } + /// A 0x02 report driving the pad's audio (haptics-select + audio-valid flags + the volume/ + /// routing bytes) surfaces an `AudioCtl` with the exact raw region and the condensed flags; + /// a plain rumble write (haptics-select but a silent audio region — every SDL rumble) does + /// NOT — that is what `parse_output_respects_valid_flags` pins with its `hidout.is_empty()`. + #[test] + fn parse_output_surfaces_audio_ctl() { + let mut data = vec![0u8; 48]; + data[0] = 0x02; + data[1] = 0xB2; // flag0: haptics-select (BIT1) + audio-valid bits 4/5/7 + data[5] = 0x50; // headphone volume + data[6] = 0x60; // speaker volume + data[7] = 0x70; // mic volume + data[8] = 0x05; // audio routing / enable bits + let mut fb = DsFeedback::default(); + parse_ds_output(3, &data, &mut fb); + // flags: bit0 = flag0 bit1, bits1..4 = flag0 bits 4..7 (0b1011 → 0b10110). + assert_eq!( + fb.hidout, + vec![HidOutput::AudioCtl { + pad: 3, + flags: 0b1_0111, + raw: [0x50, 0x60, 0x70, 0x05, 0x00, 0x00], + }] + ); + // A non-zero audio region with NO audio-valid flags still surfaces (dedup collapses the + // repeats downstream) — some writers leave stale volumes gated off; the host side wants + // the honest bytes either way. + let mut data = vec![0u8; 48]; + data[0] = 0x02; + data[9] = 0x01; + let mut fb = DsFeedback::default(); + parse_ds_output(0, &data, &mut fb); + assert_eq!( + fb.hidout, + vec![HidOutput::AudioCtl { + pad: 0, + flags: 0, + raw: [0, 0, 0, 0, 0x01, 0], + }] + ); + } + /// A short / wrong-id report yields nothing. #[test] fn parse_output_rejects_garbage() { diff --git a/crates/pf-inject/src/inject/uhid_manager.rs b/crates/pf-inject/src/inject/uhid_manager.rs index cdb95fd4..7850382a 100644 --- a/crates/pf-inject/src/inject/uhid_manager.rs +++ b/crates/pf-inject/src/inject/uhid_manager.rs @@ -475,6 +475,7 @@ mod tests { index: 2, kind: 1, capabilities: 0, + audio_caps: 0, }); assert!(m.slots.get(2).is_some()); } diff --git a/crates/punktfunk-core/src/abi.rs b/crates/punktfunk-core/src/abi.rs index 3e0692b5..2ddfba4e 100644 --- a/crates/punktfunk-core/src/abi.rs +++ b/crates/punktfunk-core/src/abi.rs @@ -670,6 +670,12 @@ pub const PUNKTFUNK_HIDOUT_TRIGGER: u8 = 3; /// side (0 = right pad, 1 = left pad); `effect[0..6]` packs `amplitude` / `period` / `count` as /// little-endian `u16`s with `effect_len = 6`. Clients without trackpad coils drop it. pub const PUNKTFUNK_HIDOUT_TRACKPAD_HAPTIC: u8 = 4; +/// `PunktfunkHidOutput::kind` — the audio-control region of a DS5 output report (pad-audio +/// routing/volumes; the audio SAMPLES arrive via [`punktfunk_connection_next_pad_audio`]). +/// `which` = the condensed audio flags (bit0 = haptics-select, bits1..4 = the report's +/// audio-valid flags); `effect[0..6]` = bytes 5..=10 of the report verbatim +/// (headphone/speaker/mic volumes + routing) with `effect_len = 6`. Forwarded change-only. +pub const PUNKTFUNK_HIDOUT_AUDIO_CTL: u8 = 5; /// Capacity of `PunktfunkHidOutput::effect` (the DualSense trigger parameter block). pub const PUNKTFUNK_HID_EFFECT_MAX: u8 = 11; @@ -759,6 +765,16 @@ impl PunktfunkHidOutput { out.effect_len = 6; } HidOutput::HidRaw { .. } => return None, + HidOutput::AudioCtl { pad, flags, raw } => { + // Same packing idiom as TrackpadHaptic: `which` carries the flags byte, + // `effect[0..6]` the raw audio region. The u16 wire pad narrows losslessly — + // pads are 0..16 (`input::MAX_PADS`) end to end. + out.kind = PUNKTFUNK_HIDOUT_AUDIO_CTL; + out.pad = *pad as u8; + out.which = *flags; + out.effect[0..6].copy_from_slice(raw); + out.effect_len = 6; + } } Some(out) } @@ -1172,6 +1188,25 @@ pub const PUNKTFUNK_HOST_CAP_CLIPBOARD: u8 = 0x02; /// the client keeps its pen-as-touch fallback. (Mirrors `quic::HOST_CAP_PEN`; /// design/pen-tablet-input.md.) pub const PUNKTFUNK_HOST_CAP_PEN: u8 = 0x10; +/// Host-capability bit in [`punktfunk_connection_host_caps`]: the host can capture per-gamepad +/// audio (DualSense voice-coil haptics + speaker) and emit it on the 0xD1 plane toward pads +/// declared capable via [`punktfunk_connection_set_pad_audio_caps`]. Set only when the client +/// asked via [`PUNKTFUNK_CLIENT_CAP_PAD_AUDIO`]. (Mirrors `quic::HOST_CAP_PAD_AUDIO`.) +pub const PUNKTFUNK_HOST_CAP_PAD_AUDIO: u8 = 0x20; + +/// Pad-audio `kind` ([`punktfunk_connection_next_pad_audio`]): the BACK channel pair — DualSense +/// voice-coil haptics, 5 ms Opus frames. (Mirrors `quic::PAD_AUDIO_KIND_HAPTICS`.) +pub const PUNKTFUNK_PAD_AUDIO_KIND_HAPTICS: u8 = 0; +/// Pad-audio `kind`: the FRONT channel pair — the controller's built-in speaker, 10 ms Opus +/// frames. (Mirrors `quic::PAD_AUDIO_KIND_SPEAKER`.) +pub const PUNKTFUNK_PAD_AUDIO_KIND_SPEAKER: u8 = 1; + +/// [`punktfunk_connection_set_pad_audio_caps`] `audio_caps` bit: the pad renders the HAPTICS +/// stream (a real DualSense's voice coils). +pub const PUNKTFUNK_PAD_AUDIO_CAP_HAPTICS: u8 = 0x01; +/// [`punktfunk_connection_set_pad_audio_caps`] `audio_caps` bit: the pad renders the SPEAKER +/// stream. +pub const PUNKTFUNK_PAD_AUDIO_CAP_SPEAKER: u8 = 0x02; // Keep the ABI cap bits in lockstep with the wire constants (compile-time guard against drift). #[cfg(feature = "quic")] @@ -1186,6 +1221,20 @@ const _: () = { assert!(PUNKTFUNK_HOST_CAP_GAMEPAD_STATE == crate::quic::HOST_CAP_GAMEPAD_STATE); assert!(PUNKTFUNK_HOST_CAP_CLIPBOARD == crate::quic::HOST_CAP_CLIPBOARD); assert!(PUNKTFUNK_HOST_CAP_PEN == crate::quic::HOST_CAP_PEN); + assert!(PUNKTFUNK_HOST_CAP_PAD_AUDIO == crate::quic::HOST_CAP_PAD_AUDIO); + assert!(PUNKTFUNK_CLIENT_CAP_PAD_AUDIO == crate::quic::CLIENT_CAP_PAD_AUDIO); + assert!(PUNKTFUNK_PAD_AUDIO_KIND_HAPTICS == crate::quic::PAD_AUDIO_KIND_HAPTICS); + assert!(PUNKTFUNK_PAD_AUDIO_KIND_SPEAKER == crate::quic::PAD_AUDIO_KIND_SPEAKER); + // The setter's caps bits are the arrival flags bits 8/9 shifted down (the wire packing + // `input::encode_gamepad_arrival` applies). + assert!( + (PUNKTFUNK_PAD_AUDIO_CAP_HAPTICS as u32) << 8 + == crate::input::ARRIVAL_FLAG_PAD_AUDIO_HAPTICS + ); + assert!( + (PUNKTFUNK_PAD_AUDIO_CAP_SPEAKER as u32) << 8 + == crate::input::ARRIVAL_FLAG_PAD_AUDIO_SPEAKER + ); assert!(PUNKTFUNK_PEN_IN_RANGE == crate::quic::PEN_IN_RANGE); assert!(PUNKTFUNK_PEN_TOUCHING == crate::quic::PEN_TOUCHING); assert!(PUNKTFUNK_PEN_BARREL1 == crate::quic::PEN_BARREL1); @@ -1768,6 +1817,13 @@ pub const PUNKTFUNK_CLIENT_CAP_CURSOR: u8 = 0x01; /// forward-compatible. pub const PUNKTFUNK_CLIENT_CAP_PHASE_LOCK: u8 = 0x02; +/// [`punktfunk_connect_ex9`] `client_caps` bit: the client understands the pad-audio plane +/// (0xD1 — per-gamepad DualSense voice-coil haptics + speaker). The embedder MUST then drain +/// [`punktfunk_connection_next_pad_audio`] and declare each capable pad via +/// [`punktfunk_connection_set_pad_audio_caps`]; the host emits pad audio only when it answers +/// with [`PUNKTFUNK_HOST_CAP_PAD_AUDIO`]. (Mirrors `quic::CLIENT_CAP_PAD_AUDIO`.) +pub const PUNKTFUNK_CLIENT_CAP_PAD_AUDIO: u8 = 0x04; + /// Shared body of [`punktfunk_connect_ex7`] / [`punktfunk_connect_ex8`]: `status_out` /// (nullable) is written on EVERY path — `Ok`, the mapped [`PunktfunkError`], /// `InvalidArg` for bad arguments, `Panic` if the connect panicked. @@ -2312,6 +2368,117 @@ pub unsafe extern "C" fn punktfunk_connection_next_audio_pcm( }) } +/// Pull the next pad-audio frame (0xD1) — one Opus frame of DualSense voice-coil haptics +/// (`kind` = [`PUNKTFUNK_PAD_AUDIO_KIND_HAPTICS`], 5 ms) or built-in-speaker audio +/// ([`PUNKTFUNK_PAD_AUDIO_KIND_SPEAKER`], 10 ms) for gamepad `*out_pad` — waiting up to +/// `timeout_ms`. The payload is COPIED into `buf` (no borrow-until-next-call slot); the return +/// value is its length in bytes, `0` = nothing this poll (timeout — or a DTX/oversized frame, +/// both of which an embedder treats the same way), `-1` = the session ended (or an invalid +/// handle/buffer). All pads/kinds share one queue — fan out by `*out_pad`/`*out_kind` to +/// per-actuator Opus decoders. A frame larger than `buf_len` is dropped like the timeout case +/// (the plane is lossy by design; any real Opus frame fits a 1500-byte buffer). Only a session +/// connected with [`PUNKTFUNK_CLIENT_CAP_PAD_AUDIO`] against a +/// [`PUNKTFUNK_HOST_CAP_PAD_AUDIO`] host — with the pad declared via +/// [`punktfunk_connection_set_pad_audio_caps`] — ever receives any. Drain from a dedicated +/// thread (one puller, may run alongside the other planes' pullers). +/// +/// # Safety +/// `c` is a valid connection handle; the `out_*` pointers are writable (NULLs are skipped); +/// `buf` is writable for `buf_len` bytes. +#[cfg(feature = "quic")] +#[no_mangle] +pub unsafe extern "C" fn punktfunk_connection_next_pad_audio( + c: *mut PunktfunkConnection, + out_pad: *mut u8, + out_kind: *mut u8, + out_seq: *mut u32, + out_pts_ns: *mut u64, + buf: *mut u8, + buf_len: usize, + timeout_ms: u32, +) -> i32 { + let r = std::panic::catch_unwind(AssertUnwindSafe(|| { + // SAFETY: per the ABI contract - an opaque handle from a `*_new`/`*_pair` that the caller + // has not yet freed, or null, which `as_mut`/`as_ref` reports as `None` and the `match` + // here handles. + let c = match unsafe { c.as_ref() } { + Some(c) => c, + None => return -1, + }; + if buf.is_null() && buf_len != 0 { + return -1; + } + match c + .inner + .next_pad_audio(std::time::Duration::from_millis(timeout_ms as u64)) + { + Some(f) => { + if f.opus.is_empty() || f.opus.len() > buf_len { + // DTX silence (skipped like the audio-PCM path — decoding an empty payload + // as loss would synthesize concealment) or doesn't fit — report "nothing + // this poll" (the next_hidout HidRaw-skip precedent; truncated Opus would + // be undecodable anyway). + return 0; + } + // SAFETY: per the ABI contract - each out-param below is OPTIONAL, so it is null- + // checked before it is written; `buf` is a caller-owned writable region of + // `buf_len` bytes and the copy length was just bounds-checked against it. + unsafe { + if !out_pad.is_null() { + *out_pad = f.pad; + } + if !out_kind.is_null() { + *out_kind = f.kind; + } + if !out_seq.is_null() { + *out_seq = f.seq; + } + if !out_pts_ns.is_null() { + *out_pts_ns = f.pts_ns; + } + std::ptr::copy_nonoverlapping(f.opus.as_ptr(), buf, f.opus.len()); + } + f.opus.len() as i32 + } + // `None` folds timeout and closed; the shutdown flag tells them apart so the + // embedder's plane loop can exit instead of polling a dead session forever. + None if c.inner.is_session_ended() => -1, + None => 0, + } + })); + r.unwrap_or(-1) +} + +/// Declare wire pad `pad`'s pad-audio render capabilities (`audio_caps`: OR of +/// [`PUNKTFUNK_PAD_AUDIO_CAP_HAPTICS`] / [`PUNKTFUNK_PAD_AUDIO_CAP_SPEAKER`]) — how a client +/// tells the host WHICH pads can actually play the 0xD1 streams. Call at controller attach, +/// BEFORE the pad's arrival event is sent (the [`punktfunk_connection_set_rumble_quirks`] +/// timing): the core folds the bits into the arrival's flags (bits 8/9), and only toward a +/// [`PUNKTFUNK_HOST_CAP_PAD_AUDIO`] host — never calling this leaves the wire bytes exactly as +/// before. Latest-wins per pad; unknown bits are masked off. +/// +/// # Safety +/// `c` is a valid connection handle. Callable from any thread. +#[cfg(feature = "quic")] +#[no_mangle] +pub unsafe extern "C" fn punktfunk_connection_set_pad_audio_caps( + c: *mut PunktfunkConnection, + pad: u8, + audio_caps: u8, +) -> PunktfunkStatus { + guard(|| { + // SAFETY: per the ABI contract - an opaque handle from a `*_new`/`*_pair` that the caller + // has not yet freed, or null, which `as_mut`/`as_ref` reports as `None` and the `match` + // here handles. + let c = match unsafe { c.as_ref() } { + Some(c) => c, + None => return PunktfunkStatus::NullPointer, + }; + c.inner.set_pad_audio_caps(pad, audio_caps); + PunktfunkStatus::Ok + }) +} + /// Pull the next rumble (force-feedback) update, waiting up to `timeout_ms`. Amplitudes /// are 0..0xFFFF (`low` = low-frequency motor, `high` = high-frequency), `(0, 0)` = stop. /// Same timeout/closed semantics as [`punktfunk_connection_next_audio`]. @@ -4408,3 +4575,36 @@ pub unsafe extern "C" fn punktfunk_reanchor_gate_is_holding( PunktfunkStatus::Ok }) } + +#[cfg(all(test, feature = "quic"))] +mod tests { + use super::*; + + /// The `AudioCtl` → `PunktfunkHidOutput` mapping: kind 5, pad narrowed, `which` carries the + /// flags byte, `effect[0..6]` the raw audio region with `effect_len = 6` (the TrackpadHaptic + /// packing idiom — no struct growth, so the size guard above stays at 19). + #[test] + fn hidout_abi_maps_audio_ctl() { + let out = PunktfunkHidOutput::from_hid(&crate::quic::HidOutput::AudioCtl { + pad: 3, + flags: 0x17, + raw: [0x50, 0x60, 0x70, 0x05, 0, 0], + }) + .unwrap(); + assert_eq!(out.kind, PUNKTFUNK_HIDOUT_AUDIO_CTL); + assert_eq!(out.pad, 3); + assert_eq!(out.which, 0x17); + assert_eq!(out.effect_len, 6); + assert_eq!(out.effect[..6], [0x50, 0x60, 0x70, 0x05, 0, 0]); + assert_eq!(out.effect[6..], [0; 5]); + // A raw passthrough report still has no C representation (skipped at the pull site). + assert!( + PunktfunkHidOutput::from_hid(&crate::quic::HidOutput::HidRaw { + pad: 0, + kind: 0, + data: vec![0x80], + }) + .is_none() + ); + } +} diff --git a/crates/punktfunk-core/src/client/mod.rs b/crates/punktfunk-core/src/client/mod.rs index eb9234b4..dcb99354 100644 --- a/crates/punktfunk-core/src/client/mod.rs +++ b/crates/punktfunk-core/src/client/mod.rs @@ -16,11 +16,13 @@ use crate::config::{CompositorPref, GamepadPref, Mode}; use crate::error::{PunktfunkError, Result}; use crate::input::InputEvent; use crate::quic::{ - endpoint, ClipControl, ClipKind, ClipOffer, ColorInfo, HdrMeta, HidOutput, ProbeRequest, - RfiRequest, RichInput, + endpoint, ClipControl, ClipKind, ClipOffer, ColorInfo, HdrMeta, HidOutput, PadAudioFrame, + ProbeRequest, RfiRequest, RichInput, }; use crate::session::Frame; -use std::sync::atomic::{AtomicBool, AtomicI64, AtomicU16, AtomicU32, AtomicU64, Ordering}; +use std::sync::atomic::{ + AtomicBool, AtomicI64, AtomicU16, AtomicU32, AtomicU64, AtomicU8, Ordering, +}; use std::sync::mpsc::{Receiver, RecvTimeoutError}; use std::sync::{Arc, Mutex}; use std::time::{Duration, Instant}; @@ -43,7 +45,7 @@ use self::control::{CtrlRequest, Negotiated}; use self::frame_channel::{DecodeLatAcc, FrameChannel, FramePop}; use self::planes::{ RumbleUpdate, AUDIO_QUEUE, CLIP_EVENT_QUEUE, CURSOR_SHAPE_QUEUE, CURSOR_STATE_QUEUE, - HDR_META_QUEUE, HIDOUT_QUEUE, HOST_TIMING_QUEUE, RUMBLE_QUEUE, + HDR_META_QUEUE, HIDOUT_QUEUE, HOST_TIMING_QUEUE, PAD_AUDIO_QUEUE, RUMBLE_QUEUE, }; use self::probe::ProbeState; use self::pump::run_pump; @@ -122,6 +124,14 @@ pub struct NativeClient { rumble_sched: Arc, /// Inbound DualSense feedback (lightbar / player LEDs / adaptive triggers) — 0xCD datagrams. hidout: Mutex>, + /// Inbound pad audio (DualSense voice-coil haptics + speaker Opus frames) — 0xD1 datagrams. + /// Only a session that advertised [`quic::CLIENT_CAP_PAD_AUDIO`] against a + /// [`quic::HOST_CAP_PAD_AUDIO`] host ever receives any. + pad_audio: Mutex>, + /// Per-pad pad-audio render capabilities (bit0 haptics, bit1 speaker), written by + /// [`NativeClient::set_pad_audio_caps`] and OR'd into outgoing gamepad-arrival flags + /// (bits 8/9) by the worker's input task — toward a `HOST_CAP_PAD_AUDIO` host only. + pad_audio_caps: Arc<[AtomicU8; crate::input::MAX_PADS]>, /// Inbound static HDR metadata (ST.2086 mastering + content light level) — 0xCE datagrams. hdr_meta: Mutex>, /// Inbound per-AU host capture→send timings — 0xCF datagrams (the client always advertises @@ -418,6 +428,10 @@ impl NativeClient { let rumble_sched = Arc::new(rumble::RumbleShared::new()); let rumble_feed = rumble::RumbleFeed(rumble_sched.clone()); let (hidout_tx, hidout_rx) = std::sync::mpsc::sync_channel::(HIDOUT_QUEUE); + let (pad_audio_tx, pad_audio_rx) = + std::sync::mpsc::sync_channel::(PAD_AUDIO_QUEUE); + let pad_audio_caps: Arc<[AtomicU8; crate::input::MAX_PADS]> = + Arc::new(std::array::from_fn(|_| AtomicU8::new(0))); let (hdr_meta_tx, hdr_meta_rx) = std::sync::mpsc::sync_channel::(HDR_META_QUEUE); let (host_timing_tx, host_timing_rx) = std::sync::mpsc::sync_channel::(HOST_TIMING_QUEUE); @@ -459,6 +473,7 @@ impl NativeClient { let clock_offset_w = clock_offset.clone(); let decode_lat_w = decode_lat.clone(); let live_bitrate_w = live_bitrate.clone(); + let pad_audio_caps_w = pad_audio_caps.clone(); let ctrl_tx_pump = ctrl_tx.clone(); // the data-plane pump sends adaptive-FEC LossReports let worker = std::thread::Builder::new() .name("punktfunk-client".into()) @@ -502,6 +517,8 @@ impl NativeClient { rumble_tx, rumble_feed, hidout_tx, + pad_audio_tx, + pad_audio_caps: pad_audio_caps_w, hdr_meta_tx, host_timing_tx, cursor_shape_tx, @@ -550,6 +567,8 @@ impl NativeClient { rumble: Mutex::new(rumble_rx), rumble_sched, hidout: Mutex::new(hidout_rx), + pad_audio: Mutex::new(pad_audio_rx), + pad_audio_caps, hdr_meta: Mutex::new(hdr_meta_rx), host_timing: Mutex::new(host_timing_rx), cursor_shape: Mutex::new(cursor_shape_rx), @@ -1051,6 +1070,33 @@ impl NativeClient { } } + /// Pull the next pad-audio frame (0xD1): one Opus frame of DualSense voice-coil haptics + /// ([`quic::PAD_AUDIO_KIND_HAPTICS`], 5 ms) or built-in-speaker audio + /// ([`quic::PAD_AUDIO_KIND_SPEAKER`], 10 ms) for gamepad `pad`. All pads/kinds share the + /// queue — the embedder fans out by `pad`/`kind` to per-actuator Opus decoders. `None` on + /// timeout AND once the session ended ([`is_session_ended`](Self::is_session_ended) + /// distinguishes, and the plane is best-effort either way). Only a session that advertised + /// [`quic::CLIENT_CAP_PAD_AUDIO`] against a [`quic::HOST_CAP_PAD_AUDIO`] host — with the + /// pad's render caps declared via [`set_pad_audio_caps`](Self::set_pad_audio_caps) — ever + /// receives any. Drain on a dedicated thread like [`next_audio`](Self::next_audio); one + /// puller per the plane contract. + pub fn next_pad_audio(&self, timeout: Duration) -> Option { + self.pad_audio.lock().unwrap().recv_timeout(timeout).ok() + } + + /// Declare wire pad `pad`'s pad-audio render capabilities: `audio_caps` bit0 = the pad can + /// play the HAPTICS stream (a real DualSense's voice coils), bit1 = the SPEAKER stream. + /// Call at controller attach, BEFORE the pad's arrival is sent (like + /// [`set_rumble_quirks`](Self::set_rumble_quirks)) — the worker ORs the bits into the + /// arrival's flags (bits 8/9), and only toward a [`quic::HOST_CAP_PAD_AUDIO`] host, so an + /// embedder that never calls this (or a host that can't capture pad audio) leaves the wire + /// bytes exactly as before. Latest-wins per pad; unknown bits are masked off. + pub fn set_pad_audio_caps(&self, pad: u8, audio_caps: u8) { + if let Some(slot) = self.pad_audio_caps.get(pad as usize) { + slot.store(audio_caps & 0x03, Ordering::Relaxed); + } + } + /// Pull the next static HDR metadata update (ST.2086 mastering display + content light level) /// the host sent for an HDR session; same timeout/closed semantics as /// [`NativeClient::next_hidout`]. The host sends one near session start and re-sends it on diff --git a/crates/punktfunk-core/src/client/planes.rs b/crates/punktfunk-core/src/client/planes.rs index e70b2fbc..b84170fd 100644 --- a/crates/punktfunk-core/src/client/planes.rs +++ b/crates/punktfunk-core/src/client/planes.rs @@ -20,6 +20,12 @@ pub(crate) type RumbleUpdate = (u16, u16, u16, Option); /// Same overflow discipline as rumble; the host re-sends on the next feedback change. pub(crate) const HIDOUT_QUEUE: usize = 32; +/// Pad-audio frames (`0xD1` — DualSense voice-coil haptics + speaker) buffered for the embedder, +/// ALL pads and kinds on one queue (the embedder fans out by `pad`/`kind`): 64 × 5 ms = 320 ms of +/// slack on a haptics-only stream, the [`AUDIO_QUEUE`] discipline. A lagging embedder drops the +/// newest frame (the renderer conceals the gap). +pub(crate) const PAD_AUDIO_QUEUE: usize = 64; + /// Static HDR metadata (ST.2086 mastering + content light level) buffered for the embedder. Tiny /// and low-rate (one on start, re-sent on mastering changes / keyframes); a small ring is ample. pub(crate) const HDR_META_QUEUE: usize = 8; diff --git a/crates/punktfunk-core/src/client/pump.rs b/crates/punktfunk-core/src/client/pump.rs index cdd2d1b0..e6ab8e58 100644 --- a/crates/punktfunk-core/src/client/pump.rs +++ b/crates/punktfunk-core/src/client/pump.rs @@ -50,6 +50,8 @@ pub(super) async fn run_pump(args: WorkerArgs) { rumble_tx, rumble_feed, hidout_tx, + pad_audio_tx, + pad_audio_caps, hdr_meta_tx, host_timing_tx, cursor_shape_tx, @@ -92,9 +94,17 @@ pub(super) async fn run_pump(args: WorkerArgs) { // Input task: embedder events → uplink datagrams, with per-transition gamepad events // folded into idempotent seq-stamped snapshots toward a HOST_CAP_GAMEPAD_STATE host - // (see [`input_task`]). + // (see [`input_task`]). Pad-audio render caps ride arrival flags bits 8/9 ONLY toward a + // HOST_CAP_PAD_AUDIO host — an older host reads the whole flags word as the pad index. let gamepad_snapshots = host_caps & crate::quic::HOST_CAP_GAMEPAD_STATE != 0; - tokio::spawn(input_task::run(conn.clone(), input_rx, gamepad_snapshots)); + let pad_audio_arrivals = host_caps & crate::quic::HOST_CAP_PAD_AUDIO != 0; + tokio::spawn(input_task::run( + conn.clone(), + input_rx, + gamepad_snapshots, + pad_audio_arrivals, + pad_audio_caps, + )); // Mic task: embedder Opus mic frames → 0xCB uplink datagrams (best-effort, dropped on loss). // Self-healing latency bound: every frame still queued once this task catches up is standing @@ -166,6 +176,7 @@ pub(super) async fn run_pump(args: WorkerArgs) { rumble_tx, rumble_feed, hidout_tx, + pad_audio_tx, hdr_meta_tx, host_timing_tx, encode_lat.clone(), diff --git a/crates/punktfunk-core/src/client/pump/datagram_task.rs b/crates/punktfunk-core/src/client/pump/datagram_task.rs index b62c8dd7..f0089e47 100644 --- a/crates/punktfunk-core/src/client/pump/datagram_task.rs +++ b/crates/punktfunk-core/src/client/pump/datagram_task.rs @@ -12,6 +12,7 @@ pub(super) async fn run( rumble_tx: std::sync::mpsc::SyncSender, rumble_feed: super::super::rumble::RumbleFeed, hidout_tx: std::sync::mpsc::SyncSender, + pad_audio_tx: std::sync::mpsc::SyncSender, hdr_meta_tx: std::sync::mpsc::SyncSender, host_timing_tx: std::sync::mpsc::SyncSender, // The ABR encode signal's accumulator (see [`EncodeLatAcc`]) — fed HERE, not off @@ -70,6 +71,11 @@ pub(super) async fn run( let _ = hidout_tx.try_send(h); } } + Some(&crate::quic::PAD_AUDIO_MAGIC) => { + if let Some(f) = crate::quic::decode_pad_audio_datagram(&d) { + let _ = pad_audio_tx.try_send(f); + } + } Some(&crate::quic::HDR_META_MAGIC) => { if let Some(m) = crate::quic::decode_hdr_meta_datagram(&d) { let _ = hdr_meta_tx.try_send(m); diff --git a/crates/punktfunk-core/src/client/pump/input_task.rs b/crates/punktfunk-core/src/client/pump/input_task.rs index 8bcbc5a0..1038eda7 100644 --- a/crates/punktfunk-core/src/client/pump/input_task.rs +++ b/crates/punktfunk-core/src/client/pump/input_task.rs @@ -15,8 +15,16 @@ pub(super) async fn run( conn: quinn::Connection, mut input_rx: tokio::sync::mpsc::UnboundedReceiver, gamepad_snapshots: bool, + // Whether the host advertised HOST_CAP_PAD_AUDIO: only then do arrivals carry the per-pad + // audio-render bits (flags 8/9) — an older host reads the whole flags word as the pad index, + // so unexpected high bits would make it drop the kind declaration entirely. + pad_audio: bool, + // Per-pad audio-render capabilities (bit0 haptics, bit1 speaker), fed by the embedder via + // [`NativeClient::set_pad_audio_caps`] and by arrival events already carrying the bits. + pad_audio_caps: std::sync::Arc<[std::sync::atomic::AtomicU8; crate::input::MAX_PADS]>, ) { use crate::input::{GamepadSnapshot, InputKind, MAX_PADS}; + use std::sync::atomic::Ordering; // Touched pads only: an entry appears on the first gamepad event for that index, so the // refresh never conjures a virtual pad the embedder didn't drive. let mut pads: [Option; MAX_PADS] = [None; MAX_PADS]; @@ -37,6 +45,17 @@ pub(super) async fn run( const ARRIVAL_RESENDS: u8 = 2; let mut arrival: [Option; MAX_PADS] = [None; MAX_PADS]; let mut arrival_owed: [u8; MAX_PADS] = [0; MAX_PADS]; + // An arrival's outgoing flags word: the pad index, plus the pad's audio-render bits (8/9) + // toward a HOST_CAP_PAD_AUDIO host. With no declared caps (or an older host) this is + // byte-identical to the plain index — the pre-pad-audio wire. + let arrival_flags = |idx: usize| -> u32 { + let caps = if pad_audio { + pad_audio_caps[idx].load(Ordering::Relaxed) + } else { + 0 + }; + crate::input::encode_gamepad_arrival(idx as u8, caps) + }; let mut refresh = tokio::time::interval(Duration::from_millis(100)); refresh.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay); loop { @@ -81,13 +100,28 @@ pub(super) async fn run( let _ = conn.send_datagram(rem.encode().to_vec().into()); continue; } - if gamepad_snapshots && ev.kind == InputKind::GamepadArrival && idx < MAX_PADS { - // Remember the declared kind (`code`) and forward it, arming a re-send burst - // so the host learns it before the pad's first frame even under loss. - arrival[idx] = Some(ev.code as u8); - arrival_owed[idx] = ARRIVAL_RESENDS; - let _ = conn.send_datagram(ev.encode().to_vec().into()); - continue; + if gamepad_snapshots && ev.kind == InputKind::GamepadArrival { + // The index is the LOW BYTE only — bits 8/9 may carry the pad's audio-render + // caps (an embedder building raw events; the `set_pad_audio_caps` registry is + // the usual source). Fold event-carried bits into the registry so the re-send + // burst keeps them, then send with the negotiation-gated flags word. + let (pad, ev_caps) = crate::input::decode_gamepad_arrival(ev.flags); + let idx = pad as usize; + if idx < MAX_PADS { + if ev_caps != 0 { + pad_audio_caps[idx].fetch_or(ev_caps, Ordering::Relaxed); + } + // Remember the declared kind (`code`) and forward it, arming a re-send + // burst so the host learns it before the pad's first frame even under loss. + arrival[idx] = Some(ev.code as u8); + arrival_owed[idx] = ARRIVAL_RESENDS; + let arr = crate::input::InputEvent { + flags: arrival_flags(idx), + ..ev + }; + let _ = conn.send_datagram(arr.encode().to_vec().into()); + continue; + } } let _ = conn.send_datagram(ev.encode().to_vec().into()); } @@ -104,7 +138,7 @@ pub(super) async fn run( code: kind as u32, x: 0, y: 0, - flags: idx as u32, + flags: arrival_flags(idx), }; let _ = conn.send_datagram(arr.encode().to_vec().into()); } else { diff --git a/crates/punktfunk-core/src/client/worker.rs b/crates/punktfunk-core/src/client/worker.rs index 669acdf1..35685135 100644 --- a/crates/punktfunk-core/src/client/worker.rs +++ b/crates/punktfunk-core/src/client/worker.rs @@ -5,8 +5,8 @@ use crate::clipboard::{ClipCommand, ClipEventCore}; use crate::config::{CompositorPref, GamepadPref, Mode}; use crate::error::Result; use crate::input::InputEvent; -use crate::quic::{HdrMeta, HidOutput}; -use std::sync::atomic::{AtomicBool, AtomicI64, AtomicU32, AtomicU64}; +use crate::quic::{HdrMeta, HidOutput, PadAudioFrame}; +use std::sync::atomic::{AtomicBool, AtomicI64, AtomicU32, AtomicU64, AtomicU8}; use std::sync::mpsc::SyncSender; use std::sync::{Arc, Mutex}; @@ -43,6 +43,14 @@ pub(crate) struct WorkerArgs { /// closed, so the command API always observes connection teardown. pub(crate) rumble_feed: super::rumble::RumbleFeed, pub(crate) hidout_tx: SyncSender, + /// Inbound pad-audio frames (`0xD1` — DualSense voice-coil haptics + speaker), drained by + /// [`NativeClient::next_pad_audio`]. + pub(crate) pad_audio_tx: SyncSender, + /// Per-pad pad-audio render capabilities (bit0 haptics, bit1 speaker), written by + /// [`NativeClient::set_pad_audio_caps`] and OR'd into outgoing + /// [`GamepadArrival`](crate::input::InputKind::GamepadArrival) flags (bits 8/9) by the input + /// task — toward a `HOST_CAP_PAD_AUDIO` host only. + pub(crate) pad_audio_caps: Arc<[AtomicU8; crate::input::MAX_PADS]>, pub(crate) hdr_meta_tx: SyncSender, pub(crate) host_timing_tx: SyncSender, pub(crate) cursor_shape_tx: SyncSender, diff --git a/crates/punktfunk-core/src/input.rs b/crates/punktfunk-core/src/input.rs index 49da2129..12a5db94 100644 --- a/crates/punktfunk-core/src/input.rs +++ b/crates/punktfunk-core/src/input.rs @@ -64,7 +64,11 @@ pub enum InputKind { GamepadRemove = 13, /// Declares which controller KIND a pad presents so a session can MIX types (pad 0 a /// DualSense, pad 1 an Xbox pad). `code` = the [`GamepadPref`](crate::config::GamepadPref) - /// wire byte, `flags` = pad index. Sent when the client opens a pad slot — before that pad's + /// wire byte, `flags` = pad index in the low byte plus the pad's render capabilities in bits + /// 8/9 ([`ARRIVAL_FLAG_PAD_AUDIO_HAPTICS`]/[`ARRIVAL_FLAG_PAD_AUDIO_SPEAKER`] — sent only + /// toward a [`HOST_CAP_PAD_AUDIO`](crate::quic::HOST_CAP_PAD_AUDIO) host, so an older host + /// keeps reading the whole word as the index; hosts decode via [`decode_gamepad_arrival`]). + /// Sent when the client opens a pad slot — before that pad's /// first input — and re-sent a few times against datagram loss (like [`GamepadRemove`]). The /// host resolves the kind to a buildable backend and routes that pad's virtual device to it; a /// pad the client never declares (an older client, or a fully-lost declaration) falls back to @@ -97,6 +101,34 @@ pub fn decode_gamepad_remove(flags: u32) -> (u8, u8) { (flags as u8, (flags >> 24) as u8) } +/// [`InputKind::GamepadArrival`] `flags` bit: this pad renders pad-audio HAPTICS — it is (or +/// forwards to) a real DualSense whose voice-coil actuators can play the +/// [`PAD_AUDIO_KIND_HAPTICS`](crate::quic::PAD_AUDIO_KIND_HAPTICS) stream. Rides above the pad +/// index byte; sent only toward a [`HOST_CAP_PAD_AUDIO`](crate::quic::HOST_CAP_PAD_AUDIO) host +/// (an older host reads the whole `flags` word as the index, so unexpected high bits would make +/// it drop the declaration). +pub const ARRIVAL_FLAG_PAD_AUDIO_HAPTICS: u32 = 1 << 8; +/// [`InputKind::GamepadArrival`] `flags` bit: this pad renders pad-audio SPEAKER — the +/// [`PAD_AUDIO_KIND_SPEAKER`](crate::quic::PAD_AUDIO_KIND_SPEAKER) stream. Same wire discipline +/// as [`ARRIVAL_FLAG_PAD_AUDIO_HAPTICS`]. +pub const ARRIVAL_FLAG_PAD_AUDIO_SPEAKER: u32 = 1 << 9; + +/// Pack a [`InputKind::GamepadArrival`] `flags` word: the pad index in the low byte plus +/// `audio_caps` (bit0 = haptics, bit1 = speaker) as bits 8/9. `audio_caps = 0` reproduces the +/// pre-pad-audio wire bytes exactly. +pub fn encode_gamepad_arrival(pad: u8, audio_caps: u8) -> u32 { + (pad as u32) | (((audio_caps & 0x03) as u32) << 8) +} + +/// Unpack a [`InputKind::GamepadArrival`] `flags` word into `(pad, audio_caps)`. The pad index +/// is `flags & 0xFF` — hosts MUST mask rather than take the whole word, or a capability bit +/// reads as a phantom index; `audio_caps` is bits 8/9 (bit0 = haptics, bit1 = speaker — the +/// [`ARRIVAL_FLAG_PAD_AUDIO_HAPTICS`]/[`ARRIVAL_FLAG_PAD_AUDIO_SPEAKER`] bits shifted down). +/// An old-format word (index only) yields `audio_caps = 0`. +pub fn decode_gamepad_arrival(flags: u32) -> (u8, u8) { + (flags as u8, ((flags >> 8) & 0x03) as u8) +} + /// The gamepad wire contract for [`InputKind::GamepadButton`]/[`InputKind::GamepadAxis`]. /// /// Everything follows the GameStream/XInput conventions end to end: buttons reuse @@ -348,6 +380,11 @@ pub enum GamepadEvent { kind: u8, /// LI_CCAP_* bits (0x02 = rumble). capabilities: u16, + /// Pad-audio render capabilities from a NATIVE-plane arrival's `flags` bits 8/9 + /// (bit0 = haptics, bit1 = speaker — see [`decode_gamepad_arrival`]). NOT a GameStream + /// LI_CCAP bit (that vocabulary lives in `capabilities`); the GameStream plane cannot + /// express pad audio and always sets `0`, as does an old client. + audio_caps: u8, }, } @@ -443,6 +480,31 @@ mod tests { assert_eq!((pad, seq), (9, 123)); } + #[test] + fn gamepad_arrival_flags_roundtrip() { + // The capability bits ride bits 8/9; the index stays the low byte. + for (pad, caps) in [(0u8, 0u8), (3, 0b01), (15, 0b10), (7, 0b11)] { + let flags = encode_gamepad_arrival(pad, caps); + assert_eq!(decode_gamepad_arrival(flags), (pad, caps)); + assert_eq!(flags & 0xFF, pad as u32); + } + assert_eq!( + encode_gamepad_arrival(2, 0b11), + 2 | ARRIVAL_FLAG_PAD_AUDIO_HAPTICS | ARRIVAL_FLAG_PAD_AUDIO_SPEAKER + ); + // Old-format compat both ways: a caps-less word (an old client, or a new one toward an + // old host) is byte-identical to the plain index, and decodes with caps 0. + assert_eq!(encode_gamepad_arrival(5, 0), 5); + assert_eq!(decode_gamepad_arrival(5), (5, 0)); + // Undefined high bits (a future extension) never leak into the index OR the caps. + assert_eq!( + decode_gamepad_arrival(0xFFFF_0000 | (0b01 << 8) | 9), + (9, 1) + ); + // encode masks unknown caps bits, so a sloppy embedder can't corrupt the index space. + assert_eq!(encode_gamepad_arrival(1, 0xFF), 1 | (0b11 << 8)); + } + #[test] fn gamepad_snapshot_roundtrip() { let s = GamepadSnapshot { diff --git a/crates/punktfunk-core/src/lib.rs b/crates/punktfunk-core/src/lib.rs index c482a017..ca4f5a12 100644 --- a/crates/punktfunk-core/src/lib.rs +++ b/crates/punktfunk-core/src/lib.rs @@ -120,7 +120,13 @@ pub use stats::Stats; /// uncertainty and the circular arrival-lead statistic the host's controller steers on. Additive; /// the wire grows only a new control message (`PhaseReport`, 0x32) an old host never reads and a /// strict-prefix append on the 0xCF host-timing tail, so [`WIRE_VERSION`] is unchanged. -pub const ABI_VERSION: u32 = 14; +/// v15: added the pad-audio client surface — `punktfunk_connection_next_pad_audio` (the 0xD1 +/// per-gamepad DualSense haptics/speaker plane) + `punktfunk_connection_set_pad_audio_caps` and +/// the `PUNKTFUNK_CLIENT_CAP_PAD_AUDIO` / `PUNKTFUNK_HOST_CAP_PAD_AUDIO` mirrors. Additive and +/// capability-gated end to end: the wire grows a new datagram tag (0xD1) an old client never +/// receives (double-gated caps), a new 0xCD kind (0x06, dropped as unknown by old clients) and +/// arrival flag bits 8/9 sent only toward a capable host, so [`WIRE_VERSION`] is unchanged. +pub const ABI_VERSION: u32 = 15; /// The punktfunk/1 **wire** version — what `Hello`/`Welcome` carry and hosts equality-check. /// Deliberately its own constant: [`ABI_VERSION`] tracks the embeddable **C surface** diff --git a/crates/punktfunk-core/src/quic/caps.rs b/crates/punktfunk-core/src/quic/caps.rs index 1cf5e69b..0c0a44d9 100644 --- a/crates/punktfunk-core/src/quic/caps.rs +++ b/crates/punktfunk-core/src/quic/caps.rs @@ -111,6 +111,16 @@ pub const CLIENT_CAP_CURSOR: u8 = 0x01; /// simply ignored — no behavior change in either direction. pub const CLIENT_CAP_PHASE_LOCK: u8 = 0x02; +/// [`Hello::client_caps`] bit: the client understands the pad-audio plane +/// ([`PAD_AUDIO_MAGIC`](super::datagram::PAD_AUDIO_MAGIC), `0xD1`) — per-gamepad DualSense +/// voice-coil haptics + speaker Opus frames, plus the [`HidOutput::AudioCtl`] +/// (super::datagram::HidOutput) routing/volume events. Active only when the host answers with +/// [`HOST_CAP_PAD_AUDIO`] AND the pad's arrival declared a renderer for the kind +/// ([`crate::input::ARRIVAL_FLAG_PAD_AUDIO_HAPTICS`]/`_SPEAKER`) — the capable-and-agreed +/// precedent, per pad; toward an older or incapable host nothing changes. `0x04` — `0x01` is +/// [`CLIENT_CAP_CURSOR`], `0x02` is [`CLIENT_CAP_PHASE_LOCK`]. +pub const CLIENT_CAP_PAD_AUDIO: u8 = 0x04; + /// [`Welcome::host_caps`] bit: the host CAN forward the cursor out-of-band (it captures cursor /// metadata separately from the frame — the Linux portal `SPA_META_Cursor` path; NOT gamescope, /// whose capture carries no cursor, and NOT Windows yet, where DWM composites into the IDD @@ -132,6 +142,17 @@ pub const HOST_CAP_CURSOR: u8 = 0x08; /// [`HOST_CAP_TEXT_INPUT`], `0x01`/`0x02` are gamepad-state / clipboard. pub const HOST_CAP_PEN: u8 = 0x10; +/// [`Welcome::host_caps`] bit: the host can capture pad audio — its virtual DualSense exposes +/// the pad's audio endpoints (voice-coil haptics + speaker), so a game's per-pad audio can be +/// captured and shipped on the [`PAD_AUDIO_MAGIC`](super::datagram::PAD_AUDIO_MAGIC) plane. +/// Set only when the client asked via [`CLIENT_CAP_PAD_AUDIO`]; when both bits agree, a +/// capable client marks its pads' render capabilities on their arrivals +/// ([`crate::input::ARRIVAL_FLAG_PAD_AUDIO_HAPTICS`]/`_SPEAKER`) and the host emits `0xD1` +/// toward exactly those pads. `0x20` — `0x10` is [`HOST_CAP_PEN`], `0x08` is +/// [`HOST_CAP_CURSOR`], `0x04` is [`HOST_CAP_TEXT_INPUT`], `0x01`/`0x02` are gamepad-state / +/// clipboard. +pub const HOST_CAP_PAD_AUDIO: u8 = 0x20; + /// [`Hello::video_codecs`] bit: the client can decode H.264 / AVC. The GPU-less **software** /// encode path (openh264) emits H.264, so a client that wants to stream from a software host MUST /// advertise this. @@ -314,6 +335,28 @@ mod tests { ); } + #[test] + fn pad_audio_cap_bits_are_distinct() { + // The new pad-audio bits pack into the existing caps bytes without colliding with any + // taken bit (a collision would silently negotiate an unrelated feature). + assert_eq!( + CLIENT_CAP_PAD_AUDIO & (CLIENT_CAP_CURSOR | CLIENT_CAP_PHASE_LOCK), + 0 + ); + assert_eq!( + HOST_CAP_PAD_AUDIO + & (HOST_CAP_GAMEPAD_STATE + | HOST_CAP_CLIPBOARD + | HOST_CAP_TEXT_INPUT + | HOST_CAP_CURSOR + | HOST_CAP_PEN), + 0 + ); + // Single-bit values (a multi-bit cap would OR neighbours in). + assert_eq!(CLIENT_CAP_PAD_AUDIO.count_ones(), 1); + assert_eq!(HOST_CAP_PAD_AUDIO.count_ones(), 1); + } + #[test] fn resolve_codec_canonicalizes_a_multi_bit_preference() { // A non-conformant peer may stuff its capability MASK into `preferred` — the result diff --git a/crates/punktfunk-core/src/quic/datagram.rs b/crates/punktfunk-core/src/quic/datagram.rs index 22977987..4cbe3dc0 100644 --- a/crates/punktfunk-core/src/quic/datagram.rs +++ b/crates/punktfunk-core/src/quic/datagram.rs @@ -1,12 +1,15 @@ -//! The QUIC-datagram side planes, demultiplexed by their first byte (0xC9–0xCF): -//! audio, rumble, mic uplink, rich input, HID output, HDR metadata, host timing. +//! The QUIC-datagram side planes, demultiplexed by their first byte (0xC9–0xD1): +//! audio, rumble, mic uplink, rich input, HID output, HDR metadata, host timing, +//! cursor state, pad audio. /// Datagram wire tags. Video rides UDP; everything low-rate rides QUIC datagrams, /// demultiplexed by the first byte: input = [`crate::input::INPUT_MAGIC`] (0xC8, client→host), /// audio = [`AUDIO_MAGIC`] (0xC9, host→client), rumble = [`RUMBLE_MAGIC`] (0xCA, host→client), /// mic = [`MIC_MAGIC`] (0xCB, client→host), rich-input = [`RICH_INPUT_MAGIC`] (0xCC, client→host), /// HID-output = [`HIDOUT_MAGIC`] (0xCD, host→client), HDR metadata = [`HDR_META_MAGIC`] -/// (0xCE, host→client). +/// (0xCE, host→client), host timing = [`HOST_TIMING_MAGIC`] (0xCF, host→client), cursor state = +/// [`CURSOR_STATE_MAGIC`] (0xD0, host→client), pad audio = [`PAD_AUDIO_MAGIC`] (0xD1, +/// host→client). pub const AUDIO_MAGIC: u8 = 0xC9; pub const RUMBLE_MAGIC: u8 = 0xCA; /// Microphone uplink: the client's mic, Opus-encoded, client → host (the inverse of @@ -332,6 +335,7 @@ const HIDOUT_PLAYER_LEDS: u8 = 0x02; const HIDOUT_TRIGGER: u8 = 0x03; const HIDOUT_TRACKPAD_HAPTIC: u8 = 0x04; const HIDOUT_HID_RAW: u8 = 0x05; +const HIDOUT_AUDIO_CTL: u8 = 0x06; /// [`HidOutput::HidRaw`] `kind`: an OUTPUT report — what the host's hidraw client wrote with /// `write()`/`SDL_hid_write` (Triton rumble `0x80`, haptic pulse `0x81`, …). The client replays @@ -372,6 +376,16 @@ pub enum HidOutput { /// hardware safety timeout, and settings (lizard/IMU) are refreshed every ~3 s against the /// firmware watchdog — a lost datagram heals on the next refresh. HidRaw { pad: u8, kind: u8, data: Vec }, + /// The audio-control region of a DS5 output report `0x02` a game wrote to the host's virtual + /// pad — the routing/volume side of pad audio (the audio SAMPLES ride the [`PAD_AUDIO_MAGIC`] + /// plane). `raw` is bytes 5..=10 of the report verbatim (headphone/speaker/mic volumes + + /// audio routing); `flags` condenses the report's audio valid-flags: bit0 = haptics-select + /// (`valid_flag0` bit1 — the title asked for audio haptics on the voice coils), bits1..4 = + /// `valid_flag0` bits 4..7 (the audio-valid flags gating `raw`). Wire form + /// `[0xCD][0x06][u16 pad LE][u8 flags][6 raw bytes]`. Forwarded change-only (deduped by + /// value host-side, like `Led`/`Trigger`) — a merely-rumbling pad re-sends unchanged audio + /// state on every output report. + AudioCtl { pad: u16, flags: u8, raw: [u8; 6] }, } impl HidOutput { @@ -404,6 +418,12 @@ impl HidOutput { out.extend_from_slice(&[HIDOUT_HID_RAW, *pad, *kind]); out.extend_from_slice(&data[..data.len().min(HID_REPORT_MAX)]); } + HidOutput::AudioCtl { pad, flags, raw } => { + out.push(HIDOUT_AUDIO_CTL); + out.extend_from_slice(&pad.to_le_bytes()); + out.push(*flags); + out.extend_from_slice(raw); + } } out } @@ -441,6 +461,11 @@ impl HidOutput { // Bounded: at most HID_REPORT_MAX bytes are kept from the (attacker-sized) tail. data: b[4..b.len().min(4 + HID_REPORT_MAX)].to_vec(), }), + HIDOUT_AUDIO_CTL if b.len() >= 11 => Some(HidOutput::AudioCtl { + pad: u16::from_le_bytes([b[2], b[3]]), + flags: b[4], + raw: b[5..11].try_into().unwrap(), + }), _ => None, } } @@ -699,6 +724,72 @@ pub fn decode_cursor_state_datagram(b: &[u8]) -> Option { }) } +/// Pad-audio datagram tag, host → client: per-gamepad audio a game routed +/// to the host's virtual DualSense — voice-coil haptics and the built-in speaker — for the client +/// to render on the matching real controller. Next tag after [`CURSOR_STATE_MAGIC`]. The +/// per-pad AUDIO plane (Opus frames, the [`AUDIO_MAGIC`]/[`MIC_MAGIC`] shape plus pad + kind); +/// the routing/volume CONTROL side rides [`HidOutput::AudioCtl`]. Emitted only when the session +/// negotiated it ([`CLIENT_CAP_PAD_AUDIO`](super::caps::CLIENT_CAP_PAD_AUDIO) ∧ +/// [`HOST_CAP_PAD_AUDIO`](super::caps::HOST_CAP_PAD_AUDIO)) and the pad's arrival declared a +/// renderer for the kind ([`crate::input::ARRIVAL_FLAG_PAD_AUDIO_HAPTICS`]/`_SPEAKER`). +/// Best-effort like every audio datagram: a lost frame is a concealed gap, never state. +pub const PAD_AUDIO_MAGIC: u8 = 0xD1; + +/// [`PadAudioFrame::kind`]: the BACK channel pair — the DualSense voice-coil actuators (audio +/// haptics). 5 ms Opus frames, matching the [`AUDIO_MAGIC`] cadence: haptics are felt latency. +pub const PAD_AUDIO_KIND_HAPTICS: u8 = 0; +/// [`PadAudioFrame::kind`]: the FRONT channel pair — the controller's built-in speaker. 10 ms +/// Opus frames (speaker content tolerates the extra buffering for the better coding efficiency). +pub const PAD_AUDIO_KIND_SPEAKER: u8 = 1; + +/// Wire length of a pad-audio datagram header: tag + pad + kind + u32 seq + u64 pts = 15 bytes. +const PAD_AUDIO_HEADER_LEN: usize = 1 + 1 + 1 + 4 + 8; + +/// One decoded pad-audio frame (owned — the client's plane queue stores it). `seq`/`pts_ns` are +/// per-(pad, kind) counters from the host's capture clock, for gap concealment and lip-sync +/// against the main audio plane. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct PadAudioFrame { + /// Gamepad index (the wire pad space, same as rumble/HID-output). + pub pad: u8, + /// [`PAD_AUDIO_KIND_HAPTICS`] or [`PAD_AUDIO_KIND_SPEAKER`]. + pub kind: u8, + pub seq: u32, + pub pts_ns: u64, + /// The raw Opus payload — feed it to an Opus decoder as one frame. Empty = DTX silence. + pub opus: Vec, +} + +/// Pad-audio datagram, host → client: +/// `[0xD1][u8 pad][u8 kind][u32 seq LE][u64 pts_ns LE][opus payload]` — the +/// [`encode_audio_datagram`]/[`encode_mic_datagram`] layout with a pad + kind prefix, one Opus +/// frame per datagram (5/10 ms — well under any MTU); QUIC already encrypts. +pub fn encode_pad_audio_datagram(pad: u8, kind: u8, seq: u32, pts_ns: u64, opus: &[u8]) -> Vec { + let mut b = Vec::with_capacity(PAD_AUDIO_HEADER_LEN + opus.len()); + b.push(PAD_AUDIO_MAGIC); + b.push(pad); + b.push(kind); + b.extend_from_slice(&seq.to_le_bytes()); + b.extend_from_slice(&pts_ns.to_le_bytes()); + b.extend_from_slice(opus); + b +} + +/// Parse a pad-audio datagram → [`PadAudioFrame`]. `None` on bad tag/length (the fixed header +/// length bounds every read before it happens). +pub fn decode_pad_audio_datagram(buf: &[u8]) -> Option { + if buf.len() < PAD_AUDIO_HEADER_LEN || buf[0] != PAD_AUDIO_MAGIC { + return None; + } + Some(PadAudioFrame { + pad: buf[1], + kind: buf[2], + seq: u32::from_le_bytes(buf[3..7].try_into().unwrap()), + pts_ns: u64::from_le_bytes(buf[7..15].try_into().unwrap()), + opus: buf[15..].to_vec(), + }) +} + #[cfg(test)] mod tests { use crate::quic::*; @@ -1027,6 +1118,12 @@ mod tests { f }, }, + // The DS5 audio-control region (haptics-select + speaker volume asserted). + HidOutput::AudioCtl { + pad: 1, + flags: 0b0_0101, + raw: [0x50, 0x60, 0x70, 0x05, 0x00, 0x00], + }, ]; for ev in &cases { let d = ev.encode(); @@ -1045,6 +1142,47 @@ mod tests { ) .is_none()); } + + #[test] + fn audio_ctl_wire_layout_and_truncation() { + // The exact 11-byte layout: [0xCD][0x06][u16 pad LE][u8 flags][6 raw bytes]. + let a = HidOutput::AudioCtl { + pad: 0x0201, + flags: 0x17, + raw: [1, 2, 3, 4, 5, 6], + }; + let d = a.encode(); + assert_eq!(d, [0xCD, 0x06, 0x01, 0x02, 0x17, 1, 2, 3, 4, 5, 6]); + assert_eq!(HidOutput::decode(&d), Some(a)); + // Truncated buffers are rejected outright (fixed length — never a partial read). + for n in 2..d.len() { + assert_eq!(HidOutput::decode(&d[..n]), None); + } + } + + #[test] + fn pad_audio_datagram_roundtrip_and_truncation() { + let opus = [0x5Au8; 61]; + let d = encode_pad_audio_datagram(3, PAD_AUDIO_KIND_HAPTICS, 42, 9_999, &opus); + assert_eq!(d[0], PAD_AUDIO_MAGIC); + assert_eq!(d.len(), 15 + opus.len()); + let f = decode_pad_audio_datagram(&d).unwrap(); + assert_eq!((f.pad, f.kind, f.seq, f.pts_ns), (3, 0, 42, 9_999)); + assert_eq!(f.opus, opus); + // Truncated headers are rejected outright (never partially read). + for n in 0..15 { + assert_eq!(decode_pad_audio_datagram(&d[..n]), None); + } + // Tag separation: a pad-audio datagram is not a session-audio/mic datagram and vice-versa. + assert!(decode_audio_datagram(&d).is_none()); + assert!(decode_mic_datagram(&d).is_none()); + assert!(decode_pad_audio_datagram(&encode_audio_datagram(1, 2, &opus)).is_none()); + // Empty payload (DTX) is legal — header-only datagram. + let hdr = encode_pad_audio_datagram(0, PAD_AUDIO_KIND_SPEAKER, 0, 0, &[]); + assert_eq!(hdr.len(), 15); + assert!(decode_pad_audio_datagram(&hdr).unwrap().opus.is_empty()); + } + #[test] fn cursor_state_roundtrip() { for (flags, x, y) in [ diff --git a/crates/punktfunk-core/src/quic/mod.rs b/crates/punktfunk-core/src/quic/mod.rs index 326374f5..dca6620f 100644 --- a/crates/punktfunk-core/src/quic/mod.rs +++ b/crates/punktfunk-core/src/quic/mod.rs @@ -25,7 +25,7 @@ //! Split by concern (networking-audit deferred plan §3 — a pure move): `handshake` the //! positional Hello/Welcome/Start codecs, `caps` the capability/codec-negotiation //! vocabulary, `control` the typed control + clipboard messages, `pairing` the pairing -//! message codecs with [`pake`] the SPAKE2 itself, `datagram` the 0xC9–0xCF plane codecs, +//! message codecs with [`pake`] the SPAKE2 itself, `datagram` the 0xC9–0xD1 plane codecs, //! `pen` the stylus batch (0xCC kind 0x05) + host stroke tracker, //! [`io`] framed stream IO, `clock` skew estimation + mid-stream re-sync, [`endpoint`] the //! quinn constructors, [`clipstream`] the per-transfer clipboard fetch streams. Every item diff --git a/crates/punktfunk-host/Cargo.toml b/crates/punktfunk-host/Cargo.toml index c3a86327..5984dd9e 100644 --- a/crates/punktfunk-host/Cargo.toml +++ b/crates/punktfunk-host/Cargo.toml @@ -259,6 +259,17 @@ windows = { version = "0.62", features = [ # CoCreateInstance(PolicyConfigClient) — set the default audio playback/recording endpoints via the # undocumented IPolicyConfig (audio/windows/audio_control.rs) so mic + desktop audio auto-wire. "Win32_System_Com", + # Pad-audio endpoint provisioning (audio/windows/pad_endpoint.rs): IMMDevice + IPropertyStore + # to stamp the DualSense identity onto the minted endpoints (PROPVARIANT lives in + # StructuredStorage and is gated on the Variant feature), DEVPKEY_Device_DriverInfPath to + # resolve the installed Steam Streaming Speakers INF, and raw Reg* calls behind the MMDevices + # ACL repair + the devnode's pad-index marker value. + "Win32_Media_Audio", + "Win32_UI_Shell_PropertiesSystem", + "Win32_System_Com_StructuredStorage", + "Win32_System_Variant", + "Win32_Devices_Properties", + "Win32_System_Registry", # SetUnhandledExceptionFilter + EXCEPTION_POINTERS — the last-resort native-crash logger # (src/windows/crash.rs); Kernel gates the CONTEXT type EXCEPTION_POINTERS embeds. "Win32_System_Diagnostics_Debug", diff --git a/crates/punktfunk-host/src/audio.rs b/crates/punktfunk-host/src/audio.rs index 0df8b571..bb82d7c6 100644 --- a/crates/punktfunk-host/src/audio.rs +++ b/crates/punktfunk-host/src/audio.rs @@ -183,6 +183,12 @@ pub fn open_virtual_mic(_channels: u32) -> Result> { mod audio_control; #[cfg(target_os = "linux")] mod linux; +// DualSense pad-audio endpoint provisioning + loopback capture (design: pad haptics/audio). +// pub(crate): the session layer queries endpoints by pad index and the CLI exposes the +// `pad-endpoint` devtest. +#[cfg(target_os = "windows")] +#[path = "audio/windows/pad_endpoint.rs"] +pub(crate) mod pad_endpoint; #[cfg(target_os = "windows")] #[path = "audio/windows/wasapi_cap.rs"] mod wasapi_cap; diff --git a/crates/punktfunk-host/src/audio/windows/audio_control.rs b/crates/punktfunk-host/src/audio/windows/audio_control.rs index 785373bb..a39ec594 100644 --- a/crates/punktfunk-host/src/audio/windows/audio_control.rs +++ b/crates/punktfunk-host/src/audio/windows/audio_control.rs @@ -75,6 +75,17 @@ pub(crate) fn host_audio_requested() -> bool { std::env::var_os("PUNKTFUNK_HOST_AUDIO").is_some() } +/// Endpoint ids among `renders` that are the host's own pad-audio endpoints — the exclusion +/// data [`plan`] runs on. Detection lives in [`super::pad_endpoint`] (stamped PFDS container / +/// devnode marker, registry-only reads); this is just the per-pass collection. +fn pad_render_ids(renders: &[Endpoint]) -> Vec { + renders + .iter() + .filter(|(_, id)| super::pad_endpoint::is_pad_render_endpoint(id)) + .map(|(_, id)| id.clone()) + .collect() +} + /// Enumerate endpoints, compute the assignment, apply the default-device changes (unless /// `PUNKTFUNK_KEEP_DEFAULT`), and return the plan for the caller to act on (mic target / loopback /// echo guard). `set_playback` — true only from the desktop-audio capture open — additionally @@ -90,7 +101,17 @@ pub(crate) fn wire_now(set_playback: bool) -> Wiring { let want = std::env::var("PUNKTFUNK_MIC_DEVICE") .ok() .map(|s| s.to_lowercase()); - let wiring = plan(&renders, &captures, want.as_deref(), host_audio_requested()); + // The host's own pad-audio ("DualSense speaker") endpoints, by id — the pure plan filters + // them out of every role. Identity is platform data (stamped container / devnode marker), + // so it is collected HERE and passed in, like the candidate lists themselves. + let pad_ids = pad_render_ids(&renders); + let wiring = plan( + &renders, + &captures, + want.as_deref(), + host_audio_requested(), + &pad_ids, + ); // Log assignment changes exactly once (first plan included). static LAST: Mutex> = Mutex::new(None); @@ -135,7 +156,7 @@ pub(crate) fn wire_now(set_playback: bool) -> Wiring { if let Some((mic_name, mic_id)) = &wiring.mic_render { if default_render_id().as_deref() == Some(mic_id.as_str()) { // Audible preference = the host_audio plan's loopback pick (real hardware first). - match plan(&renders, &captures, want.as_deref(), true).loopback_render { + match plan(&renders, &captures, want.as_deref(), true, &pad_ids).loopback_render { Some((name, id)) => match set_default_endpoint(&id) { Ok(()) => tracing::info!(mic = %mic_name, device = %name, "default playback was the virtual-mic target — moved it so desktop \ @@ -184,8 +205,10 @@ fn park_marker_path() -> std::path::PathBuf { pf_paths::config_dir().join("audio-default.prev") } -/// The current default RENDER endpoint id, if any. -fn default_render_id() -> Option { +/// The current default RENDER endpoint id, if any. pub(crate): the pad-endpoint provisioning +/// uses it for its default-device guard (a freshly minted pad endpoint must never stay the +/// default playback device). +pub(crate) fn default_render_id() -> Option { wasapi::DeviceEnumerator::new() .ok()? .get_default_device(&Direction::Render) @@ -332,8 +355,9 @@ const _: () = { /// Set `device_id` as the default audio endpoint for eConsole/eMultimedia/eCommunications via the /// undocumented `IPolicyConfig::SetDefaultEndpoint` (the call `mmsys.cpl` makes). Errs if any role -/// fails. -fn set_default_endpoint(device_id: &str) -> Result<()> { +/// fails. pub(crate): the pad-endpoint default-device guard restores the operator's default +/// through the same machinery. +pub(crate) fn set_default_endpoint(device_id: &str) -> Result<()> { use windows::core::{IUnknown, Interface, GUID, PCWSTR}; use windows::Win32::System::Com::{CoCreateInstance, CLSCTX_ALL}; diff --git a/crates/punktfunk-host/src/audio/windows/pad_endpoint.rs b/crates/punktfunk-host/src/audio/windows/pad_endpoint.rs new file mode 100644 index 00000000..019908fa --- /dev/null +++ b/crates/punktfunk-host/src/audio/windows/pad_endpoint.rs @@ -0,0 +1,1678 @@ +//! DualSense pad-audio endpoint provisioning (Windows) — mint a per-pad render endpoint that +//! games recognise as the pad's SPEAKER, and loopback-capture what they play into it. +//! +//! Games find a DualSense's audio endpoint by (a) ContainerId — the first render endpoint whose +//! `PKEY_Device_ContainerId` equals the pad's HID container — and/or (b) a FriendlyName +//! containing "Wireless Controller"; the endpoint must be 4 ch / 48 kHz (all verified on-glass +//! 2026-08-01). The punktfunk virtual pad already stamps the deterministic per-pad container +//! `{50464453-0000-0000-0000-00000000000}` ("PFDS" — see +//! `pf-inject/src/inject/windows/dualsense_windows.rs`, `create_swdevice`), so this module's +//! whole job is to produce a render endpoint carrying the SAME container + name + formats: +//! +//! 1. **Devnode** ([`ensure`], idempotent, host startup — NOT per session): one extra devnode of +//! Valve's Steam Streaming Speakers driver per pad slot (hwid `ROOT\SteamStreamingSpeakers`, +//! present whenever Steam is installed; the driver happily multi-instances — each devnode +//! yields a fresh render endpoint). Registered via `SetupDiRegisterDeviceInfo`, NOT +//! `SetupDiCallClassInstaller(DIF_REGISTERDEVICE)`, which needs an interactive window +//! station and fails with error 1459 from a service. The pad slot is persisted in the +//! devnode's `Device Parameters` key (`PunktfunkPadIndex`) — the INF rewrites DeviceDesc at +//! driver install, so the marker value is the durable "this one is ours" signal. +//! 2. **Stamp**: the new endpoint gets the MINIMAL DualSense identity — description + device +//! name, the PFDS container, and the 4 ch/48 kHz format triplet. Never hardware-id or +//! devicepath properties: an inconsistent stamp makes AudioEndpointBuilder DELETE the +//! endpoint and re-mint it under a new GUID. The cleaner `IPropertyStore` route is tried +//! first (audiosrv notices immediately); keys it rejects fall back to raw registry writes +//! behind the measured MMDevices ACL repair (see [`grant_system_full_control`]). +//! 3. **Capture**: sessions loopback-capture the endpoint ([`PadLoopbackCapturer`], 4 ch f32 +//! interleaved) and ship the PCM to the client's pad speaker/haptics. +//! +//! The wiring plan must never route desktop audio or the virtual mic onto these endpoints — +//! [`audio_control`](super::audio_control) collects the exclusion ids via +//! [`is_pad_render_endpoint`] and the pure plan filters them. A default-playback flip onto a +//! freshly minted pad endpoint is undone inside [`ensure`] (the IPolicyConfig machinery +//! `audio_control` already owns). +//! +//! COM discipline matches the sibling modules: WASAPI/COM objects live on the thread that made +//! them (the provisioning worker, the capture thread); only channels and plain data cross. + +// Every `unsafe` block in this file carries a `// SAFETY:` proof; enforce it. +#![deny(clippy::undocumented_unsafe_blocks)] + +use super::{audio_control, AudioCapturer, SAMPLE_RATE}; +use anyhow::{anyhow, bail, Context, Result}; +use std::collections::{HashSet, VecDeque}; +use std::mem::ManuallyDrop; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::mpsc::{sync_channel, Receiver, RecvTimeoutError, SyncSender}; +use std::sync::{Arc, Mutex, OnceLock}; +use std::thread::{self, JoinHandle}; +use std::time::{Duration, Instant}; +use wasapi::{Direction, SampleType, StreamMode, WaveFormat}; +use windows::core::{w, GUID, PCWSTR, PWSTR}; +use windows::Win32::Devices::DeviceAndDriverInstallation::{ + SetupDiCreateDevRegKeyW, SetupDiCreateDeviceInfoList, SetupDiCreateDeviceInfoW, + SetupDiDestroyDeviceInfoList, SetupDiEnumDeviceInfo, SetupDiGetClassDevsW, + SetupDiGetDeviceInstanceIdW, SetupDiGetDevicePropertyW, SetupDiGetDeviceRegistryPropertyW, + SetupDiOpenDevRegKey, SetupDiRegisterDeviceInfo, SetupDiSetDeviceRegistryPropertyW, + UpdateDriverForPlugAndPlayDevicesW, DICD_GENERATE_ID, DICS_FLAG_GLOBAL, DIREG_DEV, + GUID_DEVCLASS_MEDIA, HDEVINFO, SETUP_DI_GET_CLASS_DEVS_FLAGS, SPDRP_HARDWAREID, + SP_DEVINFO_DATA, UPDATEDRIVERFORPLUGANDPLAYDEVICES_FLAGS, +}; +use windows::Win32::Devices::Properties::{ + DEVPKEY_Device_DriverInfPath, DEVPROPTYPE, DEVPROP_TYPE_STRING, +}; +use windows::Win32::Foundation::PROPERTYKEY; +use windows::Win32::Media::Audio::{IMMDevice, IMMDeviceEnumerator, MMDeviceEnumerator}; +use windows::Win32::System::Com::StructuredStorage::{ + PropVariantClear, PROPVARIANT, PROPVARIANT_0, PROPVARIANT_0_0, PROPVARIANT_0_0_0, +}; +use windows::Win32::System::Com::{CoCreateInstance, BLOB, CLSCTX_ALL, STGM_READ, STGM_READWRITE}; +use windows::Win32::System::Registry::{ + RegCloseKey, RegQueryValueExW, RegSetValueExW, KEY_QUERY_VALUE, KEY_SET_VALUE, REG_DWORD, + REG_VALUE_TYPE, +}; +use windows::Win32::System::Variant::{VT_BLOB, VT_CLSID, VT_LPWSTR}; +use windows::Win32::UI::Shell::PropertiesSystem::IPropertyStore; + +/// Data1 of the deterministic per-pad container GUID ("PFDS") — MUST equal the +/// `container_tag` pf-inject stamps on the virtual DualSense devnodes, or games will never +/// match the endpoint to the pad. +pub(crate) const PFDS_TAG: u32 = 0x5046_4453; +/// DeviceDesc our devnodes are created with (the INF overwrites it at driver install, so this +/// only identifies a devnode whose install never completed — [`PAD_INDEX_VALUE`] is the +/// durable marker). +const DEVNODE_DESC: &str = "Punktfunk Pad Audio"; +/// The multi-instancing Steam Remote Play render driver we ride on. +const SSS_HWID: &str = "ROOT\\SteamStreamingSpeakers"; +/// Registry value under the devnode's `Device Parameters` key persisting which pad slot the +/// devnode serves (REG_DWORD). +const PAD_INDEX_VALUE: &str = "PunktfunkPadIndex"; +/// The endpoint store for render endpoints (each subkey = one endpoint GUID). +const MMDEV_RENDER_PATH: &str = r"SOFTWARE\Microsoft\Windows\CurrentVersion\MMDevices\Audio\Render"; +/// WASAPI endpoint-id prefix for render endpoints (`{0.0.0.00000000}.{guid}`). +const ENDPOINT_ID_PREFIX: &str = "{0.0.0.00000000}."; +/// How long [`ensure`] waits for the new render endpoint to materialise after driver install. +const ENDPOINT_WAIT: Duration = Duration::from_secs(10); + +/// One provisioned pad-audio endpoint. Persistent by design (endpoints survive host restarts); +/// [`remove`] exists for tests + the `pad-endpoint remove` escape hatch only. +#[derive(Debug, Clone)] +pub struct PadEndpoint { + /// Full WASAPI endpoint id (`{0.0.0.00000000}.{guid}`) — what [`PadLoopbackCapturer::open`] + /// takes. Empty only from [`find`] when the devnode exists but the endpoint never + /// registered (driver install incomplete). + pub endpoint_id: String, + /// PnP device instance id of the devnode (`ROOT\MEDIA\00NN`). + pub device_instance: String, + /// The pad slot this endpoint serves — byte 23 of the stamped container GUID. + pub pad_index: u8, + /// True when at least one stamp is STORED but not SERVED: the audio stack must restart + /// (AudioEndpointBuilder + Audiosrv) before games see the DualSense identity. Never acted + /// on mid-flight — host startup may do ONE restart before any session exists + /// ([`provision_at_startup`]); everyone else just reads the flag. + pub needs_aeb_kick: bool, +} + +// --- the stamp set ------------------------------------------------------------------------- + +/// One endpoint property to stamp: the property-store key, the value, a short log label. +struct Stamp { + label: &'static str, + key: PROPERTYKEY, + value: StampValue, +} + +enum StampValue { + Str(&'static str), + /// The PFDS container (VT_CLSID / serialized-CLSID registry blob). + Container(GUID), + /// A `WAVEFORMATEXTENSIBLE` (VT_BLOB / serialized-blob registry value). + Format(&'static [u8; 40]), +} + +const fn pkey(fmtid: u128, pid: u32) -> PROPERTYKEY { + PROPERTYKEY { + fmtid: GUID::from_u128(fmtid), + pid, + } +} + +/// `PKEY_Device_DeviceDesc` — the "description" half of the endpoint display name. +const PKEY_DEVICE_DESC: PROPERTYKEY = pkey(0xa45c254e_df1c_4efd_8020_67d146a850e0, 2); +/// Endpoint-store "device name" half of the display name. +const PKEY_ENDPOINT_DEVICE_NAME: PROPERTYKEY = pkey(0xb3f8fa53_0004_438e_9003_51a46e139bfc, 6); +/// Endpoint-store devnode link: `"{1}."` — how an endpoint is tied back to +/// the devnode that owns it. +const PKEY_ENDPOINT_DEVNODE: PROPERTYKEY = pkey(0xb3f8fa53_0004_438e_9003_51a46e139bfc, 2); +/// `PKEY_Device_ContainerId` — what games match against the pad's HID container. +const PKEY_CONTAINER_ID: PROPERTYKEY = pkey(0x8c7ed206_3f8a_4827_b3ab_ae9e1faefc6c, 2); +/// `PKEY_AudioEngine_DeviceFormat` (16-bit PCM leg of the format set). +const PKEY_DEVICE_FORMAT: PROPERTYKEY = pkey(0xf19f064d_082c_4e27_bc73_6882a1bb8e4c, 0); +/// Endpoint format pair (float leg) — pids 2 and 3 of the same fmtid. +const PKEY_MIX_FORMAT_2: PROPERTYKEY = pkey(0x3d6e1656_2e50_4c4c_8d85_d0acae3c6c68, 2); +const PKEY_MIX_FORMAT_3: PROPERTYKEY = pkey(0x3d6e1656_2e50_4c4c_8d85_d0acae3c6c68, 3); +/// Host processing format (float leg). +const PKEY_HOST_FORMAT: PROPERTYKEY = pkey(0xe4870e26_3cc5_4cd2_ba46_ca0a9a70ed04, 0); + +/// `WAVEFORMATEXTENSIBLE`: 4 ch / 48 kHz / 16-bit PCM, mask 0x33 (FL FR BL BR), PCM subtype. +const WFX_PCM16_4CH_48K: [u8; 40] = [ + 0xfe, 0xff, // wFormatTag = WAVE_FORMAT_EXTENSIBLE + 0x04, 0x00, // nChannels = 4 + 0x80, 0xbb, 0x00, 0x00, // nSamplesPerSec = 48000 + 0x00, 0xdc, 0x05, 0x00, // nAvgBytesPerSec = 384000 + 0x08, 0x00, // nBlockAlign = 8 + 0x10, 0x00, // wBitsPerSample = 16 + 0x16, 0x00, // cbSize = 22 + 0x10, 0x00, // wValidBitsPerSample = 16 + 0x33, 0x00, 0x00, 0x00, // dwChannelMask = 0x33 + 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x10, 0x00, 0x80, 0x00, 0x00, 0xaa, 0x00, 0x38, 0x9b, + 0x71, // KSDATAFORMAT_SUBTYPE_PCM +]; +/// `WAVEFORMATEXTENSIBLE`: 4 ch / 48 kHz / 32-bit float, mask 0x33, IEEE-float subtype. +const WFX_F32_4CH_48K: [u8; 40] = [ + 0xfe, 0xff, // wFormatTag = WAVE_FORMAT_EXTENSIBLE + 0x04, 0x00, // nChannels = 4 + 0x80, 0xbb, 0x00, 0x00, // nSamplesPerSec = 48000 + 0x00, 0xb8, 0x0b, 0x00, // nAvgBytesPerSec = 768000 + 0x10, 0x00, // nBlockAlign = 16 + 0x20, 0x00, // wBitsPerSample = 32 + 0x16, 0x00, // cbSize = 22 + 0x20, 0x00, // wValidBitsPerSample = 32 + 0x33, 0x00, 0x00, 0x00, // dwChannelMask = 0x33 + 0x03, 0x00, 0x00, 0x00, 0x00, 0x00, 0x10, 0x00, 0x80, 0x00, 0x00, 0xaa, 0x00, 0x38, 0x9b, + 0x71, // KSDATAFORMAT_SUBTYPE_IEEE_FLOAT +]; + +/// The deterministic per-pad container GUID — identical to pf-inject's +/// `GUID::from_values(container_tag, 0, 0, [0,0,0,0,0,0,0,index])` for the DualSense family. +pub(crate) fn pfds_container_guid(pad_index: u8) -> GUID { + GUID::from_values(PFDS_TAG, 0, 0, [0, 0, 0, 0, 0, 0, 0, pad_index]) +} + +/// The MINIMAL stamp set — nothing more (hardware-id/devicepath writes make +/// AudioEndpointBuilder delete + re-mint the endpoint under a new GUID). +fn stamps_for(pad_index: u8) -> [Stamp; 7] { + [ + Stamp { + label: "device-desc", + key: PKEY_DEVICE_DESC, + value: StampValue::Str("Wireless Controller"), + }, + Stamp { + label: "device-name", + key: PKEY_ENDPOINT_DEVICE_NAME, + value: StampValue::Str("DualSense Wireless Controller"), + }, + Stamp { + label: "container-id", + key: PKEY_CONTAINER_ID, + value: StampValue::Container(pfds_container_guid(pad_index)), + }, + Stamp { + label: "device-format", + key: PKEY_DEVICE_FORMAT, + value: StampValue::Format(&WFX_PCM16_4CH_48K), + }, + Stamp { + label: "mix-format-2", + key: PKEY_MIX_FORMAT_2, + value: StampValue::Format(&WFX_F32_4CH_48K), + }, + Stamp { + label: "mix-format-3", + key: PKEY_MIX_FORMAT_3, + value: StampValue::Format(&WFX_F32_4CH_48K), + }, + Stamp { + label: "host-format", + key: PKEY_HOST_FORMAT, + value: StampValue::Format(&WFX_F32_4CH_48K), + }, + ] +} + +// --- small encoding helpers ---------------------------------------------------------------- + +/// NUL-terminated UTF-16. +fn wide(s: &str) -> Vec { + s.encode_utf16().chain(std::iter::once(0)).collect() +} + +/// REG_MULTI_SZ bytes (UTF-16LE, item NULs + terminating NUL). +fn multi_sz_bytes(items: &[&str]) -> Vec { + let mut units: Vec = items + .iter() + .flat_map(|s| s.encode_utf16().chain(std::iter::once(0))) + .collect(); + units.push(0); + units.iter().flat_map(|u| u.to_le_bytes()).collect() +} + +/// REG_SZ bytes (UTF-16LE incl. the NUL). +fn sz_bytes(s: &str) -> Vec { + wide(s).iter().flat_map(|u| u.to_le_bytes()).collect() +} + +/// Lowercase registry spelling of a GUID (no braces). +fn guid_str(g: &GUID) -> String { + format!( + "{:08x}-{:04x}-{:04x}-{:02x}{:02x}-{:02x}{:02x}{:02x}{:02x}{:02x}{:02x}", + g.data1, + g.data2, + g.data3, + g.data4[0], + g.data4[1], + g.data4[2], + g.data4[3], + g.data4[4], + g.data4[5], + g.data4[6], + g.data4[7] + ) +} + +/// The registry value name MMDevices uses for a property key: `"{fmtid},pid"`. +fn reg_value_name(k: &PROPERTYKEY) -> String { + format!("{{{}}},{}", guid_str(&k.fmtid), k.pid) +} + +/// GUID in registry byte order (Data1/2/3 little-endian, Data4 as-is) — byte 23 of the full +/// serialized container blob ends up being the pad index. +fn guid_registry_bytes(g: &GUID) -> [u8; 16] { + let mut out = [0u8; 16]; + out[..4].copy_from_slice(&g.data1.to_le_bytes()); + out[4..6].copy_from_slice(&g.data2.to_le_bytes()); + out[6..8].copy_from_slice(&g.data3.to_le_bytes()); + out[8..].copy_from_slice(&g.data4); + out +} + +/// The stamped value in the endpoint store's on-disk shape: strings are plain REG_SZ; +/// container + formats are serialized PROPVARIANTs (8-byte header `vt, 0x00000001`, then the +/// payload) as REG_BINARY. +fn reg_registry_value(v: &StampValue) -> winreg::RegValue<'static> { + use winreg::enums::{REG_BINARY, REG_SZ}; + match v { + StampValue::Str(s) => winreg::RegValue { + bytes: sz_bytes(s).into(), + vtype: REG_SZ, + }, + StampValue::Container(g) => { + let mut b = vec![0x48, 0, 0, 0, 1, 0, 0, 0]; // VT_CLSID header + b.extend_from_slice(&guid_registry_bytes(g)); + winreg::RegValue { + bytes: b.into(), + vtype: REG_BINARY, + } + } + StampValue::Format(wfx) => { + let mut b = vec![0x41, 0, 0, 0, 1, 0, 0, 0]; // VT_BLOB header + b.extend_from_slice(&wfx[..]); + winreg::RegValue { + bytes: b.into(), + vtype: REG_BINARY, + } + } + } +} + +/// The per-endpoint GUID portion of a WASAPI endpoint id (`{0.0.0.00000000}.{guid}` → +/// `{guid}`) — the endpoint's MMDevices registry key name. +fn endpoint_guid_part(endpoint_id: &str) -> Result<&str> { + endpoint_id + .rfind('{') + .map(|i| &endpoint_id[i..]) + .filter(|g| g.len() >= 38 && g.ends_with('}')) + .ok_or_else(|| anyhow!("unrecognised endpoint id shape: {endpoint_id}")) +} + +// --- PROPVARIANT plumbing (windows-rs PROPVARIANTs have no Drop: building borrowed ones is +// --- safe; owned ones from GetValue are cleared explicitly) --------------------------------- + +fn pv_lpwstr(w: &[u16]) -> PROPVARIANT { + PROPVARIANT { + Anonymous: PROPVARIANT_0 { + Anonymous: ManuallyDrop::new(PROPVARIANT_0_0 { + vt: VT_LPWSTR, + wReserved1: 0, + wReserved2: 0, + wReserved3: 0, + Anonymous: PROPVARIANT_0_0_0 { + pwszVal: PWSTR(w.as_ptr().cast_mut()), + }, + }), + }, + } +} + +fn pv_clsid(g: &GUID) -> PROPVARIANT { + PROPVARIANT { + Anonymous: PROPVARIANT_0 { + Anonymous: ManuallyDrop::new(PROPVARIANT_0_0 { + vt: VT_CLSID, + wReserved1: 0, + wReserved2: 0, + wReserved3: 0, + Anonymous: PROPVARIANT_0_0_0 { + puuid: std::ptr::from_ref(g).cast_mut(), + }, + }), + }, + } +} + +fn pv_blob(b: &[u8]) -> PROPVARIANT { + PROPVARIANT { + Anonymous: PROPVARIANT_0 { + Anonymous: ManuallyDrop::new(PROPVARIANT_0_0 { + vt: VT_BLOB, + wReserved1: 0, + wReserved2: 0, + wReserved3: 0, + Anonymous: PROPVARIANT_0_0_0 { + blob: BLOB { + cbSize: b.len() as u32, + pBlobData: b.as_ptr().cast_mut(), + }, + }, + }), + }, + } +} + +fn pv_string(pv: &PROPVARIANT) -> Option { + // SAFETY: the variant is initialized (built by us or returned by GetValue); pwszVal is only + // read when vt says VT_LPWSTR, in which case it points at the variant's NUL-terminated + // string (or is null, which we check). + unsafe { + let inner = &pv.Anonymous.Anonymous; + if inner.vt != VT_LPWSTR { + return None; + } + let p = inner.Anonymous.pwszVal; + if p.is_null() { + return None; + } + p.to_string().ok() + } +} + +fn pv_guid(pv: &PROPVARIANT) -> Option { + // SAFETY: as in `pv_string` — puuid is only dereferenced when vt == VT_CLSID and non-null. + unsafe { + let inner = &pv.Anonymous.Anonymous; + if inner.vt != VT_CLSID { + return None; + } + let p = inner.Anonymous.puuid; + if p.is_null() { + return None; + } + Some(*p) + } +} + +fn pv_bytes(pv: &PROPVARIANT) -> Option> { + // SAFETY: as in `pv_string` — the blob pointer/length pair is only read when vt == VT_BLOB + // and the pointer is non-null; the variant owns cbSize bytes there. + unsafe { + let inner = &pv.Anonymous.Anonymous; + if inner.vt != VT_BLOB { + return None; + } + let b = &inner.Anonymous.blob; + if b.pBlobData.is_null() { + return None; + } + Some(std::slice::from_raw_parts(b.pBlobData, b.cbSize as usize).to_vec()) + } +} + +// --- devnode management (SetupAPI) ---------------------------------------------------------- + +/// Owns an HDEVINFO and destroys it on drop. +struct DevInfoSet(HDEVINFO); +impl Drop for DevInfoSet { + fn drop(&mut self) { + // SAFETY: the handle came from SetupDiGetClassDevsW/SetupDiCreateDeviceInfoList and is + // destroyed exactly once (this owner's drop). + unsafe { + let _ = SetupDiDestroyDeviceInfoList(self.0); + } + } +} + +fn media_class_devs() -> Result { + // SAFETY: the class GUID is a static const; flags 0 (not DIGCF_PRESENT) so a created-but- + // never-installed phantom from a previous run is still found and reused, not duplicated. + let set = unsafe { + SetupDiGetClassDevsW( + Some(&GUID_DEVCLASS_MEDIA), + PCWSTR::null(), + None, + SETUP_DI_GET_CLASS_DEVS_FLAGS(0), + ) + } + .context("SetupDiGetClassDevs(MEDIA)")?; + Ok(DevInfoSet(set)) +} + +fn devinfo_data() -> SP_DEVINFO_DATA { + SP_DEVINFO_DATA { + cbSize: std::mem::size_of::() as u32, + ..Default::default() + } +} + +fn instance_id(set: &DevInfoSet, did: &SP_DEVINFO_DATA) -> Option { + let mut buf = [0u16; 200]; + // SAFETY: live devinfo set + element; the buffer length travels with the slice. + unsafe { SetupDiGetDeviceInstanceIdW(set.0, did, Some(&mut buf), None) }.ok()?; + let len = buf.iter().position(|&c| c == 0).unwrap_or(buf.len()); + Some(String::from_utf16_lossy(&buf[..len])) +} + +/// A REG_MULTI_SZ SetupDi registry property (e.g. SPDRP_HARDWAREID) as strings. +fn devnode_multi_sz_prop( + set: &DevInfoSet, + did: &SP_DEVINFO_DATA, + prop: windows::Win32::Devices::DeviceAndDriverInstallation::SETUP_DI_REGISTRY_PROPERTY, +) -> Vec { + let mut buf = vec![0u8; 4096]; + let mut req = 0u32; + // SAFETY: live set + element; the output buffer length travels with the slice. + if unsafe { + SetupDiGetDeviceRegistryPropertyW(set.0, did, prop, None, Some(&mut buf), Some(&mut req)) + } + .is_err() + { + return Vec::new(); + } + let units: Vec = buf[..(req as usize).min(buf.len())] + .chunks_exact(2) + .map(|c| u16::from_le_bytes([c[0], c[1]])) + .collect(); + units + .split(|&c| c == 0) + .filter(|s| !s.is_empty()) + .map(String::from_utf16_lossy) + .collect() +} + +/// The devnode's installed-driver INF filename (`DEVPKEY_Device_DriverInfPath`, e.g. +/// `oem32.inf`) — absent on a devnode whose driver never installed. +fn devnode_inf_path(set: &DevInfoSet, did: &SP_DEVINFO_DATA) -> Option { + let mut ty = DEVPROPTYPE(0); + let mut buf = vec![0u8; 1024]; + let mut req = 0u32; + // SAFETY: live set + element; the property key is a static const; the buffer length + // travels with the slice. + unsafe { + SetupDiGetDevicePropertyW( + set.0, + did, + &DEVPKEY_Device_DriverInfPath, + &mut ty, + Some(&mut buf), + Some(&mut req), + 0, + ) + } + .ok()?; + if ty != DEVPROP_TYPE_STRING { + return None; + } + let units: Vec = buf[..(req as usize).min(buf.len())] + .chunks_exact(2) + .map(|c| u16::from_le_bytes([c[0], c[1]])) + .collect(); + let len = units.iter().position(|&c| c == 0).unwrap_or(units.len()); + (len > 0).then(|| String::from_utf16_lossy(&units[..len])) +} + +/// The persisted pad slot of a devnode (the `PunktfunkPadIndex` value under its +/// `Device Parameters` key), or `None` for foreign devnodes. +fn devnode_pad_index(set: &DevInfoSet, did: &SP_DEVINFO_DATA) -> Option { + // SAFETY: live set + element; DIREG_DEV opens the devnode's Device Parameters key. + let hkey = unsafe { + SetupDiOpenDevRegKey( + set.0, + did, + DICS_FLAG_GLOBAL.0, + 0, + DIREG_DEV, + KEY_QUERY_VALUE.0, + ) + } + .ok()?; + let name = wide(PAD_INDEX_VALUE); + let mut data = [0u8; 4]; + let mut len = data.len() as u32; + let mut ty = REG_VALUE_TYPE(0); + // SAFETY: the value name is NUL-terminated and outlives the call; data/len are live locals + // sized together. + let rc = unsafe { + RegQueryValueExW( + hkey, + PCWSTR(name.as_ptr()), + None, + Some(&mut ty), + Some(data.as_mut_ptr()), + Some(&mut len), + ) + }; + // SAFETY: closing the key opened above, exactly once. + unsafe { + let _ = RegCloseKey(hkey); + } + (rc.is_ok() && ty == REG_DWORD && len == 4).then(|| u32::from_le_bytes(data)) +} + +/// Find the devnode previously created for `pad_index` (see the module doc: the persisted +/// index value is the durable marker; DeviceDesc only survives until the INF installs). +fn find_devnode(pad_index: u8) -> Result> { + let set = media_class_devs()?; + for i in 0.. { + let mut did = devinfo_data(); + // SAFETY: live set; `did` is a live out-param with cbSize set. + if unsafe { SetupDiEnumDeviceInfo(set.0, i, &mut did) }.is_err() { + break; // ERROR_NO_MORE_ITEMS + } + let Some(inst) = instance_id(&set, &did) else { + continue; + }; + if !inst.to_ascii_uppercase().starts_with("ROOT\\") { + continue; + } + if devnode_pad_index(&set, &did) == Some(pad_index as u32) { + return Ok(Some(inst)); + } + } + Ok(None) +} + +/// Create + register a fresh MEDIA-class root devnode carrying the Steam Streaming Speakers +/// hardware id, and persist the pad slot in its `Device Parameters` key. +fn create_devnode(pad_index: u8) -> Result { + // SAFETY: the class GUID is a static const. + let set = unsafe { SetupDiCreateDeviceInfoList(Some(&GUID_DEVCLASS_MEDIA), None) } + .context("SetupDiCreateDeviceInfoList(MEDIA)")?; + let set = DevInfoSet(set); + let mut did = devinfo_data(); + let desc = wide(DEVNODE_DESC); + // SAFETY: name/class/description are live NUL-terminated buffers; DICD_GENERATE_ID makes + // PnP mint the ROOT\MEDIA\00NN instance id; `did` receives the element. + unsafe { + SetupDiCreateDeviceInfoW( + set.0, + w!("MEDIA"), + &GUID_DEVCLASS_MEDIA, + PCWSTR(desc.as_ptr()), + None, + DICD_GENERATE_ID, + Some(&mut did), + ) + } + .context("SetupDiCreateDeviceInfo")?; + let hwid = multi_sz_bytes(&[SSS_HWID]); + // SAFETY: live set + element; the multi-sz property bytes travel with the slice. + unsafe { SetupDiSetDeviceRegistryPropertyW(set.0, &mut did, SPDRP_HARDWAREID, Some(&hwid)) } + .context("set SPDRP_HARDWAREID")?; + // NOT SetupDiCallClassInstaller(DIF_REGISTERDEVICE): that requires an interactive window + // station and fails with error 1459 from a service. Plain registration is all a root + // devnode needs before UpdateDriverForPlugAndPlayDevices binds the driver. + // SAFETY: live set + element; no compare callback. + unsafe { SetupDiRegisterDeviceInfo(set.0, &mut did, 0, None, None, None) } + .context("SetupDiRegisterDeviceInfo")?; + write_pad_index(&set, &mut did, pad_index)?; + let inst = instance_id(&set, &did).context("read the new devnode's instance id")?; + tracing::info!(pad = pad_index, devnode = %inst, "created a pad-audio devnode"); + Ok(inst) +} + +/// Persist `pad_index` in the devnode's `Device Parameters` key (created on a fresh devnode). +fn write_pad_index(set: &DevInfoSet, did: &mut SP_DEVINFO_DATA, pad_index: u8) -> Result<()> { + // SAFETY: live set + element; DIREG_DEV opens the devnode's Device Parameters key. + let opened = unsafe { + SetupDiOpenDevRegKey( + set.0, + did, + DICS_FLAG_GLOBAL.0, + 0, + DIREG_DEV, + KEY_SET_VALUE.0, + ) + }; + let hkey = match opened { + Ok(k) => k, + // SAFETY: same set + element; a fresh devnode has no Device Parameters key yet, so + // create it (no INF association). + Err(_) => unsafe { + SetupDiCreateDevRegKeyW( + set.0, + did, + DICS_FLAG_GLOBAL.0, + 0, + DIREG_DEV, + None, + PCWSTR::null(), + ) + } + .context("create the devnode's Device Parameters key")?, + }; + let name = wide(PAD_INDEX_VALUE); + // SAFETY: the value name is NUL-terminated and outlives the call; the DWORD bytes travel + // with the slice. + let rc = unsafe { + RegSetValueExW( + hkey, + PCWSTR(name.as_ptr()), + None, + REG_DWORD, + Some(&(pad_index as u32).to_le_bytes()), + ) + }; + // SAFETY: closing the key opened/created above, exactly once. + unsafe { + let _ = RegCloseKey(hkey); + } + rc.ok().context("write PunktfunkPadIndex") +} + +/// The Steam Streaming Speakers INF to feed `UpdateDriverForPlugAndPlayDevices`: prefer the +/// INSTALLED driver's `oemNN.inf` (via `DEVPKEY_Device_DriverInfPath` on any devnode already +/// bound to the SSS hwid — Steam's own SSS devnode, or a pad devnode from an earlier run); +/// fall back to Steam's driver directory when no bound devnode exists yet. +fn resolve_sss_inf() -> Result { + let set = media_class_devs()?; + for i in 0.. { + let mut did = devinfo_data(); + // SAFETY: live set; `did` is a live out-param with cbSize set. + if unsafe { SetupDiEnumDeviceInfo(set.0, i, &mut did) }.is_err() { + break; + } + if !devnode_multi_sz_prop(&set, &did, SPDRP_HARDWAREID) + .iter() + .any(|h| h.eq_ignore_ascii_case(SSS_HWID)) + { + continue; + } + if let Some(inf) = devnode_inf_path(&set, &did) { + let windir = std::env::var("WINDIR").unwrap_or_else(|_| r"C:\Windows".into()); + let full = format!(r"{windir}\INF\{inf}"); + if std::path::Path::new(&full).exists() { + return Ok(full); + } + } + } + if let Some(w) = super::wasapi_mic::steam_driver_inf_path("SteamStreamingSpeakers.inf") { + let s = String::from_utf16_lossy(&w); + let s = s.trim_end_matches('\0').to_string(); + if std::path::Path::new(&s).exists() { + return Ok(s); + } + } + bail!( + "no Steam Streaming Speakers INF found (no installed SSS devnode, and Steam's driver \ + directory is absent) — install Steam, whose Remote Play streaming drivers provide it" + ) +} + +/// Bind the SSS driver to every unbound devnode carrying its hardware id (i.e. the pad +/// devnodes just created). Idempotent: "nothing needed an update" is success. +fn install_sss_driver() -> Result<()> { + let inf = resolve_sss_inf()?; + let inf_w = wide(&inf); + let hwid_w = wide(SSS_HWID); + // SAFETY: both strings are NUL-terminated and outlive the call; a null parent HWND and no + // reboot-required out-param are documented as accepted. + let r = unsafe { + UpdateDriverForPlugAndPlayDevicesW( + None, + PCWSTR(hwid_w.as_ptr()), + PCWSTR(inf_w.as_ptr()), + UPDATEDRIVERFORPLUGANDPLAYDEVICES_FLAGS(0), + None, + ) + }; + match r { + Ok(()) => { + tracing::info!(inf = %inf, "bound the Steam Streaming Speakers driver to the pad devnode(s)"); + Ok(()) + } + // ERROR_NO_MORE_ITEMS (0x80070103): every matching devnode already runs this (or a + // better) driver — the idempotent-reissue case, not a failure. + Err(e) if e.code().0 as u32 == 0x8007_0103 => Ok(()), + Err(e) => { + Err(anyhow!(e)).with_context(|| format!("UpdateDriverForPlugAndPlayDevices({inf})")) + } + } +} + +// --- endpoint discovery + stamping ---------------------------------------------------------- + +/// The render endpoint owned by `instance_id`, identified through the endpoint store's devnode +/// link (`"{1}."` under `…\MMDevices\Audio\Render\{ep}\Properties`). +fn find_endpoint_for_devnode(instance_id: &str) -> Result> { + use winreg::enums::HKEY_LOCAL_MACHINE; + use winreg::RegKey; + let want = format!("{{1}}.{instance_id}"); + let render = RegKey::predef(HKEY_LOCAL_MACHINE) + .open_subkey(MMDEV_RENDER_PATH) + .with_context(|| format!(r"open HKLM\{MMDEV_RENDER_PATH}"))?; + for key in render.enum_keys().flatten() { + let Ok(props) = render.open_subkey(format!(r"{key}\Properties")) else { + continue; + }; + let Ok(link) = props.get_value::(reg_value_name(&PKEY_ENDPOINT_DEVNODE)) else { + continue; + }; + if link.eq_ignore_ascii_case(&want) { + return Ok(Some(format!("{ENDPOINT_ID_PREFIX}{key}"))); + } + } + Ok(None) +} + +/// Poll for the endpoint after driver install (audiosrv registers it asynchronously). +fn wait_for_endpoint(instance_id: &str) -> Result { + let deadline = Instant::now() + ENDPOINT_WAIT; + loop { + if let Some(ep) = find_endpoint_for_devnode(instance_id)? { + return Ok(ep); + } + if Instant::now() >= deadline { + bail!( + "no render endpoint appeared for {instance_id} within {}s — is Audiosrv \ + running?", + ENDPOINT_WAIT.as_secs() + ); + } + thread::sleep(Duration::from_millis(250)); + } +} + +fn open_mmdevice(endpoint_id: &str) -> Result { + let id_w = wide(endpoint_id); + // SAFETY: standard COM activation on a COM-initialized thread; the id buffer is + // NUL-terminated and outlives the call. + unsafe { + let en: IMMDeviceEnumerator = CoCreateInstance(&MMDeviceEnumerator, None, CLSCTX_ALL) + .context("CoCreateInstance(MMDeviceEnumerator)")?; + en.GetDevice(PCWSTR(id_w.as_ptr())) + .with_context(|| format!("IMMDeviceEnumerator::GetDevice({endpoint_id})")) + } +} + +/// Does the property store SERVE this stamp's value right now? +fn stamp_served(store: &IPropertyStore, s: &Stamp) -> bool { + // SAFETY: the key is a valid PROPERTYKEY; GetValue returns an owned variant that is + // cleared below, exactly once. + let Ok(mut pv) = (unsafe { store.GetValue(&s.key) }) else { + return false; + }; + let matches = match &s.value { + StampValue::Str(v) => pv_string(&pv).is_some_and(|got| got == *v), + StampValue::Container(g) => pv_guid(&pv) == Some(*g), + StampValue::Format(wfx) => pv_bytes(&pv).is_some_and(|got| got == wfx[..]), + }; + // SAFETY: `pv` owns store-allocated memory; cleared exactly once, then dropped inert. + unsafe { + let _ = PropVariantClear(&mut pv); + } + matches +} + +fn set_store_value(store: &IPropertyStore, s: &Stamp) -> Result<()> { + match &s.value { + StampValue::Str(v) => { + let buf = wide(v); + let pv = pv_lpwstr(&buf); + // SAFETY: the variant borrows `buf`, which outlives the call; SetValue copies. + unsafe { store.SetValue(&s.key, &pv) }.context(s.label)?; + } + StampValue::Container(g) => { + let pv = pv_clsid(g); + // SAFETY: the variant borrows `g`, which outlives the call; SetValue copies. + unsafe { store.SetValue(&s.key, &pv) }.context(s.label)?; + } + StampValue::Format(wfx) => { + let pv = pv_blob(&wfx[..]); + // SAFETY: the variant borrows the static format bytes; SetValue copies. + unsafe { store.SetValue(&s.key, &pv) }.context(s.label)?; + } + } + Ok(()) +} + +/// Write the still-missing stamps: IPropertyStore first (audiosrv notices immediately, no +/// restart), raw registry for whatever it rejects. Idempotent — already-served keys are +/// skipped entirely. +fn stamp_endpoint(endpoint_id: &str, pad_index: u8) -> Result<()> { + let stamps = stamps_for(pad_index); + let dev = open_mmdevice(endpoint_id)?; + let pending: Vec<&Stamp> = { + // SAFETY: read-only property store on a COM-initialized thread. + let store = + unsafe { dev.OpenPropertyStore(STGM_READ) }.context("OpenPropertyStore(STGM_READ)")?; + stamps.iter().filter(|s| !stamp_served(&store, s)).collect() + }; + if pending.is_empty() { + tracing::debug!(endpoint = %endpoint_id, pad = pad_index, "pad endpoint already fully stamped"); + return Ok(()); + } + let mut via_store: Vec<&'static str> = Vec::new(); + let mut via_registry: Vec<&Stamp> = Vec::new(); + // SAFETY: read-write property store on a COM-initialized thread (may be denied — handled). + match unsafe { dev.OpenPropertyStore(STGM_READWRITE) } { + Ok(rw) => { + for s in &pending { + match set_store_value(&rw, s) { + Ok(()) => via_store.push(s.label), + Err(e) => { + tracing::debug!(key = s.label, error = %format!("{e:#}"), + "IPropertyStore rejected a pad stamp — registry route"); + via_registry.push(s); + } + } + } + if !via_store.is_empty() { + // SAFETY: committing the writes above on the same store/thread. + if let Err(e) = unsafe { rw.Commit() } { + // A failed commit may have dropped every store-side write — re-route them + // all through the registry rather than trust half a stamp. + tracing::debug!(error = %format!("{e:#}"), + "IPropertyStore::Commit failed — registry route for all pending stamps"); + via_store.clear(); + via_registry = pending.clone(); + } + } + } + Err(e) => { + tracing::debug!(error = %format!("{e:#}"), + "pad endpoint property store not writable — registry route for all stamps"); + via_registry = pending.clone(); + } + } + if !via_registry.is_empty() { + registry_stamp(endpoint_id, &via_registry)?; + } + tracing::info!( + endpoint = %endpoint_id, + pad = pad_index, + property_store = ?via_store, + registry = ?via_registry.iter().map(|s| s.label).collect::>(), + "pad endpoint stamped (route per key)" + ); + Ok(()) +} + +/// MEASURED ACL trap (on-glass 2026-08-01): `…\MMDevices\Audio\Render\{ep}\Properties` denies +/// writes even to SYSTEM — SYSTEM is the key OWNER but the DACL carries no write ACE for it. +/// The owner's IMPLICIT right to WRITE_DAC still applies, so: open with +/// READ_CONTROL|WRITE_DAC, append a FullControl ACE for S-1-5-18, write the DACL back. +/// Principals are resolved BY SID (a well-known-SID build), never by name — account-name +/// lookups like "BUILTIN\Administrators" fail to translate on localized (e.g. German) Windows. +/// Works when running as SYSTEM (the production host service); a dev run fails here and the +/// caller degrades with the error. +fn grant_system_full_control(subkey_path: &str) -> Result<()> { + use windows::Win32::Foundation::{LocalFree, HANDLE, HLOCAL}; + use windows::Win32::Security::Authorization::{ + GetSecurityInfo, SetEntriesInAclW, SetSecurityInfo, EXPLICIT_ACCESS_W, GRANT_ACCESS, + NO_MULTIPLE_TRUSTEE, SE_REGISTRY_KEY, TRUSTEE_IS_SID, TRUSTEE_IS_USER, TRUSTEE_W, + }; + use windows::Win32::Security::{ + CreateWellKnownSid, WinLocalSystemSid, ACL, CONTAINER_INHERIT_ACE, + DACL_SECURITY_INFORMATION, PSECURITY_DESCRIPTOR, PSID, SECURITY_MAX_SID_SIZE, + }; + use windows::Win32::System::Registry::{ + RegOpenKeyExW, HKEY, HKEY_LOCAL_MACHINE, KEY_ALL_ACCESS, REG_SAM_FLAGS, + }; + + const READ_CONTROL: u32 = 0x0002_0000; + const WRITE_DAC: u32 = 0x0004_0000; + let path_w = wide(subkey_path); + let mut hkey = HKEY::default(); + // SAFETY: the path is NUL-terminated and outlives the call; hkey is a live out-param. + unsafe { + RegOpenKeyExW( + HKEY_LOCAL_MACHINE, + PCWSTR(path_w.as_ptr()), + None, + REG_SAM_FLAGS(READ_CONTROL | WRITE_DAC), + &mut hkey, + ) + } + .ok() + .with_context(|| { + format!("open {subkey_path} for WRITE_DAC (owner-implicit right — requires SYSTEM)") + })?; + let handle = HANDLE(hkey.0); + let mut old_dacl: *mut ACL = std::ptr::null_mut(); + let mut sd = PSECURITY_DESCRIPTOR::default(); + // SAFETY: live handle; out-params are live locals; the returned descriptor is LocalFree'd + // below. + let gs = unsafe { + GetSecurityInfo( + handle, + SE_REGISTRY_KEY, + DACL_SECURITY_INFORMATION, + None, + None, + Some(&mut old_dacl), + None, + Some(&mut sd), + ) + }; + let result = (|| -> Result<()> { + gs.ok().context("GetSecurityInfo(DACL)")?; + let mut sid = [0u8; SECURITY_MAX_SID_SIZE as usize]; + let mut cb = sid.len() as u32; + // SAFETY: the buffer is SECURITY_MAX_SID_SIZE, the documented maximum SID size. + unsafe { + CreateWellKnownSid( + WinLocalSystemSid, + None, + Some(PSID(sid.as_mut_ptr().cast())), + &mut cb, + ) + } + .context("CreateWellKnownSid(S-1-5-18)")?; + let ea = EXPLICIT_ACCESS_W { + grfAccessPermissions: KEY_ALL_ACCESS.0, + grfAccessMode: GRANT_ACCESS, + grfInheritance: CONTAINER_INHERIT_ACE, + Trustee: TRUSTEE_W { + pMultipleTrustee: std::ptr::null_mut(), + MultipleTrusteeOperation: NO_MULTIPLE_TRUSTEE, + TrusteeForm: TRUSTEE_IS_SID, + TrusteeType: TRUSTEE_IS_USER, + ptstrName: PWSTR(sid.as_mut_ptr().cast()), + }, + }; + let mut new_dacl: *mut ACL = std::ptr::null_mut(); + // SAFETY: one live entry whose SID buffer outlives the call; old_dacl is the (possibly + // null) DACL GetSecurityInfo returned, still owned by `sd`. + unsafe { + SetEntriesInAclW( + Some(&[ea]), + (!old_dacl.is_null()).then_some(old_dacl as *const ACL), + &mut new_dacl, + ) + } + .ok() + .context("SetEntriesInAclW")?; + // SAFETY: new_dacl is the ACL SetEntriesInAclW just allocated; freed right after. + let ss = unsafe { + SetSecurityInfo( + handle, + SE_REGISTRY_KEY, + DACL_SECURITY_INFORMATION, + None, + None, + Some(new_dacl), + None, + ) + }; + // SAFETY: LocalFree of the SetEntriesInAclW allocation, exactly once. + unsafe { + let _ = LocalFree(Some(HLOCAL(new_dacl.cast()))); + } + ss.ok().context("SetSecurityInfo(DACL)") + })(); + // SAFETY: free the descriptor GetSecurityInfo allocated (skipped when null) and close the + // key opened above, each exactly once. + unsafe { + if !sd.0.is_null() { + let _ = LocalFree(Some(HLOCAL(sd.0))); + } + let _ = RegCloseKey(hkey); + } + result +} + +/// The raw-registry stamp route: repair the Properties key ACL, then write the serialized +/// values (see [`reg_registry_value`]). Values written here are STORED but possibly not +/// SERVED until an AudioEndpointBuilder restart — the caller's read-back decides. +fn registry_stamp(endpoint_id: &str, stamps: &[&Stamp]) -> Result<()> { + use winreg::enums::HKEY_LOCAL_MACHINE; + use winreg::RegKey; + let guid = endpoint_guid_part(endpoint_id)?; + let path = format!(r"{MMDEV_RENDER_PATH}\{guid}\Properties"); + grant_system_full_control(&path) + .with_context(|| format!("make {path} writable (registry stamp route)"))?; + let key = RegKey::predef(HKEY_LOCAL_MACHINE) + .open_subkey_with_flags(&path, KEY_QUERY_VALUE.0 | KEY_SET_VALUE.0) + .with_context(|| format!("open {path} for writing"))?; + for s in stamps { + key.set_raw_value(reg_value_name(&s.key), ®_registry_value(&s.value)) + .with_context(|| format!("write {} ({})", reg_value_name(&s.key), s.label))?; + } + Ok(()) +} + +/// True when EVERY stamp reads back — through a fresh property store — with the stamped value, +/// i.e. the audio stack SERVES the identity rather than merely storing it. Any error counts as +/// "not served" (the only consumer is the needs-AEB-kick decision). +fn all_served(endpoint_id: &str, pad_index: u8) -> bool { + let Ok(dev) = open_mmdevice(endpoint_id) else { + return false; + }; + // SAFETY: read-only property store on a COM-initialized thread. + let Ok(store) = (unsafe { dev.OpenPropertyStore(STGM_READ) }) else { + return false; + }; + stamps_for(pad_index) + .iter() + .all(|s| stamp_served(&store, s)) +} + +// --- public provisioning API ---------------------------------------------------------------- + +/// Idempotently provision the pad-audio endpoint for one pad slot: reuse (or create) the +/// devnode, bind the Steam Streaming Speakers driver, wait for the endpoint, stamp the +/// DualSense identity, and undo any default-playback flip onto the new endpoint. Called at +/// host startup (pre-provisioning) — NOT per session. Must run on (or become) a +/// COM-initialized thread; WASAPI objects never leave it. +pub fn ensure(pad_index: u8) -> Result { + anyhow::ensure!(pad_index < 8, "pad index out of range (0..=7): {pad_index}"); + wasapi::initialize_mta() + .ok() + .context("CoInitializeEx (MTA)")?; + let prev_default = audio_control::default_render_id(); + let device_instance = match find_devnode(pad_index)? { + Some(inst) => inst, + None => create_devnode(pad_index)?, + }; + let endpoint_id = match find_endpoint_for_devnode(&device_instance)? { + Some(ep) => ep, + None => { + install_sss_driver().context("bind the Steam Streaming Speakers driver")?; + wait_for_endpoint(&device_instance)? + } + }; + stamp_endpoint(&endpoint_id, pad_index) + .with_context(|| format!("stamp pad endpoint {endpoint_id}"))?; + // Default-device guard: a freshly registered render endpoint can grab the default. A pad + // "speaker" as default playback would swallow ALL desktop audio — put the previous default + // back via the IPolicyConfig machinery audio_control already owns. + if audio_control::default_render_id().as_deref() == Some(endpoint_id.as_str()) + && prev_default.as_deref() != Some(endpoint_id.as_str()) + { + match &prev_default { + Some(prev) => match audio_control::set_default_endpoint(prev) { + Ok(()) => tracing::info!(pad = pad_index, + "default playback had moved to the new pad endpoint — restored the previous default"), + Err(e) => tracing::warn!(pad = pad_index, error = %format!("{e:#}"), + "default playback moved to the new pad endpoint and could not be restored"), + }, + None => tracing::warn!(pad = pad_index, + "default playback moved to the new pad endpoint and no previous default is known"), + } + } + let served = all_served(&endpoint_id, pad_index); + Ok(PadEndpoint { + endpoint_id, + device_instance, + pad_index, + needs_aeb_kick: !served, + }) +} + +/// Best-effort teardown by devnode removal (`pnputil /remove-device`). Not called in normal +/// operation — endpoints are persistent by design; this backs tests and the +/// `pad-endpoint remove` escape hatch. +pub fn remove(pe: &PadEndpoint) { + let windir = std::env::var("WINDIR").unwrap_or_else(|_| r"C:\Windows".into()); + let pnputil = format!(r"{windir}\System32\pnputil.exe"); + match std::process::Command::new(&pnputil) + .args(["/remove-device", &pe.device_instance]) + .output() + { + Ok(o) if o.status.success() => { + tracing::info!(devnode = %pe.device_instance, "pad-audio devnode removed") + } + Ok(o) => tracing::warn!(devnode = %pe.device_instance, status = ?o.status.code(), + stderr = %String::from_utf8_lossy(&o.stderr).trim(), + "pnputil could not remove the pad-audio devnode"), + Err(e) => tracing::warn!(devnode = %pe.device_instance, error = %e, + "could not run pnputil to remove the pad-audio devnode"), + } +} + +/// Locate (never create) the provisioned state for a pad slot — the `status`/`remove` devtest +/// path. `endpoint_id` is empty when the devnode exists but its endpoint never registered. +pub(crate) fn find(pad_index: u8) -> Result> { + wasapi::initialize_mta() + .ok() + .context("CoInitializeEx (MTA)")?; + let Some(device_instance) = find_devnode(pad_index)? else { + return Ok(None); + }; + let endpoint_id = find_endpoint_for_devnode(&device_instance)?.unwrap_or_default(); + let needs_aeb_kick = endpoint_id.is_empty() || !all_served(&endpoint_id, pad_index); + Ok(Some(PadEndpoint { + endpoint_id, + device_instance, + pad_index, + needs_aeb_kick, + })) +} + +/// `pad-endpoint status` devtest body: devnode, endpoint, and per-stamp stored/served state. +pub(crate) fn print_status(pad_index: u8) -> Result<()> { + use winreg::enums::HKEY_LOCAL_MACHINE; + use winreg::RegKey; + wasapi::initialize_mta() + .ok() + .context("CoInitializeEx (MTA)")?; + let Some(inst) = find_devnode(pad_index)? else { + println!("pad {pad_index}: no pad-audio devnode"); + return Ok(()); + }; + println!("pad {pad_index}: devnode {inst}"); + let Some(ep) = find_endpoint_for_devnode(&inst)? else { + println!(" endpoint: NONE (driver not installed, or the endpoint never registered)"); + return Ok(()); + }; + println!(" endpoint: {ep}"); + let props = RegKey::predef(HKEY_LOCAL_MACHINE) + .open_subkey(format!( + r"{MMDEV_RENDER_PATH}\{}\Properties", + endpoint_guid_part(&ep)? + )) + .context("open the endpoint's Properties key")?; + let dev = open_mmdevice(&ep)?; + // SAFETY: read-only property store on the MTA-initialized current thread. + let store = unsafe { dev.OpenPropertyStore(STGM_READ) }.context("OpenPropertyStore")?; + let mut all = true; + for s in stamps_for(pad_index) { + let stored = match &s.value { + StampValue::Str(v) => props + .get_value::(reg_value_name(&s.key)) + .map(|got| got == *v) + .unwrap_or(false), + other => props + .get_raw_value(reg_value_name(&s.key)) + .map(|rv| rv.bytes == reg_registry_value(other).bytes) + .unwrap_or(false), + }; + let served = stamp_served(&store, &s); + all &= served; + println!(" {:<14} stored={stored} served={served}", s.label); + } + println!(" needs_aeb_kick={}", !all); + Ok(()) +} + +// --- wiring-plan exclusion data ------------------------------------------------------------- + +/// Is this render endpoint one of ours (a pad "speaker" that must never become a mic target, +/// loopback source, or default device)? Registry-only — callable off the COM threads and cheap +/// enough for every wiring pass. Rules (either suffices): +/// +/// * the STORED ContainerId is a PFDS container (`Data1 == "PFDS"`), or +/// * the endpoint's devnode is one of ours — the `PunktfunkPadIndex` marker (or, pre-install, +/// the creation DeviceDesc). +/// +/// Positives are cached (a pad endpoint never becomes a normal one); negatives are recomputed +/// each time, because an endpoint the wiring plan saw BEFORE `ensure()` stamped it must flip +/// to excluded on the next pass. +pub(crate) fn is_pad_render_endpoint(endpoint_id: &str) -> bool { + static KNOWN: OnceLock>> = OnceLock::new(); + let known = KNOWN.get_or_init(|| Mutex::new(HashSet::new())); + if known.lock().unwrap().contains(endpoint_id) { + return true; + } + let is_pad = compute_is_pad_endpoint(endpoint_id); + if is_pad { + known.lock().unwrap().insert(endpoint_id.to_string()); + } + is_pad +} + +fn compute_is_pad_endpoint(endpoint_id: &str) -> bool { + use winreg::enums::HKEY_LOCAL_MACHINE; + use winreg::RegKey; + let Ok(guid) = endpoint_guid_part(endpoint_id) else { + return false; + }; + let hklm = RegKey::predef(HKEY_LOCAL_MACHINE); + let Ok(props) = hklm.open_subkey(format!(r"{MMDEV_RENDER_PATH}\{guid}\Properties")) else { + return false; + }; + // (a) stamped container: serialized VT_CLSID whose Data1 spells "PFDS". + if let Ok(v) = props.get_raw_value(reg_value_name(&PKEY_CONTAINER_ID)) { + if v.bytes.len() >= 12 && v.bytes[0] == 0x48 && v.bytes[8..12] == PFDS_TAG.to_le_bytes() { + return true; + } + } + // (b) created-but-not-yet-stamped: the owning devnode carries our marker. + let Ok(link) = props.get_value::(reg_value_name(&PKEY_ENDPOINT_DEVNODE)) else { + return false; + }; + let Some(inst) = link.strip_prefix("{1}.") else { + return false; + }; + let enum_key = format!(r"SYSTEM\CurrentControlSet\Enum\{inst}"); + if hklm + .open_subkey(format!(r"{enum_key}\Device Parameters")) + .and_then(|k| k.get_raw_value(PAD_INDEX_VALUE)) + .is_ok() + { + return true; + } + hklm.open_subkey(&enum_key) + .and_then(|k| k.get_value::("DeviceDesc")) + .map(|d| d == DEVNODE_DESC) + .unwrap_or(false) +} + +// --- host-startup integration --------------------------------------------------------------- + +/// `PUNKTFUNK_PAD_AUDIO`: unset or anything but "0" = on; "0" = off. +fn pad_audio_enabled() -> bool { + std::env::var_os("PUNKTFUNK_PAD_AUDIO").is_none_or(|v| v != "0") +} + +/// `PUNKTFUNK_PAD_AUDIO_SLOTS`: how many pad slots to pre-provision (default 1, max 4). +fn pad_audio_slots() -> u8 { + std::env::var("PUNKTFUNK_PAD_AUDIO_SLOTS") + .ok() + .and_then(|s| s.parse::().ok()) + .unwrap_or(1) + .clamp(1, 4) +} + +/// The endpoints provisioned at startup, set exactly once by the worker thread. +static PROVISIONED: OnceLock>> = OnceLock::new(); + +/// Host-startup pre-provisioning (Windows, env-gated): spawn a COM worker that `ensure()`s +/// endpoints for slots `0..N`, performs at most ONE AudioEndpointBuilder+Audiosrv restart if +/// any stamp is stored-but-not-served (then re-verifies), and publishes the results for the +/// session layer ([`endpoint_for`]). Any failure logs one warning and leaves the feature off — +/// the pad itself keeps working, just without audio. +pub(crate) fn provision_at_startup() { + if !pad_audio_enabled() { + tracing::info!("pad audio disabled (PUNKTFUNK_PAD_AUDIO=0)"); + return; + } + if PROVISIONED.get().is_some() { + return; + } + let slots = pad_audio_slots(); + let spawned = thread::Builder::new() + .name("punktfunk-pad-audio".into()) + .spawn(move || { + let mut eps: Vec = Vec::new(); + for idx in 0..slots { + match ensure(idx) { + Ok(pe) => { + tracing::info!(pad = idx, endpoint = %pe.endpoint_id, + needs_aeb_kick = pe.needs_aeb_kick, "pad-audio endpoint ready"); + eps.push(pe); + } + Err(e) => { + tracing::warn!(pad = idx, error = %format!("{e:#}"), + "pad-audio endpoint provisioning failed — pad audio unavailable \ + (pads still work, without a pad speaker)"); + break; + } + } + } + if eps.iter().any(|p| p.needs_aeb_kick) { + // One restart, at startup, before any session exists — never mid-flight. + match restart_audio_endpoint_services() { + Ok(()) => { + for pe in &mut eps { + pe.needs_aeb_kick = !all_served(&pe.endpoint_id, pe.pad_index); + if pe.needs_aeb_kick { + tracing::warn!(pad = pe.pad_index, endpoint = %pe.endpoint_id, + "pad endpoint stamps still not served after the audio-stack \ + restart"); + } + } + } + Err(e) => tracing::warn!(error = %format!("{e:#}"), + "could not restart the audio stack for the pad endpoints — stamps stay \ + stored-but-not-served until the next reboot"), + } + } + let _ = PROVISIONED.set(Arc::new(eps)); + }); + if let Err(e) = spawned { + tracing::warn!(error = %e, "could not spawn the pad-audio provisioning thread"); + } +} + +/// All endpoints provisioned at startup (`None` while provisioning runs / when disabled). +#[allow(dead_code)] +pub(crate) fn provisioned_endpoints() -> Option>> { + PROVISIONED.get().cloned() +} + +/// The provisioned endpoint for one pad slot — what a session queries when a client pad with +/// speaker support arrives, to attach a [`PadLoopbackCapturer`]. +#[allow(dead_code)] +pub(crate) fn endpoint_for(pad_index: u8) -> Option { + PROVISIONED + .get()? + .iter() + .find(|p| p.pad_index == pad_index) + .cloned() +} + +/// Restart AudioEndpointBuilder + Audiosrv (dependency order: the dependent Audiosrv stops +/// first, starts last) so registry-routed stamps get served. Mirrors the SCM idioms of +/// `service.rs::restart`. +fn restart_audio_endpoint_services() -> Result<()> { + use windows_service::service::{Service, ServiceAccess, ServiceState}; + use windows_service::service_manager::{ServiceManager, ServiceManagerAccess}; + + fn stop_and_wait(svc: &Service, name: &str) -> Result<()> { + let _ = svc.stop(); // ERROR_SERVICE_NOT_ACTIVE just means it is already down + let deadline = Instant::now() + Duration::from_secs(20); + loop { + let state = svc + .query_status() + .with_context(|| format!("query {name} status"))?; + if state.current_state == ServiceState::Stopped { + return Ok(()); + } + if Instant::now() >= deadline { + bail!("{name} did not stop within 20 s"); + } + thread::sleep(Duration::from_millis(250)); + } + } + + tracing::info!( + "restarting AudioEndpointBuilder + Audiosrv once so the pad endpoints serve their \ + stamped identity (host startup, before any session)" + ); + let mgr = ServiceManager::local_computer(None::<&str>, ServiceManagerAccess::CONNECT) + .context("open Service Control Manager")?; + let access = ServiceAccess::STOP | ServiceAccess::START | ServiceAccess::QUERY_STATUS; + let audiosrv = mgr + .open_service("Audiosrv", access) + .context("open Audiosrv")?; + let aeb = mgr + .open_service("AudioEndpointBuilder", access) + .context("open AudioEndpointBuilder")?; + stop_and_wait(&audiosrv, "Audiosrv")?; + stop_and_wait(&aeb, "AudioEndpointBuilder")?; + aeb.start(&[] as &[&std::ffi::OsStr]) + .context("start AudioEndpointBuilder")?; + audiosrv + .start(&[] as &[&std::ffi::OsStr]) + .context("start Audiosrv")?; + let deadline = Instant::now() + Duration::from_secs(20); + loop { + let state = audiosrv.query_status().context("query Audiosrv status")?; + if state.current_state == ServiceState::Running { + return Ok(()); + } + if Instant::now() >= deadline { + bail!("Audiosrv did not come back within 20 s"); + } + thread::sleep(Duration::from_millis(250)); + } +} + +// --- loopback capture ----------------------------------------------------------------------- + +/// The pad endpoint's channel count (quad). +pub const PAD_CHANNELS: u32 = 4; +/// `dwChannelMask` for the 4-ch pad layout (FL FR BL BR) — what the endpoint is stamped with. +/// NOT `punktfunk_core::audio::wasapi_channel_mask`, which only speaks the GameStream +/// stereo/5.1/7.1 layouts. +const PAD_CHANNEL_MASK: u32 = 0x33; +/// 4 ch × f32. +const PAD_BLOCK_ALIGN: usize = PAD_CHANNELS as usize * 4; + +/// WASAPI loopback capture of one pad endpoint: whatever a game renders into the pad +/// "speaker" comes out as interleaved 4-ch f32 at 48 kHz, ready for the pad-audio downlink. +/// Same COM discipline as [`super::wasapi_cap`]: the WASAPI objects live on a dedicated +/// thread; the struct holds only the channel + stop flag + join handle. Any device error +/// (endpoint invalidated, engine restart) ends the thread — [`AudioCapturer::next_chunk`] +/// then returns `Err` and the caller reopens. +pub struct PadLoopbackCapturer { + chunks: Receiver>, + stop: Arc, + join: Option>, +} + +impl PadLoopbackCapturer { + /// Open a loopback capture on a provisioned pad endpoint (its full WASAPI endpoint id — + /// [`PadEndpoint::endpoint_id`]). + pub fn open(endpoint_id: &str) -> Result { + let (tx, rx) = sync_channel::>(64); + let stop = Arc::new(AtomicBool::new(false)); + // Bring-up handshake: surface an open failure as Err (caller retries/reopens), never a + // silent dead thread — the crate-wide capture idiom. + let (ready_tx, ready_rx) = sync_channel::>(1); + let (stop_t, id) = (stop.clone(), endpoint_id.to_string()); + let join = thread::Builder::new() + .name("punktfunk-pad-cap".into()) + .spawn(move || { + if let Err(e) = pad_capture_thread(&id, tx, stop_t, ready_tx) { + tracing::error!(error = %format!("{e:#}"), "pad loopback thread failed"); + } + }) + .context("spawn pad loopback thread")?; + match ready_rx.recv_timeout(Duration::from_secs(10)) { + Ok(Ok(())) => Ok(PadLoopbackCapturer { + chunks: rx, + stop, + join: Some(join), + }), + Ok(Err(e)) => Err(e), + Err(_) => { + stop.store(true, Ordering::SeqCst); + Err(anyhow!("pad loopback init timed out")) + } + } + } +} + +impl Drop for PadLoopbackCapturer { + fn drop(&mut self) { + self.stop.store(true, Ordering::SeqCst); + if let Some(j) = self.join.take() { + let _ = j.join(); + } + } +} + +impl AudioCapturer for PadLoopbackCapturer { + fn next_chunk(&mut self) -> Result> { + match self.chunks.recv_timeout(Duration::from_secs(5)) { + Ok(c) => Ok(c), + // A quiet pad (no game renders into it) is NOT a failure — empty chunk, keep the + // capturer. Err is reserved for a dead capture thread (device invalidated), which + // tells the caller to reopen. + Err(RecvTimeoutError::Timeout) => Ok(Vec::new()), + Err(RecvTimeoutError::Disconnected) => Err(anyhow!("pad loopback thread ended")), + } + } + fn channels(&self) -> u32 { + PAD_CHANNELS + } + fn drain(&mut self) { + while self.chunks.try_recv().is_ok() {} + } +} + +fn pad_capture_thread( + endpoint_id: &str, + tx: SyncSender>, + stop: Arc, + ready: SyncSender>, +) -> Result<()> { + if let Err(e) = wasapi::initialize_mta() + .ok() + .context("CoInitializeEx (MTA)") + { + let _ = ready.send(Err(e)); + return Ok(()); + } + // Open the endpoint EXPLICITLY by id (never a default-device resolve — pad endpoints must + // never be anyone's default). 48 kHz 4-ch f32 with shared-mode autoconvert, so the engine + // hands us the contract format regardless of the endpoint's current mix format; capturing + // a RENDER device with Direction::Capture in shared mode is WASAPI loopback. + let setup = (|| -> Result<(wasapi::AudioClient, wasapi::AudioCaptureClient, wasapi::Handle)> { + let device = wasapi::DeviceEnumerator::new() + .map_err(|e| anyhow!("DeviceEnumerator: {e}"))? + .get_device(endpoint_id) + .map_err(|e| anyhow!("open pad endpoint {endpoint_id}: {e}"))?; + let mut audio_client = device.get_iaudioclient().context("IAudioClient")?; + let desired = WaveFormat::new( + 32, + 32, + &SampleType::Float, + SAMPLE_RATE as usize, + PAD_CHANNELS as usize, + Some(PAD_CHANNEL_MASK), + ); + let (default_period, _min) = audio_client.get_device_period().context("device period")?; + let mode = StreamMode::EventsShared { + autoconvert: true, + buffer_duration_hns: default_period, + }; + audio_client + .initialize_client(&desired, &Direction::Capture, &mode) + .context("initialize pad loopback client")?; + let h_event = audio_client.set_get_eventhandle().context("event handle")?; + let capture_client = audio_client + .get_audiocaptureclient() + .context("IAudioCaptureClient")?; + audio_client.start_stream().context("start pad loopback")?; + Ok((audio_client, capture_client, h_event)) + })(); + let (audio_client, capture_client, h_event) = match setup { + Ok(t) => t, + Err(e) => { + let _ = ready.send(Err(anyhow!("{e:#}"))); + return Ok(()); + } + }; + let _ = ready.send(Ok(())); + tracing::info!(endpoint = %endpoint_id, "pad loopback capturing (4 ch / 48 kHz f32)"); + + // Any error below (endpoint invalidated/removed, engine restart) ends the thread; the + // channel disconnect surfaces as next_chunk() -> Err and the caller reopens. + let mut bytes: VecDeque = VecDeque::new(); + while !stop.load(Ordering::Relaxed) { + // Loopback fires events only while a game renders into the pad; the finite timeout + // keeps `stop` responsive across silence. + let _ = h_event.wait_for_event(100); + loop { + match capture_client.get_next_packet_size() { + Ok(Some(0)) | Ok(None) => break, + Ok(Some(_n)) => { + capture_client + .read_from_device_to_deque(&mut bytes) + .context("read pad loopback")?; + } + Err(e) => return Err(anyhow!("get_next_packet_size: {e}")), + } + } + let whole = (bytes.len() / PAD_BLOCK_ALIGN) * PAD_BLOCK_ALIGN; + if whole > 0 { + let raw: Vec = bytes.drain(..whole).collect(); + let mut samples = Vec::with_capacity(whole / 4); + for c in raw.chunks_exact(4) { + samples.push(f32::from_le_bytes([c[0], c[1], c[2], c[3]])); + } + let _ = tx.try_send(samples); // non-blocking, lossy — the crate's capture discipline + } + } + audio_client.stop_stream().ok(); + Ok(()) +} + +// --- pure-logic tests (no devnodes, no COM) ------------------------------------------------- + +#[cfg(test)] +mod tests { + use super::*; + + /// The serialized container blob for pad 0 must be byte-for-byte the on-glass-measured + /// value, and byte 23 must be the pad index. + #[test] + fn container_registry_blob_matches_measured() { + let v = reg_registry_value(&StampValue::Container(pfds_container_guid(0))); + let expect: Vec = vec![ + 0x48, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, // VT_CLSID header + 0x53, 0x44, 0x46, 0x50, // "PFDS" little-endian Data1 + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + ]; + assert_eq!(v.bytes.as_ref(), expect.as_slice()); + for idx in [1u8, 3, 7] { + let v = reg_registry_value(&StampValue::Container(pfds_container_guid(idx))); + assert_eq!(v.bytes.len(), 24); + assert_eq!(v.bytes[23], idx, "byte 23 must be the pad index"); + } + } + + /// Both WAVEFORMATEXTENSIBLE legs: 4 ch, 48 kHz, mask 0x33, and internally consistent + /// block alignment / byte rate. + #[test] + fn wfx_blobs_are_4ch_48k() { + for (wfx, bits) in [(&WFX_PCM16_4CH_48K, 16u16), (&WFX_F32_4CH_48K, 32u16)] { + assert_eq!(wfx.len(), 40); + let ch = u16::from_le_bytes([wfx[2], wfx[3]]); + let rate = u32::from_le_bytes([wfx[4], wfx[5], wfx[6], wfx[7]]); + let byte_rate = u32::from_le_bytes([wfx[8], wfx[9], wfx[10], wfx[11]]); + let align = u16::from_le_bytes([wfx[12], wfx[13]]); + let b = u16::from_le_bytes([wfx[14], wfx[15]]); + let mask = u32::from_le_bytes([wfx[20], wfx[21], wfx[22], wfx[23]]); + assert_eq!(ch, 4); + assert_eq!(rate, 48_000); + assert_eq!(b, bits); + assert_eq!(align as u32, ch as u32 * bits as u32 / 8); + assert_eq!(byte_rate, rate * align as u32); + assert_eq!(mask, PAD_CHANNEL_MASK); + } + // The serialized registry shape adds the 8-byte VT_BLOB header. + let v = reg_registry_value(&StampValue::Format(&WFX_F32_4CH_48K)); + assert_eq!(v.bytes.len(), 48); + assert_eq!(&v.bytes[..8], &[0x41, 0, 0, 0, 1, 0, 0, 0]); + } + + /// Registry value names use the lowercase `{fmtid},pid` spelling MMDevices uses. + #[test] + fn reg_value_names() { + assert_eq!( + reg_value_name(&PKEY_CONTAINER_ID), + "{8c7ed206-3f8a-4827-b3ab-ae9e1faefc6c},2" + ); + assert_eq!( + reg_value_name(&PKEY_ENDPOINT_DEVNODE), + "{b3f8fa53-0004-438e-9003-51a46e139bfc},2" + ); + assert_eq!( + reg_value_name(&PKEY_ENDPOINT_DEVICE_NAME), + "{b3f8fa53-0004-438e-9003-51a46e139bfc},6" + ); + } + + #[test] + fn endpoint_guid_extraction() { + let id = "{0.0.0.00000000}.{aeb07c72-0f2b-4d3c-9a08-2b4a01234567}"; + assert_eq!( + endpoint_guid_part(id).unwrap(), + "{aeb07c72-0f2b-4d3c-9a08-2b4a01234567}" + ); + assert!(endpoint_guid_part("bogus").is_err()); + } + + /// The string stamps stay REG_SZ (UTF-16LE + NUL), not serialized blobs. + #[test] + fn string_stamp_is_reg_sz() { + let v = reg_registry_value(&StampValue::Str("Wireless Controller")); + assert_eq!(v.vtype, winreg::enums::REG_SZ); + assert_eq!(v.bytes.len(), ("Wireless Controller".len() + 1) * 2); + assert_eq!(&v.bytes[..2], &[b'W', 0]); + } +} diff --git a/crates/punktfunk-host/src/audio/windows/wasapi_mic.rs b/crates/punktfunk-host/src/audio/windows/wasapi_mic.rs index dbc15aa0..f52ed991 100644 --- a/crates/punktfunk-host/src/audio/windows/wasapi_mic.rs +++ b/crates/punktfunk-host/src/audio/windows/wasapi_mic.rs @@ -253,25 +253,16 @@ pub(crate) fn install_steam_audio_pair() -> bool { mic || spk } -/// Install one Steam Streaming driver INF by filename via `DiInstallDriverW` (loaded from -/// `newdev.dll`, like Apollo, to avoid an extra windows-crate feature). See -/// [`install_steam_audio_pair`] for the contract; `inf_name` is a bare filename under Steam's -/// per-arch `drivers\Windows10\{arch}\` directory. -/// -/// Safe: `inf_name` is a `&str` and every FFI argument is built locally from it, so there is no -/// precondition a caller could break — the `unsafe` is the `LoadLibraryExW`/`transmute`/call chain -/// inside, which is this function's own business. -fn try_install_steam_audio(inf_name: &str) -> bool { - use windows::core::{s, w, PCWSTR}; - use windows::Win32::Foundation::HWND; +/// Full path of a Steam Remote Play driver INF under Steam's per-arch driver directory +/// (`%CommonProgramFiles(x86)%\Steam\drivers\Windows10\{arch}\`), as a NUL-terminated +/// UTF-16 buffer. Shared by [`try_install_steam_audio`] and the pad-endpoint provisioning +/// ([`super::pad_endpoint`]), which feeds the same INF to `UpdateDriverForPlugAndPlayDevicesW` +/// when no installed Steam Streaming Speakers devnode exposes its `oemNN.inf`. `None` when the +/// environment expansion fails (existence is the caller's check). +pub(crate) fn steam_driver_inf_path(inf_name: &str) -> Option> { + use windows::core::PCWSTR; use windows::Win32::System::Environment::ExpandEnvironmentStringsW; - use windows::Win32::System::LibraryLoader::{ - GetProcAddress, LoadLibraryExW, LOAD_LIBRARY_SEARCH_SYSTEM32, - }; - if std::env::var_os("PUNKTFUNK_NO_MIC_INSTALL").is_some() { - return false; - } // Steam ships per-arch driver INFs under `Steam\drivers\Windows10\{arch}\`. #[cfg(target_arch = "x86_64")] let subdir = "x64"; @@ -290,8 +281,33 @@ fn try_install_steam_audio(inf_name: &str) -> bool { let n = unsafe { ExpandEnvironmentStringsW(PCWSTR(template.as_ptr()), Some(path.as_mut_slice())) }; if n == 0 || n as usize > path.len() { + return None; + } + path.truncate(n as usize); // keeps the NUL + Some(path) +} + +/// Install one Steam Streaming driver INF by filename via `DiInstallDriverW` (loaded from +/// `newdev.dll`, like Apollo, to avoid an extra windows-crate feature). See +/// [`install_steam_audio_pair`] for the contract; `inf_name` is a bare filename under Steam's +/// per-arch `drivers\Windows10\{arch}\` directory. +/// +/// Safe: `inf_name` is a `&str` and every FFI argument is built locally from it, so there is no +/// precondition a caller could break — the `unsafe` is the `LoadLibraryExW`/`transmute`/call chain +/// inside, which is this function's own business. +fn try_install_steam_audio(inf_name: &str) -> bool { + use windows::core::{s, w, PCWSTR}; + use windows::Win32::Foundation::HWND; + use windows::Win32::System::LibraryLoader::{ + GetProcAddress, LoadLibraryExW, LOAD_LIBRARY_SEARCH_SYSTEM32, + }; + + if std::env::var_os("PUNKTFUNK_NO_MIC_INSTALL").is_some() { return false; } + let Some(path) = steam_driver_inf_path(inf_name) else { + return false; + }; // SAFETY: a static NUL-terminated literal, loaded from System32 only (the flag), so this cannot // pick up a planted `newdev.dll` from the working directory. The handle is checked before use. diff --git a/crates/punktfunk-host/src/audio/wiring_plan.rs b/crates/punktfunk-host/src/audio/wiring_plan.rs index 10314419..1dfe18ea 100644 --- a/crates/punktfunk-host/src/audio/wiring_plan.rs +++ b/crates/punktfunk-host/src/audio/wiring_plan.rs @@ -102,13 +102,28 @@ fn virtualish(lname: &str) -> bool { /// Compute the assignment. `mic_want` is the operator override (`PUNKTFUNK_MIC_DEVICE`, /// lowercased): when set it beats the built-in candidate order for the mic target. `host_audio` /// flips the loopback preference to real hardware (audio audible on the host too); the default -/// (`false`) prefers the silent sink so audio plays on the client only. +/// (`false`) prefers the silent sink so audio plays on the client only. `pad_renders` are the +/// endpoint IDs of the host's own pad-audio ("DualSense speaker") endpoints — platform data +/// collected by `audio_control`, since a pad endpoint is identified by its stamped container / +/// devnode, not by any name rule this module could express. pub(crate) fn plan( renders: &[Endpoint], captures: &[Endpoint], mic_want: Option<&str>, host_audio: bool, + pad_renders: &[String], ) -> Wiring { + // 0. Pad-audio endpoints are invisible to the plan: never the mic target (client voice + // would play out of a pad "speaker"), never a loopback source (a game's controller + // audio cues would stream as desktop audio). Their names carry no virtual marker — + // they are stamped "DualSense Wireless Controller" on purpose — so without this + // exclusion the loopback rules would read one as real hardware. + let renders: Vec = renders + .iter() + .filter(|(_, id)| !pad_renders.iter().any(|p| p == id)) + .cloned() + .collect(); + let renders = renders.as_slice(); let find_render = |needle: &str| { renders .iter() @@ -191,7 +206,7 @@ mod tests { ep("Microphone (Webcam)"), ep("CABLE Output (VB-Audio Virtual Cable)"), ]; - let w = plan(&renders, &captures, None, false); + let w = plan(&renders, &captures, None, false, &[]); assert_eq!( w.mic_render.unwrap().0, "CABLE Input (VB-Audio Virtual Cable)" @@ -220,7 +235,7 @@ mod tests { ep("CABLE Output (VB-Audio Virtual Cable)"), ep("Microphone (Steam Streaming Microphone)"), ]; - let w = plan(&renders, &captures, None, false); + let w = plan(&renders, &captures, None, false, &[]); assert_eq!( w.mic_render.unwrap().0, "CABLE Input (VB-Audio Virtual Cable)" @@ -240,7 +255,7 @@ mod tests { ep("CABLE Input (VB-Audio Virtual Cable)"), ep("Speakers (Steam Streaming Microphone)"), ]; - let w = plan(&renders, &[], None, true); + let w = plan(&renders, &[], None, true, &[]); assert_eq!( w.loopback_render.unwrap().0, "Speakers (Apple Audio Device)" @@ -257,7 +272,7 @@ mod tests { ep("CABLE In 16ch (VB-Audio Virtual Cable)"), ]; for host_audio in [false, true] { - let w = plan(&renders, &[], None, host_audio); + let w = plan(&renders, &[], None, host_audio, &[]); assert!(w.loopback_render.is_none(), "host_audio={host_audio}"); } } @@ -269,7 +284,7 @@ mod tests { fn headless_cable_only_mic_wins() { let renders = [ep("CABLE Input (VB-Audio Virtual Cable)")]; let captures = [ep("CABLE Output (VB-Audio Virtual Cable)")]; - let w = plan(&renders, &captures, None, false); + let w = plan(&renders, &captures, None, false, &[]); assert!(w.mic_render.is_some(), "mic must claim the only cable"); assert!(w.loopback_render.is_none(), "no echo-safe loopback exists"); } @@ -287,7 +302,7 @@ mod tests { ep("CABLE Output (VB-Audio Virtual Cable)"), ep("Microphone (Steam Streaming Microphone)"), ]; - let w = plan(&renders, &captures, None, false); + let w = plan(&renders, &captures, None, false, &[]); assert_eq!( w.mic_render.unwrap().0, "CABLE Input (VB-Audio Virtual Cable)" @@ -311,7 +326,7 @@ mod tests { ep("Speakers (Realtek HD Audio)"), ]; let captures = [ep("Microphone (Steam Streaming Microphone)")]; - let w = plan(&renders, &captures, None, false); + let w = plan(&renders, &captures, None, false, &[]); assert_eq!( w.mic_render.unwrap().0, "Speakers (Steam Streaming Microphone)" @@ -325,7 +340,7 @@ mod tests { fn steam_mic_only_no_echo() { let renders = [ep("Speakers (Steam Streaming Microphone)")]; let captures = [ep("Microphone (Steam Streaming Microphone)")]; - let w = plan(&renders, &captures, None, false); + let w = plan(&renders, &captures, None, false, &[]); assert!(w.mic_render.is_some()); assert!(w.loopback_render.is_none()); } @@ -338,7 +353,7 @@ mod tests { ep("CABLE Input (VB-Audio Virtual Cable)"), ep("Speakers (Steam Streaming Speakers)"), ]; - let w = plan(&renders, &[], None, false); + let w = plan(&renders, &[], None, false, &[]); assert!(w.loopback_render.is_none()); } @@ -350,7 +365,7 @@ mod tests { ep("Voicemeeter Input (VB-Audio Voicemeeter VAIO)"), ]; let captures = [ep("Voicemeeter Out B1 (VB-Audio Voicemeeter VAIO)")]; - let w = plan(&renders, &captures, Some("voicemeeter input"), false); + let w = plan(&renders, &captures, Some("voicemeeter input"), false, &[]); assert_eq!( w.mic_render.unwrap().0, "Voicemeeter Input (VB-Audio Voicemeeter VAIO)" @@ -366,7 +381,7 @@ mod tests { #[test] fn no_virtual_device() { let renders = [ep("Speakers (Realtek HD Audio)")]; - let w = plan(&renders, &[], None, false); + let w = plan(&renders, &[], None, false, &[]); assert!(w.mic_render.is_none()); assert_eq!(w.loopback_render.unwrap().0, "Speakers (Realtek HD Audio)"); } @@ -384,7 +399,7 @@ mod tests { ]; let captures = [ep("Voicemeeter Out B1 (VB-Audio Voicemeeter VAIO)")]; for host_audio in [false, true] { - let w = plan(&renders, &captures, None, host_audio); + let w = plan(&renders, &captures, None, host_audio, &[]); assert_eq!( w.mic_render.as_ref().unwrap().0, "Voicemeeter Input (VB-Audio Voicemeeter VAIO)", @@ -407,7 +422,7 @@ mod tests { ep("Voicemeeter Aux Input (VB-Audio Voicemeeter AUX VAIO)"), ]; for host_audio in [false, true] { - let w = plan(&renders, &[], None, host_audio); + let w = plan(&renders, &[], None, host_audio, &[]); assert!(w.mic_render.is_some(), "host_audio={host_audio}"); assert!(w.loopback_render.is_none(), "host_audio={host_audio}"); } @@ -422,7 +437,34 @@ mod tests { ep("CABLE Input (VB-Audio Virtual Cable)"), ep("Speakers (Some Virtual Audio Device)"), ]; - let w = plan(&renders, &[], None, false); + let w = plan(&renders, &[], None, false, &[]); + assert!(w.loopback_render.is_none()); + } + + /// A provisioned pad-audio endpoint (stamped "DualSense Wireless Controller") is invisible + /// to the plan. Its name carries NO virtual marker — on purpose, games must read it as the + /// pad's speaker — so the name rules alone would classify it as real hardware and hand it + /// the loopback; only the id exclusion prevents that. Measured fact: the wiring plan on the + /// target box already enumerated a stamped endpoint among `renders`. + #[test] + fn pad_endpoints_invisible() { + let renders = [ + ep("DualSense Wireless Controller"), + ep("Speakers (Realtek HD Audio)"), + ]; + let pads = [renders[0].1.clone()]; + let w = plan(&renders, &[], None, false, &pads); + assert_eq!(w.loopback_render.unwrap().0, "Speakers (Realtek HD Audio)"); + // Even an operator mic override matching the pad's name must not claim it; with the + // pad as the only render endpoint there is honestly no mic target and no loopback. + let w = plan( + &renders[..1], + &[], + Some("wireless controller"), + false, + &pads, + ); + assert!(w.mic_render.is_none()); assert!(w.loopback_render.is_none()); } } diff --git a/crates/punktfunk-host/src/devtest.rs b/crates/punktfunk-host/src/devtest.rs index d6ee6df8..7c649c5b 100644 --- a/crates/punktfunk-host/src/devtest.rs +++ b/crates/punktfunk-host/src/devtest.rs @@ -384,6 +384,7 @@ pub fn dualsense_windows_test(args: &[String]) -> Result<()> { index: idx, kind: 2, capabilities: 0, + audio_caps: 0, }); println!( "virtual {} up — cycling Cross + sweeping the left stick for {secs}s. Watch \ @@ -430,6 +431,7 @@ pub fn dualsense_windows_test(args: &[String]) -> Result<()> { index: idx, kind: 1, capabilities: 0, + audio_caps: 0, }); println!( "virtual Xbox 360 (XUSB) up — sweeping LS + toggling A for {secs}s. Check with \ @@ -486,6 +488,50 @@ pub fn dualsense_windows_test(args: &[String]) -> Result<()> { Ok(()) } +/// Windows: pad-audio endpoint provisioning — `pad-endpoint ensure|remove|status [--index N]`. +/// `ensure` runs the idempotent startup path (reuse-or-create the devnode, bind the Steam +/// Streaming Speakers driver, stamp the DualSense identity + 4ch/48k formats, report whether +/// the stamps are SERVED); `status` prints the devnode/endpoint and per-stamp stored vs served +/// state without changing anything; `remove` deletes the devnode via pnputil — the escape +/// hatch only, endpoints are persistent by design. Stamping needs SYSTEM (the MMDevices ACL); +/// run `ensure` under the service account or PsExec when the property-store route is denied. +#[cfg(target_os = "windows")] +pub fn pad_endpoint(args: &[String]) -> Result<()> { + use crate::audio::pad_endpoint as pe; + let idx: u8 = args + .iter() + .skip_while(|a| *a != "--index") + .nth(1) + .and_then(|s| s.parse().ok()) + .unwrap_or(0); + match args.get(1).map(String::as_str) { + Some("ensure") => { + let p = pe::ensure(idx)?; + println!( + "pad-endpoint ensure: pad {} devnode {} endpoint {} needs_aeb_kick={}", + p.pad_index, p.device_instance, p.endpoint_id, p.needs_aeb_kick + ); + Ok(()) + } + Some("remove") => match pe::find(idx)? { + Some(p) => { + pe::remove(&p); + println!( + "pad-endpoint remove: requested removal of {}", + p.device_instance + ); + Ok(()) + } + None => { + println!("pad-endpoint remove: no pad-audio devnode for index {idx}"); + Ok(()) + } + }, + Some("status") => pe::print_status(idx), + _ => anyhow::bail!("usage: punktfunk-host pad-endpoint [--index N]"), + } +} + /// Mirror a physical monitor and pull frames from it — the on-glass gate for per-monitor capture /// (`design/per-monitor-portal-capture.md` P2/P3), without needing a client to connect. /// diff --git a/crates/punktfunk-host/src/gamestream/gamepad.rs b/crates/punktfunk-host/src/gamestream/gamepad.rs index fdd7a990..4a0d68a7 100644 --- a/crates/punktfunk-host/src/gamestream/gamepad.rs +++ b/crates/punktfunk-host/src/gamestream/gamepad.rs @@ -65,6 +65,8 @@ pub fn decode(plaintext: &[u8]) -> Option { index: *b.first()?, kind: *b.get(1)?, capabilities: le16(2)? as u16, + // GameStream's LI_CCAP vocabulary can't express pad audio — native-plane only. + audio_caps: 0, }), _ => None, } @@ -138,6 +140,7 @@ mod tests { index, kind, capabilities, + .. }) = decode(&wrap(MAGIC_CONTROLLER_ARRIVAL, &body)) else { panic!("expected Arrival"); diff --git a/crates/punktfunk-host/src/main.rs b/crates/punktfunk-host/src/main.rs index 07e6fdc3..032b2db5 100644 --- a/crates/punktfunk-host/src/main.rs +++ b/crates/punktfunk-host/src/main.rs @@ -602,6 +602,10 @@ fn real_main() -> Result<()> { // hold it, driving the real *WindowsManager end to end. `--index N`, `--seconds N`. #[cfg(target_os = "windows")] Some("dualsense-windows-test") => devtest::dualsense_windows_test(&args), + // Windows: pad-audio endpoint provisioning (`ensure`/`status`) + the pnputil removal + // escape hatch (`remove`). `--index N` selects the pad slot (default 0). + #[cfg(target_os = "windows")] + Some("pad-endpoint") => devtest::pad_endpoint(&args), // Capture→encode→file pipeline spike (dev tool). Some("spike") => spike::run(parse_spike(&args[1..])?), // Native punktfunk/1 host (QUIC control plane + UDP data plane). diff --git a/crates/punktfunk-host/src/native.rs b/crates/punktfunk-host/src/native.rs index 7dad3025..f1968469 100644 --- a/crates/punktfunk-host/src/native.rs +++ b/crates/punktfunk-host/src/native.rs @@ -64,6 +64,12 @@ use pairing::pair_ceremony; mod audio; use audio::audio_thread; +/// Per-pad DualSense audio (the 0xD1 plane): loopback capture of the pre-provisioned pad +/// endpoints → per-kind silence gate → stereo Opus → `PAD_AUDIO_MAGIC` datagrams. The input +/// thread spawns/reaps one streamer per arriving pad (`input`); the Welcome advertises the cap +/// via `pad_audio::host_cap` (`handshake`). +mod pad_audio; + /// The native input plane (plan §W1); the session setup spawns `input_thread` and feeds it a /// channel of `ClientInput`. The `Pads` router + rumble live there too. mod input; @@ -344,6 +350,14 @@ pub(crate) async fn serve( // binds its capture device) and self-heals when the backend dies (PipeWire restart, Windows // endpoint churn). let mic_service = crate::audio::MicPump::start(); + // Windows, env-gated (PUNKTFUNK_PAD_AUDIO / _SLOTS): pre-provision the per-pad "DualSense + // speaker" render endpoints once per host lifetime — idempotent devnode + stamp work on a + // dedicated COM thread, results published for sessions to query by pad index + // (crate::audio::pad_endpoint::endpoint_for). If any stamp is stored-but-not-served, the + // worker performs ONE AudioEndpointBuilder+Audiosrv restart now, before any session exists. + // Failures log once and leave the feature off: pads still work, just without pad audio. + #[cfg(target_os = "windows")] + crate::audio::pad_endpoint::provision_at_startup(); // Host-lifetime worker that fires debounced TV-session restores (the managed gamescope path // restores the box's autologin gaming session on idle, not per-disconnect — see // `vdisplay::restore_managed_session`). Held for serve()'s lifetime; dropping it stops it. @@ -1164,9 +1178,14 @@ async fn serve_session( let input_handle = { let conn = conn.clone(); let gamepad = welcome.gamepad; + // Pad audio (0xD1) negotiated: the Welcome advertised the cap (Windows + provisioned + // endpoints + the client asked — handshake reads `pad_audio::host_cap`). Read back off + // the Welcome rather than recomputed, so the input thread's spawns cannot disagree + // with what the client was told. + let pad_audio_on = welcome.host_caps & punktfunk_core::quic::HOST_CAP_PAD_AUDIO != 0; std::thread::Builder::new() .name("punktfunk1-input".into()) - .spawn(move || input_thread(input_rx, conn, inj_tx, gamepad)) + .spawn(move || input_thread(input_rx, conn, inj_tx, gamepad, pad_audio_on)) .context("spawn input thread")? }; // One reader for ALL client→host datagrams, demuxed by magic byte (two read_datagram loops diff --git a/crates/punktfunk-host/src/native/handshake.rs b/crates/punktfunk-host/src/native/handshake.rs index acf810bb..7760f6bf 100644 --- a/crates/punktfunk-host/src/native/handshake.rs +++ b/crates/punktfunk-host/src/native/handshake.rs @@ -544,6 +544,16 @@ pub(super) async fn negotiate( punktfunk_core::quic::HOST_CAP_PEN } else { 0 + } + // Per-pad DualSense audio (0xD1 + HidOutput::AudioCtl): granted only when the + // client asked AND this host can capture it — Windows with the feature enabled + // and at least one pad endpoint provisioned at startup. A capable client then + // marks its pads' renderers on their arrivals; the input thread streams toward + // exactly those pads (`super::pad_audio`). + | if super::pad_audio::host_cap(hello.client_caps) { + punktfunk_core::quic::HOST_CAP_PAD_AUDIO + } else { + 0 }, // The negotiated session AEAD (resolved above) + its 32-byte key toward a ChaCha // client; toward everyone else cipher 0 keeps the Welcome byte-identical to the diff --git a/crates/punktfunk-host/src/native/input.rs b/crates/punktfunk-host/src/native/input.rs index a71cde1c..e85942ac 100644 --- a/crates/punktfunk-host/src/native/input.rs +++ b/crates/punktfunk-host/src/native/input.rs @@ -515,6 +515,75 @@ impl Pads { } } +/// Per-pad 0xD1 streamers (`super::pad_audio`), keyed by pad index like every per-pad table +/// here (bounded by [`MAX_WIRE_PADS`]; only slots 0..4 can ever have a provisioned endpoint — +/// `spawn` refuses the rest). Spawned when a negotiated session's DualSense-family arrival +/// declares renderer bits, reaped on remove / re-declare / session teardown. +struct PadAudioSlots { + /// `(kinds, handle)` per running pad — `kinds` is the arrival's audio-caps mask, kept so + /// an identical re-arrival (they are re-sent against datagram loss) is a no-op. + slots: [Option<(u8, pad_audio::PadAudioHandle)>; MAX_WIRE_PADS], +} + +impl PadAudioSlots { + fn new() -> PadAudioSlots { + PadAudioSlots { + slots: std::array::from_fn(|_| None), + } + } + + /// Idempotent spawn: same kinds → keep the running streamer; changed kinds → restart with + /// the new mask; not running → spawn (a slot without an endpoint stays empty — bounded + /// retries, since arrivals are only re-sent a few times per slot open). + fn ensure(&mut self, conn: &quinn::Connection, pad: u8, kinds: u8) { + let idx = pad as usize; + if idx >= MAX_WIRE_PADS { + return; + } + if let Some((have, _)) = &self.slots[idx] { + if *have == kinds { + return; // identical re-arrival — keep the running streamer + } + tracing::info!( + pad = idx, + "pad-audio kinds changed — restarting the streamer" + ); + self.stop(idx); + } + let stop = Arc::new(AtomicBool::new(false)); + if let Some(h) = pad_audio::spawn(conn.clone(), pad, kinds, stop) { + self.slots[idx] = Some((kinds, h)); + } + } + + /// Stop + reap one pad's streamer. The join rides a detached reaper thread: a quiet pad's + /// capturer can sit out its ~5 s recv timeout, and this thread must keep its ≤4 ms + /// feedback cadence (games block on GET_REPORT handshakes) — the reaper still joins, just + /// not here. A failed reaper spawn falls back to the handle's own drop (signal + join). + fn stop(&mut self, idx: usize) { + if let Some((_, h)) = self.slots.get_mut(idx).and_then(|s| s.take()) { + h.signal(); + let _ = std::thread::Builder::new() + .name("punktfunk1-padreap".into()) + .spawn(move || h.stop()); + } + } + + /// Session teardown: flag every streamer FIRST so they wind down concurrently, then join — + /// the worst case is ONE quiet-endpoint recv timeout (~5 s), well inside the session's + /// 10 s side-thread join grace, not one per pad. + fn stop_all(&mut self) { + for s in self.slots.iter().flatten() { + s.1.signal(); + } + for s in &mut self.slots { + if let Some((_, h)) = s.take() { + h.stop(); + } + } + } +} + /// One client→host input item, both planes on ONE channel so the input thread wakes the /// moment either arrives (a second rich channel drained after the 4 ms recv timeout cost /// every pure-gyro motion sample up to 4 ms of quantization). @@ -669,8 +738,13 @@ pub(super) fn input_thread( conn: quinn::Connection, inj_tx: std::sync::mpsc::Sender, gamepad: GamepadPref, + pad_audio_on: bool, ) { let mut pads = Pads::new(gamepad); + // Per-pad 0xD1 audio streamers, live only when the Welcome granted the cap (`pad_audio_on` + // — read back off the negotiated host_caps). Spawned on DualSense-family arrivals that + // declare renderer bits, reaped on remove/teardown below. + let mut pad_streams = PadAudioSlots::new(); // Motion-cadence observability (debug level): inter-arrival percentiles per 5 s window, // the measurement a "gyro feels floaty" report needs. Bounded: 5 s at even a 1 kHz pad // is 5000 u32s. @@ -829,16 +903,53 @@ pub(super) fn input_thread( rumble_seen[idx] = false; rumble_seq[idx] = 0; rumble_stop_burst[idx] = 0; + // The unplugged pad's 0xD1 streamer goes with it (seq-gated like the + // rest of this arm, so a reordered stale removal can't kill the + // stream of a re-plugged pad). A re-plug re-arrives and re-spawns. + pad_streams.stop(idx); } } InputKind::GamepadArrival => { // Per-pad controller kind declaration (mixed types): route this pad's future - // frames to a backend of the declared kind. `code` = the GamepadPref wire byte, - // `flags` = pad index. Applied before the pad's first frame (the client sends it - // on slot open), so the device is built as the right type from the start. - let idx = ev.flags as usize; + // frames to a backend of the declared kind. `code` = the GamepadPref wire + // byte, `flags` = pad index in the LOW BYTE — bits 8/9 carry the pad's + // audio-render caps (haptics/speaker) from a pad-audio-capable client, so + // the index MUST come from `decode_gamepad_arrival`, never the whole word. + // Applied before the pad's first frame (the client sends it on slot open), + // so the device is built as the right type from the start. The audio caps + // are surfaced here for the 0xD1 capture path (which emits pad audio only + // toward pads that declared a renderer). + let (pad, audio_caps) = punktfunk_core::input::decode_gamepad_arrival(ev.flags); + let idx = pad as usize; let kind = GamepadPref::from_u8(ev.code as u8); + if audio_caps != 0 { + tracing::debug!( + pad = idx, + haptics = audio_caps & 0x01 != 0, + speaker = audio_caps & 0x02 != 0, + "pad-audio render caps declared (arrival flags bits 8/9)" + ); + } pads.set_kind(idx, kind); + // Pad audio (0xD1): stream toward DualSense-family pads that declared a + // renderer, only on a session that negotiated the cap. Idempotent across + // the arrival re-sends (same kinds keeps the running streamer); a + // re-declare without bits — or as a kind with no pad audio — stops it. + if pad_audio_on { + let want = if matches!( + kind, + GamepadPref::DualSense | GamepadPref::DualSenseEdge + ) { + audio_caps + } else { + 0 + }; + if want != 0 { + pad_streams.ensure(&conn, pad, want); + } else { + pad_streams.stop(idx); + } + } } _ => { // Track press/release so a mid-press disconnect can be undone below. @@ -994,6 +1105,9 @@ pub(super) fn input_thread( flags: 0, }); } + // Reap the per-pad 0xD1 streamers with the session (after the instant release sends above + // — this can block on a quiet pad's capturer timeout, see PadAudioSlots::stop_all). + pad_streams.stop_all(); } #[cfg(test)] diff --git a/crates/punktfunk-host/src/native/pad_audio.rs b/crates/punktfunk-host/src/native/pad_audio.rs new file mode 100644 index 00000000..17487ac4 --- /dev/null +++ b/crates/punktfunk-host/src/native/pad_audio.rs @@ -0,0 +1,641 @@ +//! Per-pad DualSense audio (the 0xD1 pad-audio plane): WASAPI loopback of a pre-provisioned pad +//! endpoint ([`crate::audio::pad_endpoint`]) → 4-ch de-interleave into the speaker (front) and +//! voice-coil haptics (back) pairs → per-kind silence gate → stereo Opus (48 kHz, CBR, LowDelay) +//! → [`PAD_AUDIO_MAGIC`](punktfunk_core::quic::PAD_AUDIO_MAGIC) datagrams. One thread per +//! arriving pad, spawned/reaped by the input thread ([`super::input`]) as arrivals declare +//! renderers and pads leave. Modeled on the session audio thread ([`super::audio`]): the same +//! reopen-with-backoff on capture death, the same monotonic-seq-kept-across-reopens discipline, +//! the same power-of-two encode-warn throttle. + +use super::*; + +/// `kinds` bit for the haptics stream (bit N = wire kind N — the same packing the arrival's +/// audio-caps bits use, see [`punktfunk_core::input::decode_gamepad_arrival`]). +#[cfg(any(target_os = "windows", test))] +pub(super) const KIND_BIT_HAPTICS: u8 = 1 << punktfunk_core::quic::PAD_AUDIO_KIND_HAPTICS; +/// `kinds` bit for the speaker stream. +#[cfg(any(target_os = "windows", test))] +pub(super) const KIND_BIT_SPEAKER: u8 = 1 << punktfunk_core::quic::PAD_AUDIO_KIND_SPEAKER; + +/// Haptics frames are 5 ms (the session-audio cadence — haptics are felt latency); speaker +/// frames are 10 ms (speaker content tolerates the buffering for the coding efficiency). Both +/// are the wire contract's cadences (`punktfunk_core::quic::PAD_AUDIO_KIND_*`). +#[cfg(any(target_os = "windows", test))] +const HAPTICS_FRAME_MS: u32 = 5; +#[cfg(any(target_os = "windows", test))] +const SPEAKER_FRAME_MS: u32 = 10; +/// Samples per frame (per channel) at 48 kHz: 240 / 480. +#[cfg(any(target_os = "windows", test))] +const HAPTICS_FRAME_SAMPLES: usize = + crate::audio::SAMPLE_RATE as usize * HAPTICS_FRAME_MS as usize / 1000; +#[cfg(any(target_os = "windows", test))] +const SPEAKER_FRAME_SAMPLES: usize = + crate::audio::SAMPLE_RATE as usize * SPEAKER_FRAME_MS as usize / 1000; +/// The capture's channel count — the pad endpoint is stamped quad (FL FR BL BR: front pair = +/// speaker, back pair = voice coils). Mirrors `pad_endpoint::PAD_CHANNELS` (Windows-gated, so +/// the pure splitter logic keeps its own copy). +#[cfg(any(target_os = "windows", test))] +const CAP_CHANNELS: usize = 4; + +/// Peak (absolute sample) at or above which a frame counts as signal — the gate OPENS on that +/// very frame (haptics are felt latency; the first active frame must ship). ≈ −60 dBFS. +#[cfg(any(target_os = "windows", test))] +const GATE_OPEN_PEAK: f32 = 1e-3; +/// How long the gate keeps sending after the last signal frame before it CLOSES (hangover): +/// long enough that a decaying haptic tail (and the client decoder's own tail) is never +/// clipped, short enough that an idle pad costs nothing in steady state. +#[cfg(any(target_os = "windows", test))] +const GATE_HANGOVER_MS: u32 = 250; + +/// Per-kind Opus bitrate — a stereo voice-coil / pad-speaker pair needs far less than the +/// session plane's 128 kbps; 64 kbps CBR keeps every frame comfortably under one MTU. +#[cfg(target_os = "windows")] +const PAD_AUDIO_BITRATE: i32 = 64_000; + +/// The per-kind silence gate — the steady-state-cost feature: an idle pad endpoint (games +/// rarely render pad audio) must cost ZERO encodes and ZERO datagrams, not a permanent 200 Hz +/// stream of coded silence. Opens the instant a frame carries signal ([`GATE_OPEN_PEAK`]); +/// closes only after [`GATE_HANGOVER_MS`] of continuous sub-threshold frames. Pure logic, +/// unit-tested below. +#[cfg(any(target_os = "windows", test))] +struct SilenceGate { + /// Consecutive sub-threshold frames that close the gate ([`GATE_HANGOVER_MS`] ÷ frame ms). + hangover_frames: u32, + /// Consecutive sub-threshold frames seen so far while open. + quiet: u32, + /// Starts closed: a pad no game ever renders into never opens (and never sends). + open: bool, +} + +#[cfg(any(target_os = "windows", test))] +impl SilenceGate { + fn new(frame_ms: u32) -> SilenceGate { + SilenceGate { + hangover_frames: (GATE_HANGOVER_MS / frame_ms).max(1), + quiet: 0, + open: false, + } + } + + /// Feed one frame; `true` = encode + send it. Signal opens the gate on THIS frame; the + /// frame that completes the hangover closes it and is itself suppressed (the client + /// already has ~250 ms of ramped-out silence by then). + fn feed(&mut self, frame: &[f32]) -> bool { + if frame.iter().any(|s| s.abs() >= GATE_OPEN_PEAK) { + self.open = true; + self.quiet = 0; + } else if self.open { + self.quiet += 1; + if self.quiet >= self.hangover_frames { + self.open = false; + self.quiet = 0; + } + } + self.open + } +} + +/// One kind's send-admission + seq bookkeeping (pure logic — the capture thread wraps it with +/// the encoder and the datagram send). `seq` is monotonic per (pad, kind) and NEVER advances +/// while the gate is closed: frozen-seq = deliberate silence — the client tells silence from +/// loss by seq continuity (the mic-mute discipline, pf-client-core/src/audio.rs). It is also +/// kept across capture reopens (the session audio thread's discipline, audio.rs): the client +/// sees a gap, not a restart. +#[cfg(any(target_os = "windows", test))] +struct LaneCtl { + gate: SilenceGate, + seq: u32, +} + +#[cfg(any(target_os = "windows", test))] +impl LaneCtl { + fn new(frame_ms: u32) -> LaneCtl { + LaneCtl { + gate: SilenceGate::new(frame_ms), + seq: 0, + } + } + + /// Admit one frame: `Some(seq)` = encode + send it with this seq (advanced for the next); + /// `None` = gated — do not send, do not advance. An encode failure AFTER admission leaves a + /// one-frame seq gap, which the client conceals exactly like datagram loss. + fn admit(&mut self, frame: &[f32]) -> Option { + if !self.gate.feed(frame) { + return None; + } + let seq = self.seq; + self.seq = self.seq.wrapping_add(1); + Some(seq) + } +} + +/// De-interleave one 4-ch block (FL FR BL BR) into its stereo pairs: `(front, back)` — front = +/// speaker (channels 0/1), back = voice-coil haptics (channels 2/3). A ragged tail (not a +/// multiple of 4 — the capturer only ever delivers whole frames) is dropped, never smeared +/// across channels. +#[cfg(any(target_os = "windows", test))] +fn split_quad(block: &[f32]) -> (Vec, Vec) { + let mut front = Vec::with_capacity(block.len() / 2); + let mut back = Vec::with_capacity(block.len() / 2); + for s in block.chunks_exact(CAP_CHANNELS) { + front.extend_from_slice(&s[..2]); + back.extend_from_slice(&s[2..4]); + } + (front, back) +} + +/// Accumulates interleaved 4-ch capture and cuts it into the wire contract's per-kind stereo +/// frames — haptics every 5 ms from the back pair, speaker every 10 ms from the front pair — +/// emitting ONLY the kinds enabled in `kinds` (a disabled kind is never even split out, so it +/// can never reach an encoder). Pure logic, unit-tested; the capture thread wraps it. +#[cfg(any(target_os = "windows", test))] +struct PadFramer { + kinds: u8, + /// Raw interleaved 4-ch accumulation, drained in 5 ms blocks. + acc: Vec, + /// Front-pair stereo accumulation toward the next 10 ms speaker frame. + front: Vec, +} + +#[cfg(any(target_os = "windows", test))] +impl PadFramer { + fn new(kinds: u8) -> PadFramer { + PadFramer { + kinds, + acc: Vec::with_capacity(HAPTICS_FRAME_SAMPLES * CAP_CHANNELS * 4), + front: Vec::new(), + } + } + + /// Feed one capture chunk; `emit(kind, stereo_frame)` fires for each completed frame + /// (haptics first — it is the latency-critical pair). + fn feed(&mut self, chunk: &[f32], mut emit: impl FnMut(u8, &[f32])) { + self.acc.extend_from_slice(chunk); + let block_len = HAPTICS_FRAME_SAMPLES * CAP_CHANNELS; + while self.acc.len() >= block_len { + let block: Vec = self.acc.drain(..block_len).collect(); + let (front, back) = split_quad(&block); + if self.kinds & KIND_BIT_HAPTICS != 0 { + emit(punktfunk_core::quic::PAD_AUDIO_KIND_HAPTICS, &back); + } + if self.kinds & KIND_BIT_SPEAKER != 0 { + self.front.extend_from_slice(&front); + let frame_len = SPEAKER_FRAME_SAMPLES * 2; + while self.front.len() >= frame_len { + let frame: Vec = self.front.drain(..frame_len).collect(); + emit(punktfunk_core::quic::PAD_AUDIO_KIND_SPEAKER, &frame); + } + } + } + } + + /// Drop the partial frames straddling a capture gap (reopen). The seq/gate state is NOT + /// here — [`LaneCtl`] deliberately survives reopens, so the client sees a gap, not a + /// restart. + fn clear(&mut self) { + self.acc.clear(); + self.front.clear(); + } +} + +/// A running per-pad streamer. [`stop`](PadAudioHandle::stop) (or drop) flags the thread and +/// joins it; [`signal`](PadAudioHandle::signal) only flags — the input thread's teardown flags +/// every pad first so the joins overlap instead of serializing the capturer's worst-case ~5 s +/// quiet-endpoint recv timeout. +pub(super) struct PadAudioHandle { + stop: Arc, + join: Option>, +} + +impl PadAudioHandle { + /// Flag the streamer to wind down without waiting for it. + pub(super) fn signal(&self) { + self.stop.store(true, Ordering::SeqCst); + } + + /// Stop + reap. Bounded by the capturer's ~5 s quiet-endpoint recv timeout in the worst + /// case — the mid-session reap paths run this on a detached reaper thread for that reason + /// (`input.rs::PadAudioSlots::stop`); session teardown affords it inline (the 10 s + /// side-thread join grace covers it). + pub(super) fn stop(mut self) { + self.reap(); + } + + fn reap(&mut self) { + self.signal(); + if let Some(join) = self.join.take() { + let _ = join.join(); + } + } +} + +/// A handle dropped without `stop()` (reaper-spawn failure) still winds its thread down. +impl Drop for PadAudioHandle { + fn drop(&mut self) { + self.reap(); + } +} + +/// Whether this session's Welcome should advertise +/// [`HOST_CAP_PAD_AUDIO`](punktfunk_core::quic::HOST_CAP_PAD_AUDIO): the client asked +/// ([`CLIENT_CAP_PAD_AUDIO`](punktfunk_core::quic::CLIENT_CAP_PAD_AUDIO)), this is a Windows +/// host with the feature on (`PUNKTFUNK_PAD_AUDIO` != "0"), and startup provisioning published +/// at least one endpoint (`pad_endpoint::provision_at_startup`). Still-running provisioning +/// reads as "none yet": a session racing host startup simply negotiates without pad audio and +/// picks it up on its next connect. +pub(super) fn host_cap(client_caps: u8) -> bool { + let asked = client_caps & punktfunk_core::quic::CLIENT_CAP_PAD_AUDIO != 0; + #[cfg(target_os = "windows")] + { + asked + && std::env::var_os("PUNKTFUNK_PAD_AUDIO").is_none_or(|v| v != "0") + && crate::audio::pad_endpoint::provisioned_endpoints() + .is_some_and(|eps| !eps.is_empty()) + } + #[cfg(not(target_os = "windows"))] + { + // Only the Windows virtual DualSense exposes pad audio endpoints today. + let _ = asked; + false + } +} + +/// Start the per-pad streamer toward `conn` for `pad`, streaming the kinds in `kinds` (bit 0 = +/// haptics, bit 1 = speaker — the arrival's audio-caps packing). `stop` is this handle's own +/// flag (fresh per spawn — pad streamers stop individually, not with the session). `None` when +/// the slot has no provisioned endpoint (provisioning failed or still running, or the slot is +/// past `PUNKTFUNK_PAD_AUDIO_SLOTS` — only 0..4 can ever have one) or the thread cannot spawn; +/// the pad itself keeps working either way, just without audio. +#[cfg(target_os = "windows")] +pub(super) fn spawn( + conn: quinn::Connection, + pad: u8, + kinds: u8, + stop: Arc, +) -> Option { + if kinds & (KIND_BIT_HAPTICS | KIND_BIT_SPEAKER) == 0 { + return None; + } + let Some(ep) = crate::audio::pad_endpoint::endpoint_for(pad) else { + tracing::debug!( + pad, + "pad-audio arrival for a slot without a provisioned endpoint — not streaming" + ); + return None; + }; + if ep.endpoint_id.is_empty() { + // The devnode-without-endpoint shape (`find`) — never in the provisioned set, but + // cheap to refuse rather than spin the open/backoff loop on an empty id. + return None; + } + let stop_t = stop.clone(); + match std::thread::Builder::new() + .name(format!("punktfunk1-pad{pad}")) + .spawn(move || pad_audio_thread(conn, pad, kinds, ep.endpoint_id, stop_t)) + { + Ok(join) => Some(PadAudioHandle { + stop, + join: Some(join), + }), + Err(e) => { + tracing::warn!(pad, error = %e, "pad-audio thread spawn failed — pad streams without audio"); + None + } + } +} + +/// Stub — pad endpoints exist only behind the Windows virtual DualSense; other hosts run pads +/// without the audio side (and never advertise the cap, see [`host_cap`]). +#[cfg(not(target_os = "windows"))] +pub(super) fn spawn( + _conn: quinn::Connection, + _pad: u8, + _kinds: u8, + _stop: Arc, +) -> Option { + None +} + +/// One enabled kind's encoder lane: admission/seq control + its stereo Opus encoder + the +/// power-of-two warn throttle (a stuck encoder would otherwise fail ~200 times a second). +#[cfg(target_os = "windows")] +struct Lane { + kind: u8, + ctl: LaneCtl, + enc: opus::Encoder, + encode_errs: u64, +} + +/// Build one stereo encoder per enabled kind: 48 kHz LowDelay hard-CBR like the session audio +/// plane ([`super::audio`]), at the pad plane's 64 kbps. +#[cfg(target_os = "windows")] +fn build_lanes(kinds: u8) -> Result, opus::Error> { + let mut lanes = Vec::new(); + for (bit, kind, frame_ms) in [ + ( + KIND_BIT_HAPTICS, + punktfunk_core::quic::PAD_AUDIO_KIND_HAPTICS, + HAPTICS_FRAME_MS, + ), + ( + KIND_BIT_SPEAKER, + punktfunk_core::quic::PAD_AUDIO_KIND_SPEAKER, + SPEAKER_FRAME_MS, + ), + ] { + if kinds & bit == 0 { + continue; + } + let mut enc = opus::Encoder::new( + crate::audio::SAMPLE_RATE, + opus::Channels::Stereo, + opus::Application::LowDelay, + )?; + enc.set_bitrate(opus::Bitrate::Bits(PAD_AUDIO_BITRATE)).ok(); + enc.set_vbr(false).ok(); + lanes.push(Lane { + kind, + ctl: LaneCtl::new(frame_ms), + enc, + encode_errs: 0, + }); + } + Ok(lanes) +} + +/// The per-pad streaming thread: loopback capture → framer → per-kind gate/encode → 0xD1 +/// datagrams. Capture death reopens with the session-audio backoff ([`INJECTOR_REOPEN_BACKOFF`], +/// encoders + seq kept); a send error ends the thread (the connection — the session — is gone). +#[cfg(target_os = "windows")] +fn pad_audio_thread( + conn: quinn::Connection, + pad: u8, + kinds: u8, + endpoint_id: String, + stop: Arc, +) { + use crate::audio::AudioCapturer as _; + let mut lanes = match build_lanes(kinds) { + Ok(l) => l, + Err(e) => { + tracing::warn!(pad, error = %e, "pad-audio opus encoder init failed — pad continues without audio"); + return; + } + }; + if lanes.is_empty() { + return; // spawn() refuses kinds == 0 — belt and braces + } + let mut framer = PadFramer::new(kinds); + // One Opus frame per datagram; 64 kbps CBR at ≤10 ms is ~80 bytes — sized with the session + // plane's slack. + let mut opus_buf = vec![0u8; 1500]; + // Reopen-with-backoff (the audio.rs discipline): a capture death (endpoint invalidated, + // audio-engine restart) reopens instead of muting the pad for the rest of the session. The + // first open ALSO rides this loop, so an open lost to endpoint churn starts late, not never. + let mut capturer: Option = None; + let mut last_failed: Option = None; + tracing::info!( + pad, + haptics = kinds & KIND_BIT_HAPTICS != 0, + speaker = kinds & KIND_BIT_SPEAKER != 0, + "pad audio streaming (0xD1, Opus 48 kHz, silence-gated)" + ); + 'session: while !stop.load(Ordering::SeqCst) { + if capturer.is_none() { + if last_failed.is_some_and(|t| t.elapsed() < INJECTOR_REOPEN_BACKOFF) { + std::thread::sleep(std::time::Duration::from_millis(200)); + continue; + } + match crate::audio::pad_endpoint::PadLoopbackCapturer::open(&endpoint_id) { + Ok(c) => { + if last_failed.take().is_some() { + tracing::info!(pad, "pad-audio capture reopened"); + } + capturer = Some(c); + framer.clear(); // drop the partial frames straddling the gap + } + Err(e) => { + tracing::debug!(pad, error = %format!("{e:#}"), "pad-audio open failed — will retry"); + last_failed = Some(std::time::Instant::now()); + std::thread::sleep(std::time::Duration::from_millis(200)); + continue; + } + } + } + // An empty chunk is a QUIET endpoint (the capturer's idle timeout), not a death — keep + // it; only a genuine Err (capture thread ended) drops the capturer for reopen. + let chunk = match capturer.as_mut().unwrap().next_chunk() { + Ok(c) => c, + Err(e) => { + tracing::warn!(pad, error = %format!("{e:#}"), "pad-audio capture lost — reopening"); + capturer = None; + last_failed = Some(std::time::Instant::now()); + continue; + } + }; + let mut session_gone = false; + framer.feed(&chunk, |kind, frame| { + if session_gone { + return; + } + let Some(lane) = lanes.iter_mut().find(|l| l.kind == kind) else { + return; // framer emits only enabled kinds — unreachable, but never panic here + }; + // Gated = deliberate silence: no datagram AND a frozen seq (the client tells + // silence from loss by seq continuity). + let Some(seq) = lane.ctl.admit(frame) else { + return; + }; + let pts_ns = now_ns(); + match lane.enc.encode_float(frame, &mut opus_buf) { + Ok(n) => { + let d = punktfunk_core::quic::encode_pad_audio_datagram( + pad, + kind, + seq, + pts_ns, + &opus_buf[..n], + ); + if conn.send_datagram(d.into()).is_err() { + session_gone = true; // connection gone — the session is over + } + } + Err(e) => { + lane.encode_errs += 1; + if lane.encode_errs.is_power_of_two() { + tracing::warn!( + pad, + kind, + error = %e, + count = lane.encode_errs, + "pad-audio opus encode failed — dropping frame" + ); + } + } + } + }); + if session_gone { + break 'session; + } + } + // Dropping the capturer stops its WASAPI thread. Nothing to park: pad capture is per-pad, + // per-session by design (unlike the session audio slot there is no cross-session reuse). +} + +#[cfg(test)] +mod tests { + use super::*; + use punktfunk_core::quic::{PAD_AUDIO_KIND_HAPTICS, PAD_AUDIO_KIND_SPEAKER}; + + /// A stereo frame of `n` samples at a constant level. + fn frame(level: f32, n: usize) -> Vec { + vec![level; n * 2] + } + + #[test] + fn gate_opens_immediately_and_closes_after_hangover() { + let mut g = SilenceGate::new(HAPTICS_FRAME_MS); + // 250 ms of 5 ms frames. + assert_eq!(g.hangover_frames, 50); + // Closed from birth: an idle pad never sends. + assert!(!g.feed(&frame(0.0, HAPTICS_FRAME_SAMPLES))); + // A peak at exactly the threshold opens on THIS frame (haptics are felt latency). + assert!(g.feed(&frame(GATE_OPEN_PEAK, HAPTICS_FRAME_SAMPLES))); + // 49 quiet frames ride the hangover; the 50th completes 250 ms and is suppressed. + for _ in 0..49 { + assert!(g.feed(&frame(0.0, HAPTICS_FRAME_SAMPLES))); + } + assert!(!g.feed(&frame(0.0, HAPTICS_FRAME_SAMPLES))); + // ... and stays closed. + assert!(!g.feed(&frame(0.0, HAPTICS_FRAME_SAMPLES))); + // Sub-threshold wiggle does not reopen; real signal does (negative peaks count). + assert!(!g.feed(&frame(9e-4, HAPTICS_FRAME_SAMPLES))); + assert!(g.feed(&frame(-0.5, HAPTICS_FRAME_SAMPLES))); + // A loud frame mid-hangover rearms the full 250 ms. + for _ in 0..49 { + assert!(g.feed(&frame(0.0, HAPTICS_FRAME_SAMPLES))); + } + assert!(g.feed(&frame(0.02, HAPTICS_FRAME_SAMPLES))); + for _ in 0..49 { + assert!(g.feed(&frame(0.0, HAPTICS_FRAME_SAMPLES))); + } + assert!(!g.feed(&frame(0.0, HAPTICS_FRAME_SAMPLES))); + } + + #[test] + fn gate_hangover_scales_with_frame_ms() { + let mut g = SilenceGate::new(SPEAKER_FRAME_MS); + assert_eq!(g.hangover_frames, 25); // 250 ms of 10 ms frames + assert!(g.feed(&frame(0.1, SPEAKER_FRAME_SAMPLES))); + for _ in 0..24 { + assert!(g.feed(&frame(0.0, SPEAKER_FRAME_SAMPLES))); + } + assert!(!g.feed(&frame(0.0, SPEAKER_FRAME_SAMPLES))); + } + + #[test] + fn seq_freezes_while_gated_and_survives_reopen() { + let mut lane = LaneCtl::new(HAPTICS_FRAME_MS); + // Two audible frames: seq 0, 1. + assert_eq!(lane.admit(&frame(0.5, HAPTICS_FRAME_SAMPLES)), Some(0)); + assert_eq!(lane.admit(&frame(0.5, HAPTICS_FRAME_SAMPLES)), Some(1)); + // The hangover is still sent (seq advances), then the gate closes and seq FREEZES — + // deliberate silence the client tells from loss by continuity. + for i in 0..49u32 { + assert_eq!(lane.admit(&frame(0.0, HAPTICS_FRAME_SAMPLES)), Some(2 + i)); + } + for _ in 0..500 { + assert_eq!(lane.admit(&frame(0.0, HAPTICS_FRAME_SAMPLES)), None); + } + // A capture reopen resets ONLY the framer (PadFramer::clear) — LaneCtl is deliberately + // untouched, so the next audible frame CONTINUES the sequence (gap, not restart). + assert_eq!(lane.admit(&frame(0.9, HAPTICS_FRAME_SAMPLES)), Some(51)); + } + + #[test] + fn splitter_exact_pairs() { + // Interleave [FL FR BL BR] × 2 frames with distinct values everywhere. + let quad = [0.0, 1.0, 2.0, 3.0, 10.0, 11.0, 12.0, 13.0]; + let (front, back) = split_quad(&quad); + assert_eq!(front, [0.0, 1.0, 10.0, 11.0]); + assert_eq!(back, [2.0, 3.0, 12.0, 13.0]); + // A ragged tail (never produced by the capturer) is dropped, not smeared. + let (front, back) = split_quad(&quad[..7]); + assert_eq!((front.len(), back.len()), (2, 2)); + } + + #[test] + fn framer_cuts_the_wire_cadence() { + let mut f = PadFramer::new(KIND_BIT_HAPTICS | KIND_BIT_SPEAKER); + let mut got: Vec<(u8, usize, f32)> = Vec::new(); + // 10 ms of capture (480 samples), fed in ragged chunks: exactly two 5 ms haptics + // frames from the back pair, then one 10 ms speaker frame from the front pair. + let mut quad = Vec::new(); + for _ in 0..2 * HAPTICS_FRAME_SAMPLES { + quad.extend_from_slice(&[0.25, 0.25, -0.5, -0.5]); + } + for chunk in quad.chunks(101) { + f.feed(chunk, |kind, frame| got.push((kind, frame.len(), frame[0]))); + } + assert_eq!( + got, + vec![ + (PAD_AUDIO_KIND_HAPTICS, 2 * HAPTICS_FRAME_SAMPLES, -0.5), + (PAD_AUDIO_KIND_HAPTICS, 2 * HAPTICS_FRAME_SAMPLES, -0.5), + (PAD_AUDIO_KIND_SPEAKER, 2 * SPEAKER_FRAME_SAMPLES, 0.25), + ] + ); + } + + #[test] + fn framer_masks_disabled_kinds() { + // 20 ms of all-ones capture: 4 potential haptics frames, 2 potential speaker frames. + let quad = vec![1.0f32; 4 * HAPTICS_FRAME_SAMPLES * CAP_CHANNELS]; + let mut kinds_seen = Vec::new(); + // Haptics-only: the front pair is never split out, let alone encoded. + let mut f = PadFramer::new(KIND_BIT_HAPTICS); + f.feed(&quad, |kind, _| kinds_seen.push(kind)); + assert_eq!(kinds_seen, vec![PAD_AUDIO_KIND_HAPTICS; 4]); + // Speaker-only: no haptics frames. + let mut f = PadFramer::new(KIND_BIT_SPEAKER); + kinds_seen.clear(); + f.feed(&quad, |kind, _| kinds_seen.push(kind)); + assert_eq!(kinds_seen, vec![PAD_AUDIO_KIND_SPEAKER; 2]); + // kinds = 0 is never spawned, but the framer must still be total: nothing comes out. + let mut f = PadFramer::new(0); + kinds_seen.clear(); + f.feed(&quad, |kind, _| kinds_seen.push(kind)); + assert!(kinds_seen.is_empty()); + } + + #[test] + fn framer_clear_drops_partials_only() { + let mut f = PadFramer::new(KIND_BIT_HAPTICS | KIND_BIT_SPEAKER); + let mut emitted = 0; + // 100 samples: no frame boundary reached yet. + f.feed(&vec![0.1; 100 * CAP_CHANNELS], |_, _| emitted += 1); + assert_eq!(emitted, 0); + f.clear(); + // After the gap: exactly one haptics frame from 240 fresh samples — the 100 stale + // samples are gone (they would skew every later frame boundary). + f.feed( + &vec![0.2; HAPTICS_FRAME_SAMPLES * CAP_CHANNELS], + |kind, frame| { + emitted += 1; + assert_eq!( + (kind, frame.len()), + (PAD_AUDIO_KIND_HAPTICS, 2 * HAPTICS_FRAME_SAMPLES) + ); + }, + ); + assert_eq!(emitted, 1); + } + + #[test] + fn host_cap_requires_the_client_bit() { + // Without CLIENT_CAP_PAD_AUDIO the answer is no on EVERY platform (on Windows the + // env + provisioning legs are environment-dependent — not unit-tested here). + assert!(!host_cap(0)); + assert!(!host_cap(punktfunk_core::quic::CLIENT_CAP_CURSOR)); + } +} diff --git a/include/punktfunk_core.h b/include/punktfunk_core.h index 2bc5d07e..944c76aa 100644 --- a/include/punktfunk_core.h +++ b/include/punktfunk_core.h @@ -58,7 +58,13 @@ // uncertainty and the circular arrival-lead statistic the host's controller steers on. Additive; // the wire grows only a new control message (`PhaseReport`, 0x32) an old host never reads and a // strict-prefix append on the 0xCF host-timing tail, so [`WIRE_VERSION`] is unchanged. -#define ABI_VERSION 14 +// v15: added the pad-audio client surface — `punktfunk_connection_next_pad_audio` (the 0xD1 +// per-gamepad DualSense haptics/speaker plane) + `punktfunk_connection_set_pad_audio_caps` and +// the `PUNKTFUNK_CLIENT_CAP_PAD_AUDIO` / `PUNKTFUNK_HOST_CAP_PAD_AUDIO` mirrors. Additive and +// capability-gated end to end: the wire grows a new datagram tag (0xD1) an old client never +// receives (double-gated caps), a new 0xCD kind (0x06, dropped as unknown by old clients) and +// arrival flag bits 8/9 sent only toward a capable host, so [`WIRE_VERSION`] is unchanged. +#define ABI_VERSION 15 // The punktfunk/1 **wire** version — what `Hello`/`Welcome` carry and hosts equality-check. // Deliberately its own constant: [`ABI_VERSION`] tracks the embeddable **C surface** @@ -82,6 +88,13 @@ // little-endian `u16`s with `effect_len = 6`. Clients without trackpad coils drop it. #define PUNKTFUNK_HIDOUT_TRACKPAD_HAPTIC 4 +// `PunktfunkHidOutput::kind` — the audio-control region of a DS5 output report (pad-audio +// routing/volumes; the audio SAMPLES arrive via [`punktfunk_connection_next_pad_audio`]). +// `which` = the condensed audio flags (bit0 = haptics-select, bits1..4 = the report's +// audio-valid flags); `effect[0..6]` = bytes 5..=10 of the report verbatim +// (headphone/speaker/mic volumes + routing) with `effect_len = 6`. Forwarded change-only. +#define PUNKTFUNK_HIDOUT_AUDIO_CTL 5 + // Capacity of `PunktfunkHidOutput::effect` (the DualSense trigger parameter block). #define PUNKTFUNK_HID_EFFECT_MAX 11 @@ -266,6 +279,28 @@ // design/pen-tablet-input.md.) #define PUNKTFUNK_HOST_CAP_PEN 16 +// Host-capability bit in [`punktfunk_connection_host_caps`]: the host can capture per-gamepad +// audio (DualSense voice-coil haptics + speaker) and emit it on the 0xD1 plane toward pads +// declared capable via [`punktfunk_connection_set_pad_audio_caps`]. Set only when the client +// asked via [`PUNKTFUNK_CLIENT_CAP_PAD_AUDIO`]. (Mirrors `quic::HOST_CAP_PAD_AUDIO`.) +#define PUNKTFUNK_HOST_CAP_PAD_AUDIO 32 + +// Pad-audio `kind` ([`punktfunk_connection_next_pad_audio`]): the BACK channel pair — DualSense +// voice-coil haptics, 5 ms Opus frames. (Mirrors `quic::PAD_AUDIO_KIND_HAPTICS`.) +#define PUNKTFUNK_PAD_AUDIO_KIND_HAPTICS 0 + +// Pad-audio `kind`: the FRONT channel pair — the controller's built-in speaker, 10 ms Opus +// frames. (Mirrors `quic::PAD_AUDIO_KIND_SPEAKER`.) +#define PUNKTFUNK_PAD_AUDIO_KIND_SPEAKER 1 + +// [`punktfunk_connection_set_pad_audio_caps`] `audio_caps` bit: the pad renders the HAPTICS +// stream (a real DualSense's voice coils). +#define PUNKTFUNK_PAD_AUDIO_CAP_HAPTICS 1 + +// [`punktfunk_connection_set_pad_audio_caps`] `audio_caps` bit: the pad renders the SPEAKER +// stream. +#define PUNKTFUNK_PAD_AUDIO_CAP_SPEAKER 2 + // [`punktfunk_connect_ex9`] `client_caps` bit: render the host cursor locally (the cursor // channel, `design/remote-desktop-sweep.md` M2). #define PUNKTFUNK_CLIENT_CAP_CURSOR 1 @@ -276,6 +311,13 @@ // forward-compatible. #define PUNKTFUNK_CLIENT_CAP_PHASE_LOCK 2 +// [`punktfunk_connect_ex9`] `client_caps` bit: the client understands the pad-audio plane +// (0xD1 — per-gamepad DualSense voice-coil haptics + speaker). The embedder MUST then drain +// [`punktfunk_connection_next_pad_audio`] and declare each capable pad via +// [`punktfunk_connection_set_pad_audio_caps`]; the host emits pad audio only when it answers +// with [`PUNKTFUNK_HOST_CAP_PAD_AUDIO`]. (Mirrors `quic::CLIENT_CAP_PAD_AUDIO`.) +#define PUNKTFUNK_CLIENT_CAP_PAD_AUDIO 4 + // `*ttl_ms` sentinel written by [`punktfunk_connection_next_rumble2`] for a legacy (v1) rumble // datagram — an old host that sent no self-termination lease. The client then falls back to its // own staleness heuristic for that update instead of a host-supplied deadline. @@ -342,6 +384,19 @@ // Fixed serialized size of an [`InputEvent`] on the wire (tag + fields). #define INPUT_WIRE_LEN (((((1 + 1) + 4) + 4) + 4) + 4) +// [`InputKind::GamepadArrival`] `flags` bit: this pad renders pad-audio HAPTICS — it is (or +// forwards to) a real DualSense whose voice-coil actuators can play the +// [`PAD_AUDIO_KIND_HAPTICS`](crate::quic::PAD_AUDIO_KIND_HAPTICS) stream. Rides above the pad +// index byte; sent only toward a [`HOST_CAP_PAD_AUDIO`](crate::quic::HOST_CAP_PAD_AUDIO) host +// (an older host reads the whole `flags` word as the index, so unexpected high bits would make +// it drop the declaration). +#define ARRIVAL_FLAG_PAD_AUDIO_HAPTICS (1 << 8) + +// [`InputKind::GamepadArrival`] `flags` bit: this pad renders pad-audio SPEAKER — the +// [`PAD_AUDIO_KIND_SPEAKER`](crate::quic::PAD_AUDIO_KIND_SPEAKER) stream. Same wire discipline +// as [`ARRIVAL_FLAG_PAD_AUDIO_HAPTICS`]. +#define ARRIVAL_FLAG_PAD_AUDIO_SPEAKER (1 << 9) + // The number of gamepads addressable on the wire (`flags` pad index 0..15). Shared by the // client's snapshot fold and the host's per-pad accumulators. #define MAX_PADS 16 @@ -627,6 +682,18 @@ #define CLIENT_CAP_PHASE_LOCK 2 #endif +#if defined(PUNKTFUNK_FEATURE_QUIC) +// [`Hello::client_caps`] bit: the client understands the pad-audio plane +// ([`PAD_AUDIO_MAGIC`](super::datagram::PAD_AUDIO_MAGIC), `0xD1`) — per-gamepad DualSense +// voice-coil haptics + speaker Opus frames, plus the [`HidOutput::AudioCtl`] +// (super::datagram::HidOutput) routing/volume events. Active only when the host answers with +// [`HOST_CAP_PAD_AUDIO`] AND the pad's arrival declared a renderer for the kind +// ([`crate::input::ARRIVAL_FLAG_PAD_AUDIO_HAPTICS`]/`_SPEAKER`) — the capable-and-agreed +// precedent, per pad; toward an older or incapable host nothing changes. `0x04` — `0x01` is +// [`CLIENT_CAP_CURSOR`], `0x02` is [`CLIENT_CAP_PHASE_LOCK`]. +#define CLIENT_CAP_PAD_AUDIO 4 +#endif + #if defined(PUNKTFUNK_FEATURE_QUIC) // [`Welcome::host_caps`] bit: the host CAN forward the cursor out-of-band (it captures cursor // metadata separately from the frame — the Linux portal `SPA_META_Cursor` path; NOT gamescope, @@ -652,6 +719,19 @@ #define HOST_CAP_PEN 16 #endif +#if defined(PUNKTFUNK_FEATURE_QUIC) +// [`Welcome::host_caps`] bit: the host can capture pad audio — its virtual DualSense exposes +// the pad's audio endpoints (voice-coil haptics + speaker), so a game's per-pad audio can be +// captured and shipped on the [`PAD_AUDIO_MAGIC`](super::datagram::PAD_AUDIO_MAGIC) plane. +// Set only when the client asked via [`CLIENT_CAP_PAD_AUDIO`]; when both bits agree, a +// capable client marks its pads' render capabilities on their arrivals +// ([`crate::input::ARRIVAL_FLAG_PAD_AUDIO_HAPTICS`]/`_SPEAKER`) and the host emits `0xD1` +// toward exactly those pads. `0x20` — `0x10` is [`HOST_CAP_PEN`], `0x08` is +// [`HOST_CAP_CURSOR`], `0x04` is [`HOST_CAP_TEXT_INPUT`], `0x01`/`0x02` are gamepad-state / +// clipboard. +#define HOST_CAP_PAD_AUDIO 32 +#endif + #if defined(PUNKTFUNK_FEATURE_QUIC) // [`Hello::video_codecs`] bit: the client can decode H.264 / AVC. The GPU-less **software** // encode path (openh264) emits H.264, so a client that wants to stream from a software host MUST @@ -939,7 +1019,9 @@ // audio = [`AUDIO_MAGIC`] (0xC9, host→client), rumble = [`RUMBLE_MAGIC`] (0xCA, host→client), // mic = [`MIC_MAGIC`] (0xCB, client→host), rich-input = [`RICH_INPUT_MAGIC`] (0xCC, client→host), // HID-output = [`HIDOUT_MAGIC`] (0xCD, host→client), HDR metadata = [`HDR_META_MAGIC`] -// (0xCE, host→client). +// (0xCE, host→client), host timing = [`HOST_TIMING_MAGIC`] (0xCF, host→client), cursor state = +// [`CURSOR_STATE_MAGIC`] (0xD0, host→client), pad audio = [`PAD_AUDIO_MAGIC`] (0xD1, +// host→client). #define PUNKTFUNK_AUDIO_MAGIC 201 #endif @@ -1043,6 +1125,31 @@ #define CURSOR_RELATIVE_HINT 2 #endif +#if defined(PUNKTFUNK_FEATURE_QUIC) +// Pad-audio datagram tag, host → client: per-gamepad audio a game routed +// to the host's virtual DualSense — voice-coil haptics and the built-in speaker — for the client +// to render on the matching real controller. Next tag after [`CURSOR_STATE_MAGIC`]. The +// per-pad AUDIO plane (Opus frames, the [`AUDIO_MAGIC`]/[`MIC_MAGIC`] shape plus pad + kind); +// the routing/volume CONTROL side rides [`HidOutput::AudioCtl`]. Emitted only when the session +// negotiated it ([`CLIENT_CAP_PAD_AUDIO`](super::caps::CLIENT_CAP_PAD_AUDIO) ∧ +// [`HOST_CAP_PAD_AUDIO`](super::caps::HOST_CAP_PAD_AUDIO)) and the pad's arrival declared a +// renderer for the kind ([`crate::input::ARRIVAL_FLAG_PAD_AUDIO_HAPTICS`]/`_SPEAKER`). +// Best-effort like every audio datagram: a lost frame is a concealed gap, never state. +#define PAD_AUDIO_MAGIC 209 +#endif + +#if defined(PUNKTFUNK_FEATURE_QUIC) +// [`PadAudioFrame::kind`]: the BACK channel pair — the DualSense voice-coil actuators (audio +// haptics). 5 ms Opus frames, matching the [`AUDIO_MAGIC`] cadence: haptics are felt latency. +#define PAD_AUDIO_KIND_HAPTICS 0 +#endif + +#if defined(PUNKTFUNK_FEATURE_QUIC) +// [`PadAudioFrame::kind`]: the FRONT channel pair — the controller's built-in speaker. 10 ms +// Opus frames (speaker content tolerates the extra buffering for the better coding efficiency). +#define PAD_AUDIO_KIND_SPEAKER 1 +#endif + #if defined(PUNKTFUNK_FEATURE_QUIC) // QUIC application error code a punktfunk/1 client closes the control connection with on a // **deliberate quit** (a user "stop", not a network drop). The host reads it off the connection's @@ -1357,7 +1464,11 @@ enum PunktfunkInputKind PUNKTFUNK_INPUT_KIND_GAMEPAD_REMOVE = 13, // Declares which controller KIND a pad presents so a session can MIX types (pad 0 a // DualSense, pad 1 an Xbox pad). `code` = the [`GamepadPref`](crate::config::GamepadPref) - // wire byte, `flags` = pad index. Sent when the client opens a pad slot — before that pad's + // wire byte, `flags` = pad index in the low byte plus the pad's render capabilities in bits + // 8/9 ([`ARRIVAL_FLAG_PAD_AUDIO_HAPTICS`]/[`ARRIVAL_FLAG_PAD_AUDIO_SPEAKER`] — sent only + // toward a [`HOST_CAP_PAD_AUDIO`](crate::quic::HOST_CAP_PAD_AUDIO) host, so an older host + // keeps reading the whole word as the index; hosts decode via [`decode_gamepad_arrival`]). + // Sent when the client opens a pad slot — before that pad's // first input — and re-sent a few times against datagram loss (like [`GamepadRemove`]). The // host resolves the kind to a buildable backend and routes that pad's virtual device to it; a // pad the client never declares (an older client, or a fully-lost declaration) falls back to @@ -2262,6 +2373,50 @@ PunktfunkStatus punktfunk_connection_next_audio_pcm(PunktfunkConnection *c, uint32_t timeout_ms); #endif +#if defined(PUNKTFUNK_FEATURE_QUIC) +// Pull the next pad-audio frame (0xD1) — one Opus frame of DualSense voice-coil haptics +// (`kind` = [`PUNKTFUNK_PAD_AUDIO_KIND_HAPTICS`], 5 ms) or built-in-speaker audio +// ([`PUNKTFUNK_PAD_AUDIO_KIND_SPEAKER`], 10 ms) for gamepad `*out_pad` — waiting up to +// `timeout_ms`. The payload is COPIED into `buf` (no borrow-until-next-call slot); the return +// value is its length in bytes, `0` = nothing this poll (timeout — or a DTX/oversized frame, +// both of which an embedder treats the same way), `-1` = the session ended (or an invalid +// handle/buffer). All pads/kinds share one queue — fan out by `*out_pad`/`*out_kind` to +// per-actuator Opus decoders. A frame larger than `buf_len` is dropped like the timeout case +// (the plane is lossy by design; any real Opus frame fits a 1500-byte buffer). Only a session +// connected with [`PUNKTFUNK_CLIENT_CAP_PAD_AUDIO`] against a +// [`PUNKTFUNK_HOST_CAP_PAD_AUDIO`] host — with the pad declared via +// [`punktfunk_connection_set_pad_audio_caps`] — ever receives any. Drain from a dedicated +// thread (one puller, may run alongside the other planes' pullers). +// +// # Safety +// `c` is a valid connection handle; the `out_*` pointers are writable (NULLs are skipped); +// `buf` is writable for `buf_len` bytes. +int32_t punktfunk_connection_next_pad_audio(PunktfunkConnection *c, + uint8_t *out_pad, + uint8_t *out_kind, + uint32_t *out_seq, + uint64_t *out_pts_ns, + uint8_t *buf, + uintptr_t buf_len, + uint32_t timeout_ms); +#endif + +#if defined(PUNKTFUNK_FEATURE_QUIC) +// Declare wire pad `pad`'s pad-audio render capabilities (`audio_caps`: OR of +// [`PUNKTFUNK_PAD_AUDIO_CAP_HAPTICS`] / [`PUNKTFUNK_PAD_AUDIO_CAP_SPEAKER`]) — how a client +// tells the host WHICH pads can actually play the 0xD1 streams. Call at controller attach, +// BEFORE the pad's arrival event is sent (the [`punktfunk_connection_set_rumble_quirks`] +// timing): the core folds the bits into the arrival's flags (bits 8/9), and only toward a +// [`PUNKTFUNK_HOST_CAP_PAD_AUDIO`] host — never calling this leaves the wire bytes exactly as +// before. Latest-wins per pad; unknown bits are masked off. +// +// # Safety +// `c` is a valid connection handle. Callable from any thread. +PunktfunkStatus punktfunk_connection_set_pad_audio_caps(PunktfunkConnection *c, + uint8_t pad, + uint8_t audio_caps); +#endif + #if defined(PUNKTFUNK_FEATURE_QUIC) // Pull the next rumble (force-feedback) update, waiting up to `timeout_ms`. Amplitudes // are 0..0xFFFF (`low` = low-frequency motor, `high` = high-frequency), `(0, 0)` = stop.