From ed3d236ab865d18bdb0a05ee2bc44d71856eec47 Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Sat, 1 Aug 2026 12:07:06 +0200 Subject: [PATCH 01/21] feat(pad-audio): DualSense audio haptics + speaker, host->client end to end The 0xD1 pad-audio plane streams a DualSense's voice-coil haptics (back channel pair, 5 ms Opus frames) and speaker (front pair, 10 ms) per pad from a Windows host to the SDL clients, which render them into a USB DualSense's own 4-channel audio device. Wire (punktfunk-core, ABI v15): PAD_AUDIO_MAGIC 0xD1 [pad][kind][seq][pts] [opus]; CLIENT_CAP_PAD_AUDIO 0x04 / HOST_CAP_PAD_AUDIO 0x20; per-pad render capability rides GamepadArrival flags bits 8/9, sent only toward a host that advertised its cap so old hosts see byte-identical arrivals; silence is a frozen seq (mic-mute discipline), loss is a seq gap concealed via AudioGapTracker. HidOutput::AudioCtl (0xCD kind 0x06) forwards the 0x02 report's audio-control bytes 5..=10 change-only, value-deduped, with a once-per-pad "title asserted haptics-select" diagnosis log. Windows host endpoint provider (audio/windows/pad_endpoint.rs): per-pad render endpoints are additional devnode instances of Valve's Steam Streaming Speakers driver (SetupDiRegisterDeviceInfo, NOT the class installer - it needs an interactive window station), stamped with DualSense identity: desc "Wireless Controller", device name "DualSense Wireless Controller", ContainerId = the virtual pad's PFDS GUID, 4ch/48k format triplet. IPropertyStore route first, ACL-repaired registry fallback (the MMDevices keys deny writes even to SYSTEM; the owner's implicit WRITE_DAC + an ACE for S-1-5-18 resolved by SID is the way in). Provisioned at host startup (PUNKTFUNK_PAD_AUDIO, PUNKTFUNK_PAD_AUDIO_SLOTS, default 1), idempotent via a persisted PunktfunkPadIndex marker; pad endpoints are structurally ineligible for the mic/loopback wiring plan and guarded against default- device theft; capture is WASAPI loopback on the stamped endpoint. Devtest: punktfunk-host pad-endpoint ensure|remove|status. Host service (native/pad_audio.rs): per-(session,pad) thread, loopback 4ch -> pair splitter -> per-kind stereo Opus (48k LowDelay CBR 64k) -> per-kind silence gate (opens at peak>=1e-3, 250 ms hangover, gated = no send + frozen seq) -> datagrams. Spawned from the native input pump when a DualSense/Edge arrival carries audio bits and both caps negotiated; idempotent re-arrivals; reaped on remove and teardown. Client tier A (pf-client-core/pad_audio.rs): settings pad_haptics (default on) and pad_speaker (default "pad"); tier A = wired USB DS5/Edge via SDL connection state with an audio-sibling fallback; correlation maps the SDL HID path to the pad's own render endpoint (Windows: ContainerId match + 4ch gate via registry; Linux: Sony sink signature); renderer decodes both kinds into a quad interleave and plays it on the pad's endpoint (WASAPI autoconvert / PipeWire target.object, 240-2400 frame ring floor, dont-reconnect so an unplug never re-routes haptics to the desktop speakers). SDL's DualSense driver sets "disable audio haptics" whenever it drives rumble emulation, so tier-A pads suppress wire rumble and send one cleared-enable-bits effects packet to keep the actuators live; AudioCtl bytes fold back into the effects packet at report-minus-one offsets. Verification: punktfunk-core 265 tests (macOS) + clippy -D warnings (mac + Linux docker); pf-inject 85 tests (Linux docker); punktfunk-host cargo check + clippy + 19 pad tests + 46 audio-module tests (Windows box); pf-client-core 30 tests + clippy (Linux docker CI image) + cargo check (Windows box); punktfunk-client-session clippy (Linux) + check (Windows); cargo fmt --all --check clean on the final tree. NOT yet verified: any on-glass run (host deploy + real title + physical pad), the stamp-route split at runtime, exclusive-mode Initialize isolation, Linux-host emission (the per-pad PipeWire sink is not in this change - Windows hosts only). Scope excluded deliberately: tier B (Apple CoreHaptics) and tier C (haptics->rumble derivation), pad_speaker="mix", Android leg, settings UI surfaces (keys are serde-defaulted), GameStream-plane arrivals (audio_caps always 0 there). Co-Authored-By: Claude Fable 5 --- Cargo.lock | 1 + clients/android/native/src/feedback.rs | 5 + clients/session/src/main.rs | 11 + crates/pf-client-core/Cargo.toml | 4 + crates/pf-client-core/src/gamepad.rs | 186 ++ crates/pf-client-core/src/lib.rs | 5 + crates/pf-client-core/src/pad_audio.rs | 1220 ++++++++++++ crates/pf-client-core/src/session.rs | 35 + crates/pf-client-core/src/trust.rs | 21 + crates/pf-inject/src/inject/hidout_dedup.rs | 53 +- .../src/inject/proto/dualsense_proto.rs | 60 +- crates/pf-inject/src/inject/uhid_manager.rs | 1 + crates/punktfunk-core/src/abi.rs | 200 ++ crates/punktfunk-core/src/client/mod.rs | 54 +- crates/punktfunk-core/src/client/planes.rs | 6 + crates/punktfunk-core/src/client/pump.rs | 15 +- .../src/client/pump/datagram_task.rs | 6 + .../src/client/pump/input_task.rs | 50 +- crates/punktfunk-core/src/client/worker.rs | 12 +- crates/punktfunk-core/src/input.rs | 64 +- crates/punktfunk-core/src/lib.rs | 8 +- crates/punktfunk-core/src/quic/caps.rs | 43 + crates/punktfunk-core/src/quic/datagram.rs | 144 +- crates/punktfunk-core/src/quic/mod.rs | 2 +- crates/punktfunk-host/Cargo.toml | 11 + crates/punktfunk-host/src/audio.rs | 6 + .../src/audio/windows/audio_control.rs | 36 +- .../src/audio/windows/pad_endpoint.rs | 1678 +++++++++++++++++ .../src/audio/windows/wasapi_mic.rs | 50 +- .../punktfunk-host/src/audio/wiring_plan.rs | 72 +- crates/punktfunk-host/src/devtest.rs | 46 + .../punktfunk-host/src/gamestream/gamepad.rs | 3 + crates/punktfunk-host/src/main.rs | 4 + crates/punktfunk-host/src/native.rs | 21 +- crates/punktfunk-host/src/native/handshake.rs | 10 + crates/punktfunk-host/src/native/input.rs | 122 +- crates/punktfunk-host/src/native/pad_audio.rs | 641 +++++++ include/punktfunk_core.h | 161 +- 38 files changed, 4996 insertions(+), 71 deletions(-) create mode 100644 crates/pf-client-core/src/pad_audio.rs create mode 100644 crates/punktfunk-host/src/audio/windows/pad_endpoint.rs create mode 100644 crates/punktfunk-host/src/native/pad_audio.rs 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. From b5f91d50bb42244f68066ae883810d9a35917211 Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Sun, 2 Aug 2026 23:39:25 +0200 Subject: [PATCH 02/21] =?UTF-8?q?feat(android):=20tier-A=20pad=20audio=20?= =?UTF-8?q?=E2=80=94=20the=200xD1=20plane=20on=20the=20pad's=20USB=20endpo?= =?UTF-8?q?int=20(WP9)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Android twin of `pf-client-core`'s pad_audio: drain the host's per-pad DualSense streams, Opus-decode haptics (kind 0) and speaker (kind 1), interleave into the pad's own 4-channel layout, and render on the pad itself. Every other client hands that stream to the platform's audio graph. Android cannot: AOSP's UsbAlsaManager denylists the DualSense's output by VID/PID, so the kernel enumerates the pad's playback node and the framework discards it — `hasOutput: false`, nothing for setPreferredDevice to target, /dev/snd closed by SELinux, and UsbRequest rejects non-bulk/interrupt endpoints. So this drives the pad's isochronous endpoint directly via uac-host on the descriptor Java owns. That is measured, not assumed. On a Nothing Phone (3): the claim succeeds unprivileged, the gamepad and the pad's microphone both keep working, and the underrun-free floor is 4 ms — holding under eight-core load with the SoC in severe thermal throttling. The renderer runs at 6 ms, one step of headroom, because the same measurement found transient events that are not depth-dependent. Structured to the crate's own convention: the mixer and PLC are ungated so they compile and unit-test in the host workspace (8 tests), while everything touching an Android-only dependency is cfg'd to android. Two details worth review: - The kinds arrive on different cadences (5 ms vs 10 ms), so each has its own write cursor and both shift together on overflow — a haptics-only session renders with a silent speaker pair instead of stalling on a kind that will never arrive, and the two can never skew. - An unrecognised kind is dropped rather than folded into the coil pair. A `min(1)` clamp would have rendered a future kind straight into the actuators. Lifecycle mirrors MicCapture: dropping the handle joins the thread, and nativeStopPadAudio returns only once it has, so Kotlin may close the UsbDeviceConnection as soon as it returns and not before. usbfs-iso/uac-host enter as git dependencies pinned by revision — a transport under a real-time deadline should move when we choose. They become version dependencies once published to crates.io. --- Cargo.lock | 18 + clients/android/native/Cargo.toml | 8 + clients/android/native/src/lib.rs | 2 + clients/android/native/src/pad_audio.rs | 592 ++++++++++++++++++ clients/android/native/src/session/connect.rs | 2 + clients/android/native/src/session/mod.rs | 15 + clients/android/native/src/session/planes.rs | 66 ++ 7 files changed, 703 insertions(+) create mode 100644 clients/android/native/src/pad_audio.rs diff --git a/Cargo.lock b/Cargo.lock index 04dcbda0..b075dc31 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3347,6 +3347,8 @@ dependencies = [ "opus", "punktfunk-core", "tracing", + "uac-host", + "usbfs-iso", ] [[package]] @@ -4986,6 +4988,14 @@ version = "1.20.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" +[[package]] +name = "uac-host" +version = "0.1.0" +source = "git+https://github.com/unom-io/usbfs-iso?rev=fb01ea69c59e3bf08b3918f53a159287b5187ed2#fb01ea69c59e3bf08b3918f53a159287b5187ed2" +dependencies = [ + "usbfs-iso", +] + [[package]] name = "uds_windows" version = "1.2.1" @@ -5065,6 +5075,14 @@ dependencies = [ "serde", ] +[[package]] +name = "usbfs-iso" +version = "0.1.0" +source = "git+https://github.com/unom-io/usbfs-iso?rev=fb01ea69c59e3bf08b3918f53a159287b5187ed2#fb01ea69c59e3bf08b3918f53a159287b5187ed2" +dependencies = [ + "libc", +] + [[package]] name = "usbip-sim" version = "0.8.0" diff --git a/clients/android/native/Cargo.toml b/clients/android/native/Cargo.toml index 320f4745..5e1e661b 100644 --- a/clients/android/native/Cargo.toml +++ b/clients/android/native/Cargo.toml @@ -64,6 +64,14 @@ libc = "0.2" # host + Linux client use. audiopus_sys vendors libopus (pure C) and builds it static via cmake — # the cargo-ndk build sets LIBOPUS_STATIC=1/LIBOPUS_NO_PKG=1 so it links the bundled lib, not the host's. opus = "0.3" +# Tier-A pad audio (WP9). Android's audio framework denylists the DualSense's output by VID/PID, +# so the pad's isochronous endpoint is driven directly on the fd `UsbDeviceConnection` hands over. +# Our own crates, developed openly because the hole they fill — isochronous USB in Rust — is an +# ecosystem-wide one: https://github.com/unom-io/usbfs-iso +# Pinned by revision rather than floating: this is a transport under a real-time deadline and it +# should move when we choose to. Becomes a plain version dependency once the crates are published. +uac-host = { git = "https://github.com/unom-io/usbfs-iso", rev = "fb01ea69c59e3bf08b3918f53a159287b5187ed2" } +usbfs-iso = { git = "https://github.com/unom-io/usbfs-iso", rev = "fb01ea69c59e3bf08b3918f53a159287b5187ed2" } [lints] workspace = true diff --git a/clients/android/native/src/lib.rs b/clients/android/native/src/lib.rs index bc7bc62b..cc156155 100644 --- a/clients/android/native/src/lib.rs +++ b/clients/android/native/src/lib.rs @@ -37,6 +37,8 @@ mod discovery; mod feedback; #[cfg(target_os = "android")] mod mic; +/// Tier-A DualSense pad audio: the 0xD1 plane rendered on the pad's own USB endpoint. +mod pad_audio; mod session; mod stats; // Ungated like `discovery`: pure `jni` + `punktfunk_core::wol` (no Android framework), so it links diff --git a/clients/android/native/src/pad_audio.rs b/clients/android/native/src/pad_audio.rs new file mode 100644 index 00000000..03f52b9c --- /dev/null +++ b/clients/android/native/src/pad_audio.rs @@ -0,0 +1,592 @@ +//! Pad audio on Android (the 0xD1 plane) — tier A, WP9. +//! +//! The Android twin of [`pf_client_core::pad_audio`]: drain the host's per-pad DualSense streams, +//! Opus-decode haptics (kind 0) and speaker (kind 1), interleave them into the pad's own +//! 4-channel layout, and render them on the physical pad. +//! +//! # Why this needs a USB driver instead of an audio API +//! +//! Every other client hands the 4-channel stream to the platform's audio graph — WASAPI on +//! Windows, PipeWire on Linux, CoreAudio on Apple. **Android has no such option for this device.** +//! AOSP's `UsbAlsaManager` carries a hardcoded VID/PID denylist that includes the DualSense +//! (`054c:0ce6`), so the kernel enumerates the pad's playback node and the framework then discards +//! it: `hasOutput: false`. There is no `AudioDeviceInfo` for `setPreferredDevice` to target, and +//! `/dev/snd` is closed to apps by SELinux. Android's own `UsbRequest` API cannot help either — it +//! rejects any endpoint that is not bulk or interrupt. +//! +//! So this path drives the pad's isochronous endpoint directly, through `uac-host` on the file +//! descriptor Java already owns. That is measured, not hoped: on a Nothing Phone (3) the claim +//! succeeds unprivileged, the gamepad and the pad's microphone both keep working, and the +//! underrun-free floor is **4 ms in flight** — including under eight-core load with the SoC in +//! severe thermal throttling. +//! +//! # The firmware exclusivity that shapes everything here +//! +//! `valid_flag0` bit 1 (`HAPTICS_SELECT`) *disables* audio haptics and selects classic rumble, and +//! Linux's `hid-playstation` sets it on every force-feedback update — as does SDL, and as does our +//! own [`crate::feedback`] path. **Tier A and tier C are mutually exclusive in the pad's firmware**, +//! so a pad rendering this stream must have its wire rumble suppressed rather than mixed. The +//! arbitration is a selection, never a blend. + +use std::collections::VecDeque; + +use punktfunk_core::audio::AudioGapTracker; +use punktfunk_core::quic::{PAD_AUDIO_KIND_HAPTICS, PAD_AUDIO_KIND_SPEAKER}; + +#[cfg(target_os = "android")] +use punktfunk_core::client::NativeClient; +#[cfg(target_os = "android")] +use std::sync::atomic::{AtomicBool, Ordering}; +#[cfg(target_os = "android")] +use std::sync::Arc; +#[cfg(target_os = "android")] +use std::thread::JoinHandle; +#[cfg(target_os = "android")] +use std::time::Duration; + +/// The pad's render layout: 4 interleaved channels — speaker FL/FR on 0/1, the voice coils on +/// 2/3. Feeding a 2-channel stream would leave the coils silent rather than fail, which is the +/// failure mode most worth not having. +const PAD_CHANNELS: usize = 4; + +/// Both plane kinds decode as 48 kHz stereo. +#[cfg_attr(not(target_os = "android"), allow(dead_code))] +const SAMPLE_RATE: u32 = 48_000; + +/// Ring ceiling, in sample frames. 60 ms — far above the in-flight depth, because this bounds +/// *decoder* backlog when the USB side stalls, not stream latency. Overflow drops the oldest. +const MAX_BUFFER_FRAMES: usize = (SAMPLE_RATE as usize / 1000) * 60; + +/// Largest Opus frame this decodes in one call: 120 ms at 48 kHz, the codec's maximum. +#[cfg_attr(not(target_os = "android"), allow(dead_code))] +const MAX_FRAME_SAMPLES: usize = 5760; + +/// How much audio to keep in flight on the USB endpoint. +/// +/// WP7 measured the underrun-free floor on real hardware at **4 ms** (clean across three sweeps, +/// including one under eight-core load with the CPU thermally throttled); 3 ms was marginal and +/// 2 ms never survived. 6 ms takes one step of headroom above that floor, because the same +/// measurement found isolated transient events roughly once per three seconds that are *not* +/// depth-dependent — so the floor is a floor, not a target. +#[cfg_attr(not(target_os = "android"), allow(dead_code))] +const IN_FLIGHT_MS: u32 = 6; + +// ---- the 4-channel mixer --------------------------------------------------------------------- + +/// Interleave the two independent stereo streams into one 4-channel frame stream. +/// +/// The kinds arrive on different cadences (haptics 5 ms, speaker 10 ms), so each has its own +/// write cursor and [`pop`](Self::pop) emits everything the further-ahead kind has filled, with +/// the lagging or absent kind's pair reading silence. A haptics-only session therefore renders +/// the coils with a silent speaker pair, and vice versa, instead of stalling on the missing kind. +/// +/// Samples are `i16` — the DualSense's own wire format — so nothing converts on the hot path. +/// Pure logic, unit-tested below; pacing lives in the USB ring downstream. +pub(crate) struct QuadMixer { + /// Interleaved 4-channel samples; the front is the next frame out. Always + /// `ready_frames() * PAD_CHANNELS` long. + ring: VecDeque, + /// Per-kind write cursor in FRAMES relative to the ring front, indexed by the wire `kind`. + written: [usize; 2], + /// Frames dropped to the ceiling — a stalled USB side, visible in the logs. + dropped: u64, +} + +#[cfg_attr(not(target_os = "android"), allow(dead_code))] +impl QuadMixer { + pub(crate) fn new() -> QuadMixer { + QuadMixer { + ring: VecDeque::new(), + written: [0; 2], + dropped: 0, + } + } + + /// Write one decoded stereo chunk (interleaved L/R) for `kind` at that kind's cursor, + /// zero-extending as needed. Both cursors shift together on overflow, so the two kinds can + /// never skew relative to one another. + pub(crate) fn push(&mut self, kind: u8, stereo: &[i16]) { + // Name both kinds rather than defaulting: a kind this build does not know belongs + // nowhere in a 4-channel frame, and quietly folding it into the coil pair would render + // an unknown stream straight into the actuators. + let (k, off) = match kind { + PAD_AUDIO_KIND_HAPTICS => (0usize, 2usize), + PAD_AUDIO_KIND_SPEAKER => (1usize, 0usize), + _ => return, + }; + 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); + } + 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.dropped += over as u64; + self.drop_front(over); + } + } + + /// Frames ready to output: the further-ahead kind's cursor. + pub(crate) fn ready_frames(&self) -> usize { + self.written[0].max(self.written[1]) + } + + /// Frames discarded to the ceiling since construction. + pub(crate) fn dropped_frames(&self) -> u64 { + self.dropped + } + + /// Append every ready frame (interleaved 4-channel) to `out`; returns the frame count. + pub(crate) fn pop(&mut self, out: &mut Vec) -> usize { + let frames = self.ready_frames(); + let n = frames * PAD_CHANNELS; + 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 sink to render them on 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 + packet loss concealment --------------------------------------------------------- + +#[cfg(target_os = "android")] +/// Per-kind decode state: a stereo 48 kHz Opus decoder, the seq-gap tracker, and the last decoded +/// frame size, which is the unit PLC synthesises in. +struct KindStream { + dec: opus::Decoder, + gaps: AudioGapTracker, + frame_samples: usize, +} + +/// Concealment frames to synthesise before decoding `seq`. +/// +/// Zero until something has decoded, because there is nothing to size the PLC from yet. The +/// tracker is fed regardless, so a gap seen before the first real frame cannot resurface later as +/// a phantom. Pure, and unit-tested. +#[cfg_attr(not(target_os = "android"), allow(dead_code))] +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 USB sink ------------------------------------------------------------------------------ + +/// Everything that talks to the pad. Linux and Android only: `usbfs` is a Linux kernel ABI, and +/// this crate also builds as a host cdylib on macOS dev boxes, where the mixer and PLC above still +/// compile and still run their tests. +#[cfg(target_os = "android")] +mod sink { + use super::{IN_FLIGHT_MS, PAD_CHANNELS, SAMPLE_RATE}; + + /// Open the pad's 4-channel playback stream on a descriptor Java owns. + /// + /// # Safety + /// + /// `fd` must be a live usbfs descriptor from an open `UsbDeviceConnection` that outlives the + /// returned device — this **borrows** it and never closes it, because closing is + /// `UsbDeviceConnection.close()`'s job and a double close would strand an unrelated + /// descriptor much later. + pub(super) unsafe fn device(fd: i32) -> usbfs_iso::UsbFsDevice { + // SAFETY: forwarded from this function's own contract, which the JNI entry point upholds + // by keeping the Java connection open for the lifetime of the renderer thread. + unsafe { usbfs_iso::UsbFsDevice::from_borrowed_fd(fd) } + } + + /// Find the pad's 4-channel playback stream and open it. + /// + /// Four channels is a hard requirement, not a preference: the voice coils *are* channels 3 + /// and 4, so a 2-channel alternate setting would open successfully and then render haptics + /// into nothing. + pub(super) fn open<'d>( + dev: &'d usbfs_iso::UsbFsDevice, + ) -> Result, uac_host::Error> { + let blob = dev.raw_descriptors()?; + let function = uac_host::parse(&blob)?; + let stream = function + .output_streams() + .find(|s| usize::from(s.channels()) == PAD_CHANNELS) + .ok_or(uac_host::Error::NoAudioFunction)?; + + let opts = uac_host::OpenOptions { + depth: usbfs_iso::Depth::Millis(IN_FLIGHT_MS), + // One packet per URB: the finest granularity the bus offers, and what WP7 measured + // the 4 ms floor with. Packing more multiplies one completion's latency. + packets_per_urb: Some(1), + // Keep the endpoint fed rather than gapping when the decoder is momentarily late. + // A hole in an isochronous stream is silence forever; silence we chose is better. + underrun: usbfs_iso::Underrun::FillSilence, + ..Default::default() + }; + stream.open_with(dev, uac_host::Format::S16Le, SAMPLE_RATE, opts) + } +} + +// ---- the renderer worker ----------------------------------------------------------------------- + +/// A running renderer: the stop flag and the thread, joined on drop. +/// +/// Mirrors [`crate::mic::MicCapture`]'s discipline — dropping the handle is what stops the stream, +/// so a session teardown that forgets a step cannot leave a thread writing to a descriptor Java is +/// about to close. +#[cfg(target_os = "android")] +pub(crate) struct PadAudio { + stop: Arc, + join: Option>, +} + +#[cfg(target_os = "android")] +impl Drop for PadAudio { + fn drop(&mut self) { + self.stop.store(true, Ordering::SeqCst); + if let Some(j) = self.join.take() { + let _ = j.join(); + } + } +} + +/// Start the renderer for a pad whose descriptor Java has handed over. +/// +/// Returns `None` when neither kind is enabled (nothing to render) or the thread will not start. +/// **The caller must keep the `UsbDeviceConnection` open until the returned handle is dropped** — +/// the renderer borrows the descriptor and never closes it. +#[cfg(target_os = "android")] +pub(crate) fn start( + client: Arc, + fd: i32, + haptics: bool, + speaker: bool, +) -> Option { + if !haptics && !speaker { + return None; + } + let stop = Arc::new(AtomicBool::new(false)); + let join = spawn(client, Arc::clone(&stop), fd, haptics, speaker)?; + Some(PadAudio { + stop, + join: Some(join), + }) +} + +/// Spawn the pad-audio renderer — the 0xD1 plane's single consumer on Android. +/// +/// `fd` is the pad's usbfs descriptor from `UsbDeviceConnection.getFileDescriptor()`; the caller +/// **must** keep that connection open until [`stop`](AtomicBool) has been observed and the handle +/// joined. Returns `None` if the thread could not be started. +#[cfg(target_os = "android")] +pub(crate) fn spawn( + client: Arc, + stop: Arc, + fd: i32, + haptics: bool, + speaker: bool, +) -> Option> { + std::thread::Builder::new() + .name("pf-pad-audio".into()) + .spawn(move || run(&client, &stop, fd, haptics, speaker)) + .map_err(|e| log::warn!("pad-audio thread failed to start: {e}")) + .ok() +} + +#[cfg(target_os = "android")] +fn run(client: &NativeClient, stop: &AtomicBool, fd: i32, haptics: bool, speaker: bool) { + // Ask the scheduler for audio priority. Android does not hand SCHED_FIFO to ordinary app + // threads, so -16 (ANDROID_PRIORITY_AUDIO) is the realistic knob — and WP7 measured that it + // both applies and is enough to hold the 4 ms floor against eight busy cores. + // SAFETY: `setpriority` on the calling thread; no pointers, no shared state. + unsafe { + libc::setpriority(libc::PRIO_PROCESS, 0, -16); + } + + // SAFETY: the caller's contract — the Java connection outlives this thread. + let dev = unsafe { sink::device(fd) }; + // Through a reference, deliberately: `UsbFsDevice` has a `Drop`, and opening the stream in + // this same scope would make the borrow outlive the value it borrows. + render(&dev, client, stop, haptics, speaker); +} + +/// Open the pad's stream and render on it until the session stops or the device goes away. +#[cfg(target_os = "android")] +fn render( + dev: &usbfs_iso::UsbFsDevice, + client: &NativeClient, + stop: &AtomicBool, + haptics: bool, + speaker: bool, +) { + match sink::open(dev) { + Ok(mut playback) => { + log::info!( + "pad audio: {} ch {} at {} Hz, {} us in flight", + playback.channels(), + playback.format(), + playback.rate(), + playback.schedule().in_flight_us() + ); + pump(client, stop, haptics, speaker, &mut playback); + } + Err(e) => { + // The interesting failure is a kernel that refuses the claim: some OEM kernels do, and + // there is no app-side fix, so the session carries on without tier A rather than + // treating it as fatal. + log::warn!("pad audio unavailable, falling back: {e}"); + drain_until_stop(client, stop); + } + } +} + +#[cfg(target_os = "android")] +/// Keep the plane drained without rendering, so a host that is sending 0xD1 does not back up +/// against a consumer that never reads. +fn drain_until_stop(client: &NativeClient, stop: &AtomicBool) { + while !stop.load(Ordering::Relaxed) { + if client.next_pad_audio(Duration::from_millis(20)).is_none() + && stop.load(Ordering::Relaxed) + { + return; + } + } +} + +/// The steady state: decode arriving frames, interleave, and hand whole frames to the pad. +#[cfg(target_os = "android")] +fn pump( + client: &NativeClient, + stop: &AtomicBool, + haptics: bool, + speaker: bool, + playback: &mut uac_host::Playback<'_>, +) { + let mut mixer = QuadMixer::new(); + let mut streams: [Option; 2] = [None, None]; + let mut pcm: Vec = Vec::with_capacity(MAX_FRAME_SAMPLES * 2); + let mut out: Vec = Vec::with_capacity(MAX_BUFFER_FRAMES * PAD_CHANNELS); + + while !stop.load(Ordering::Relaxed) { + let Some(frame) = client.next_pad_audio(Duration::from_millis(10)) else { + continue; + }; + + // The settings gate each kind independently: haptics off but speaker on is a legitimate + // configuration, and the host may still be sending both. + let wanted = match frame.kind { + PAD_AUDIO_KIND_HAPTICS => haptics, + PAD_AUDIO_KIND_SPEAKER => speaker, + _ => false, + }; + if !wanted { + continue; + } + + let k = usize::from(frame.kind).min(1); + let st = match &mut streams[k] { + Some(s) => s, + slot @ None => match opus::Decoder::new(SAMPLE_RATE, opus::Channels::Stereo) { + Ok(dec) => slot.insert(KindStream { + dec, + gaps: AudioGapTracker::default(), + frame_samples: 0, + }), + Err(e) => { + log::warn!("pad audio: no Opus decoder for kind {}: {e}", frame.kind); + continue; + } + }, + }; + + // Conceal whatever the sequence numbers say is missing, before decoding what arrived. + let missing = plc_frames(&mut st.gaps, frame.seq, st.frame_samples); + for _ in 0..missing { + pcm.resize(st.frame_samples * 2, 0); + match st.dec.decode(&[], &mut pcm, false) { + Ok(n) => mixer.push(frame.kind, &pcm[..n * 2]), + Err(_) => break, + } + } + + // An empty payload is DTX silence: the tracker has already accounted for the sequence, + // and there is nothing to decode. + if !frame.opus.is_empty() { + pcm.resize(MAX_FRAME_SAMPLES * 2, 0); + match st.dec.decode(&frame.opus, &mut pcm, false) { + Ok(n) => { + st.frame_samples = n; + mixer.push(frame.kind, &pcm[..n * 2]); + } + Err(e) => log::debug!("pad audio: opus decode failed: {e}"), + } + } + + // Hand over whole frames only. `write` stages any remainder internally, so a partial + // chunk is never padded with silence mid-stream. + out.clear(); + if mixer.pop(&mut out) > 0 { + if let Err(e) = playback.write_interleaved(&out) { + if is_fatal(&e) { + log::warn!("pad audio: stream lost: {e}"); + return; + } + log::debug!("pad audio: write hiccup: {e}"); + mixer.discard(); + } + } + } + + let _ = playback.drain(Duration::from_millis(100)); + let stats = playback.stats(); + log::info!( + "pad audio stopped: {} frames, {} underruns, {} short bytes, {} dropped by backlog", + playback.frames_written(), + stats.underruns, + stats.short_bytes, + mixer.dropped_frames(), + ); +} + +/// Is this the end of the stream, or just a bad moment? +/// +/// A vanished device is unrecoverable here — the descriptor belongs to a `UsbDeviceConnection` +/// that Java must re-open — so the thread exits and the session continues without tier A. Anything +/// else is treated as transient. +#[cfg(target_os = "android")] +fn is_fatal(e: &uac_host::Error) -> bool { + matches!(e, uac_host::Error::Transport(t) if t.is_disconnected()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn speaker_lands_on_the_front_pair_and_haptics_on_the_coils() { + let mut m = QuadMixer::new(); + m.push(PAD_AUDIO_KIND_SPEAKER, &[100, 200]); + m.push(PAD_AUDIO_KIND_HAPTICS, &[300, 400]); + let mut out = Vec::new(); + assert_eq!(m.pop(&mut out), 1); + // Channels 0/1 are the speaker, 2/3 are the voice coils — the pad's own layout. + assert_eq!(out, vec![100, 200, 300, 400]); + } + + #[test] + fn a_haptics_only_session_still_renders_with_a_silent_speaker_pair() { + // The case that matters most: `pad_speaker = "off"` must not stall the coils waiting for + // a kind that will never arrive. + let mut m = QuadMixer::new(); + m.push(PAD_AUDIO_KIND_HAPTICS, &[7, 8, 9, 10]); + let mut out = Vec::new(); + assert_eq!(m.pop(&mut out), 2); + assert_eq!(out, vec![0, 0, 7, 8, 0, 0, 9, 10]); + } + + #[test] + fn the_two_kinds_never_skew_when_the_ceiling_drops_frames() { + let mut m = QuadMixer::new(); + // Push well past the ceiling on one kind, then a marker on the other. Both cursors must + // have moved together, so the marker still lands on the same output frame boundary. + let flood = vec![1i16; (MAX_BUFFER_FRAMES + 500) * 2]; + m.push(PAD_AUDIO_KIND_HAPTICS, &flood); + assert!(m.dropped_frames() > 0); + assert_eq!(m.ready_frames(), MAX_BUFFER_FRAMES); + + m.push(PAD_AUDIO_KIND_SPEAKER, &[42, 43]); + let mut out = Vec::new(); + let frames = m.pop(&mut out); + assert_eq!(frames, MAX_BUFFER_FRAMES); + assert_eq!(out.len(), frames * PAD_CHANNELS); + // The speaker sample went to the FRONT of the ring (its cursor was reset with the drop), + // not to wherever the flooded kind happened to be. + assert_eq!(&out[..4], &[42, 43, 1, 1]); + } + + #[test] + fn interleaving_survives_uneven_cadences() { + // Haptics arrive at 5 ms and the speaker at 10 ms; popping mid-flight must not lose the + // lagging kind's alignment. + let mut m = QuadMixer::new(); + m.push(PAD_AUDIO_KIND_HAPTICS, &[1, 1, 2, 2]); + m.push(PAD_AUDIO_KIND_SPEAKER, &[9, 9]); + let mut out = Vec::new(); + assert_eq!(m.pop(&mut out), 2); + assert_eq!(out, vec![9, 9, 1, 1, 0, 0, 2, 2]); + + // Next round: both cursors are back at zero, so a fresh speaker frame aligns with a fresh + // haptics frame rather than inheriting the previous round's offset. + out.clear(); + m.push(PAD_AUDIO_KIND_SPEAKER, &[5, 5]); + m.push(PAD_AUDIO_KIND_HAPTICS, &[6, 6]); + assert_eq!(m.pop(&mut out), 1); + assert_eq!(out, vec![5, 5, 6, 6]); + } + + #[test] + fn an_unknown_kind_is_dropped_rather_than_rendered_into_the_coils() { + let mut m = QuadMixer::new(); + m.push(9, &[999, 999]); + assert_eq!( + m.ready_frames(), + 0, + "an unknown kind must not occupy a channel pair" + ); + let mut out = Vec::new(); + m.push(PAD_AUDIO_KIND_HAPTICS, &[1, 2]); + assert_eq!(m.pop(&mut out), 1); + assert_eq!(out, vec![0, 0, 1, 2]); + } + + #[test] + fn discard_empties_without_disturbing_alignment() { + let mut m = QuadMixer::new(); + m.push(PAD_AUDIO_KIND_HAPTICS, &[1, 2, 3, 4]); + m.discard(); + assert_eq!(m.ready_frames(), 0); + let mut out = Vec::new(); + m.push(PAD_AUDIO_KIND_SPEAKER, &[8, 9]); + assert_eq!(m.pop(&mut out), 1); + assert_eq!(out, vec![8, 9, 0, 0]); + } + + #[test] + fn plc_stays_silent_until_something_has_decoded() { + let mut g = AudioGapTracker::default(); + // A gap before the first decode has nothing to size concealment from, and must not be + // replayed later as a phantom. + assert_eq!(plc_frames(&mut g, 5, 0), 0); + assert_eq!(plc_frames(&mut g, 6, 480), 0); + } + + #[test] + fn plc_conceals_a_real_gap_once_a_frame_size_is_known() { + let mut g = AudioGapTracker::default(); + assert_eq!(plc_frames(&mut g, 0, 0), 0); + assert_eq!(plc_frames(&mut g, 1, 480), 0); + // Sequence 2 and 3 never arrived. + assert_eq!(plc_frames(&mut g, 4, 480), 2); + } +} diff --git a/clients/android/native/src/session/connect.rs b/clients/android/native/src/session/connect.rs index 8a0ad945..2b65edfb 100644 --- a/clients/android/native/src/session/connect.rs +++ b/clients/android/native/src/session/connect.rs @@ -291,6 +291,8 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeConnect<'lo audio: Mutex::new(None), #[cfg(target_os = "android")] mic: Mutex::new(None), + #[cfg(target_os = "android")] + pad_audio: Mutex::new(None), // A fresh session is never muted (mute is per-session UI state, not a setting). mic_muted: Arc::new(std::sync::atomic::AtomicBool::new(false)), }; diff --git a/clients/android/native/src/session/mod.rs b/clients/android/native/src/session/mod.rs index 59927da8..9875d4a5 100644 --- a/clients/android/native/src/session/mod.rs +++ b/clients/android/native/src/session/mod.rs @@ -61,6 +61,11 @@ pub(crate) struct SessionHandle { audio: Mutex>, #[cfg(target_os = "android")] mic: Mutex>, + /// Tier-A DualSense pad audio (the 0xD1 plane), started by `nativeStartPadAudio` once Kotlin + /// has claimed the pad's audio interface and handed its descriptor over. Session-lifetime and + /// `Option` because a session may have no wired DualSense at all, which is the common case. + #[cfg(target_os = "android")] + pub(crate) pad_audio: Mutex>, /// In-stream mic mute, set via `nativeSetMicMuted` and read per 10 ms frame by the mic's /// encode loop ([`crate::mic`]). Session-lifetime rather than per-[`crate::mic::MicCapture`] /// for the same reason the stats gate is: the mic stops and restarts across a surface @@ -99,6 +104,14 @@ impl SessionHandle { fn stop_mic(&self) { let _ = self.mic.lock().unwrap().take(); } + + /// Stop pad audio. Dropping the [`crate::pad_audio::PadAudio`] joins its render thread, which + /// is what guarantees nothing is still writing to the descriptor when Kotlin closes the + /// `UsbDeviceConnection`. Idempotent. + #[cfg(target_os = "android")] + pub(crate) fn stop_pad_audio(&self) { + let _ = self.pad_audio.lock().unwrap().take(); + } } impl Drop for SessionHandle { @@ -108,6 +121,8 @@ impl Drop for SessionHandle { self.stop_audio(); #[cfg(target_os = "android")] self.stop_mic(); + #[cfg(target_os = "android")] + self.stop_pad_audio(); } } diff --git a/clients/android/native/src/session/planes.rs b/clients/android/native/src/session/planes.rs index 41d27a17..716228de 100644 --- a/clients/android/native/src/session/planes.rs +++ b/clients/android/native/src/session/planes.rs @@ -460,6 +460,72 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeStopMic( }) } +/// `NativeBridge.nativeStartPadAudio(handle, fd, haptics, speaker): Boolean` — start tier-A +/// DualSense pad audio on a descriptor Kotlin has already obtained. +/// +/// `fd` comes from `UsbDeviceConnection.getFileDescriptor()` **after** claiming the pad's audio +/// streaming interface. Kotlin owns that connection and **must keep it open until +/// `nativeStopPadAudio` returns**: the renderer borrows the descriptor and never closes it, so +/// closing early would pull it out from under an in-flight isochronous transfer. +/// +/// Returns `false` when there is nothing to render (both kinds disabled) or the thread would not +/// start. A kernel that refuses the interface claim is NOT reported here — the renderer discovers +/// that on its own thread and degrades to tier C, because some OEM kernels refuse and there is no +/// app-side fix worth blocking a session on. +#[no_mangle] +#[cfg(target_os = "android")] +pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeStartPadAudio( + _env: JNIEnv, + _this: JObject, + handle: jlong, + fd: jni::sys::jint, + haptics: jboolean, + speaker: jboolean, +) -> jboolean { + jni_guard(0, || { + if handle == 0 || fd < 0 { + return 0; + } + // SAFETY: live handle per the nativeConnect/nativeClose contract. + let h = unsafe { &*(handle as *const SessionHandle) }; + // Replace any previous renderer first: dropping it joins the old thread, so two of them + // can never hold the same descriptor at once. + h.stop_pad_audio(); + match crate::pad_audio::start( + std::sync::Arc::clone(&h.client), + fd, + haptics != 0, + speaker != 0, + ) { + Some(p) => { + *h.pad_audio.lock().unwrap() = Some(p); + 1 + } + None => 0, + } + }) +} + +/// `NativeBridge.nativeStopPadAudio(handle)` — stop tier-A pad audio and join its thread. +/// +/// Returns only once the render thread is joined, which is the point: Kotlin may close the +/// `UsbDeviceConnection` as soon as this returns and not before. +#[no_mangle] +#[cfg(target_os = "android")] +pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeStopPadAudio( + _env: JNIEnv, + _this: JObject, + handle: jlong, +) { + jni_guard((), || { + if handle != 0 { + // SAFETY: live handle per the nativeConnect/nativeClose contract. + let h = unsafe { &*(handle as *const SessionHandle) }; + h.stop_pad_audio(); + } + }) +} + /// `NativeBridge.nativeSetMicMuted(handle, muted)` — mute/unmute the mic uplink mid-stream. /// /// Muting deliberately does NOT stop the capture: the AAudio input stream, the input-preset rung From a10bde39bbcdb23be47f25fdae3b868fdecd6f4a Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Sun, 2 Aug 2026 23:44:51 +0200 Subject: [PATCH 03/21] feat(android): declare pad-audio caps and take tier-A pads off wire rumble MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The two things that decide whether WP9 does anything at all on a device, both failing silently rather than loudly if missed. **Capability bits.** The host emits 0xD1 only toward pads that declared they can render it (arrival flags 8/9). Without `set_pad_audio_caps` the renderer would sit on a permanently empty plane and look like a decode bug. Declared when the stream opens, withdrawn when it stops. **Rumble arbitration.** `valid_flag0` bit 1 (HAPTICS_SELECT) *disables* audio haptics and selects classic rumble, and `DsDevice` sets it on every rumble write — as Linux's hid-playstation and SDL both do. One replayed rumble command would mute the voice coils the 0xD1 stream is driving, for the rest of the session. Tier A and tier C are mutually exclusive in the pad's firmware, so the arbitration selects and never blends. Suppression sits at `nativeNextRumble`, the pull point, rather than in Kotlin: it keeps the rule next to the reason and covers every caller. The registry is an atomic bitmask because the reader is the rumble poll thread and must not block behind a start/stop on the JNI thread. Order matters on teardown: the capability is withdrawn before the pad returns to wire rumble, so the host has stopped sending 0xD1 before tier C resumes and the two never overlap. `nativeStartPadAudio`/`nativeStopPadAudio` now take the wire pad index, since both the capability and the arbitration are per-pad. Out-of-range indices are rejected rather than wrapped into another pad's slot. 12 host tests (2 new, including one pinning that an out-of-range index cannot shift the mask into undefined territory), 0 clippy findings, check clean on all three Android ABIs. --- clients/android/native/src/feedback.rs | 6 ++ clients/android/native/src/pad_audio.rs | 58 ++++++++++++++++++++ clients/android/native/src/session/planes.rs | 23 +++++++- 3 files changed, 84 insertions(+), 3 deletions(-) diff --git a/clients/android/native/src/feedback.rs b/clients/android/native/src/feedback.rs index 4e4225be..3ea7adf8 100644 --- a/clients/android/native/src/feedback.rs +++ b/clients/android/native/src/feedback.rs @@ -54,6 +54,12 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeNextRumble( // handle. let h = unsafe { &*(handle as *const SessionHandle) }; match h.client.next_rumble_command(PULL_TIMEOUT) { + // A pad rendering tier-A audio must never see wire rumble. `DsDevice` sets + // `valid_flag0` bit 1 (`HAPTICS_SELECT`) on every rumble write, and that bit + // *disables* audio haptics — so one replayed command would silently mute the voice + // coils the 0xD1 stream is driving, for the rest of the session. Dropping it here + // (rather than in Kotlin) keeps the rule next to the reason, and covers every caller. + Ok(cmd) if crate::pad_audio::is_tier_a((cmd.pad & 0xF) as u8) => -1, Ok(cmd) => { (jlong::from(cmd.pad & 0xF) << 49) | (jlong::from(cmd.backstop_ms.min(0xFFFF) as u16) << 32) diff --git a/clients/android/native/src/pad_audio.rs b/clients/android/native/src/pad_audio.rs index 03f52b9c..009e8597 100644 --- a/clients/android/native/src/pad_audio.rs +++ b/clients/android/native/src/pad_audio.rs @@ -71,6 +71,38 @@ const MAX_FRAME_SAMPLES: usize = 5760; #[cfg_attr(not(target_os = "android"), allow(dead_code))] const IN_FLIGHT_MS: u32 = 6; +// ---- tier-A registry --------------------------------------------------------------------------- + +/// Which wire pad indices are currently rendering tier-A audio, as a bitmask over the 16 wire +/// slots. +/// +/// Read on the rumble poll thread and written on the JNI thread, so it is an atomic rather than a +/// lock: the reader is on a latency path and must never block behind a start/stop. +static TIER_A_PADS: std::sync::atomic::AtomicU32 = std::sync::atomic::AtomicU32::new(0); + +/// Mark (or clear) a pad as rendering tier-A audio. +#[cfg_attr(not(target_os = "android"), allow(dead_code))] +pub(crate) fn set_tier_a(pad: u8, on: bool) { + use std::sync::atomic::Ordering; + let bit = 1u32 << (pad & 0x0f); + if on { + TIER_A_PADS.fetch_or(bit, Ordering::Relaxed); + } else { + TIER_A_PADS.fetch_and(!bit, Ordering::Relaxed); + } +} + +/// Is this pad rendering tier-A audio, and therefore forbidden from receiving wire rumble? +/// +/// **This is a firmware constraint, not a preference.** `valid_flag0` bit 1 (`HAPTICS_SELECT`) +/// *disables* audio haptics and selects classic rumble, and `DsDevice` sets it on every rumble +/// write — as Linux's `hid-playstation` and SDL both do. So a single rumble command reaching a +/// tier-A pad silently mutes the voice coils this stream drives, for the rest of the session. +/// Tier A and tier C are mutually exclusive **in the pad**: the arbitration selects, never blends. +pub(crate) fn is_tier_a(pad: u8) -> bool { + TIER_A_PADS.load(std::sync::atomic::Ordering::Relaxed) & (1u32 << (pad & 0x0f)) != 0 +} + // ---- the 4-channel mixer --------------------------------------------------------------------- /// Interleave the two independent stereo streams into one 4-channel frame stream. @@ -560,6 +592,32 @@ mod tests { assert_eq!(out, vec![0, 0, 1, 2]); } + #[test] + fn tier_a_registry_tracks_pads_independently() { + // A rumble command reaching a tier-A pad mutes its coils for the session, so this gate + // has to be exact rather than approximately right. + set_tier_a(3, true); + assert!(is_tier_a(3)); + assert!(!is_tier_a(4)); + set_tier_a(4, true); + assert!(is_tier_a(3) && is_tier_a(4)); + set_tier_a(3, false); + assert!(!is_tier_a(3), "clearing one pad must not clear another"); + assert!(is_tier_a(4)); + set_tier_a(4, false); + assert!(!is_tier_a(4)); + } + + #[test] + fn tier_a_registry_wraps_the_pad_index_into_the_wire_slot_space() { + // The wire pad space is 4 bits; an out-of-range index must not shift the mask into + // undefined territory (a shift >= 32 is a panic in debug and garbage in release). + set_tier_a(0x1f, true); + assert!(is_tier_a(0x0f), "0x1f and 0x0f are the same wire slot"); + set_tier_a(0x0f, false); + assert!(!is_tier_a(0x1f)); + } + #[test] fn discard_empties_without_disturbing_alignment() { let mut m = QuadMixer::new(); diff --git a/clients/android/native/src/session/planes.rs b/clients/android/native/src/session/planes.rs index 716228de..54594da1 100644 --- a/clients/android/native/src/session/planes.rs +++ b/clients/android/native/src/session/planes.rs @@ -460,7 +460,7 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeStopMic( }) } -/// `NativeBridge.nativeStartPadAudio(handle, fd, haptics, speaker): Boolean` — start tier-A +/// `NativeBridge.nativeStartPadAudio(handle, pad, fd, haptics, speaker): Boolean` — start tier-A /// DualSense pad audio on a descriptor Kotlin has already obtained. /// /// `fd` comes from `UsbDeviceConnection.getFileDescriptor()` **after** claiming the pad's audio @@ -478,12 +478,13 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeStartPadAud _env: JNIEnv, _this: JObject, handle: jlong, + pad: jni::sys::jint, fd: jni::sys::jint, haptics: jboolean, speaker: jboolean, ) -> jboolean { jni_guard(0, || { - if handle == 0 || fd < 0 { + if handle == 0 || fd < 0 || !(0..16).contains(&pad) { return 0; } // SAFETY: live handle per the nativeConnect/nativeClose contract. @@ -499,6 +500,15 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeStartPadAud ) { Some(p) => { *h.pad_audio.lock().unwrap() = Some(p); + // Declare what this pad can render. Without these bits the host never emits 0xD1 + // for it at all, so the renderer would sit on an empty plane forever — the bits + // ride the gamepad arrival (flags 8/9) toward a HOST_CAP_PAD_AUDIO host. + let caps = + (if haptics != 0 { 0x01 } else { 0 }) | (if speaker != 0 { 0x02 } else { 0 }); + h.client.set_pad_audio_caps(pad as u8, caps); + // And take this pad off wire rumble: tier A and tier C are mutually exclusive in + // the pad's firmware (see `pad_audio::is_tier_a`). + crate::pad_audio::set_tier_a(pad as u8, true); 1 } None => 0, @@ -506,7 +516,7 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeStartPadAud }) } -/// `NativeBridge.nativeStopPadAudio(handle)` — stop tier-A pad audio and join its thread. +/// `NativeBridge.nativeStopPadAudio(handle, pad)` — stop tier-A pad audio and join its thread. /// /// Returns only once the render thread is joined, which is the point: Kotlin may close the /// `UsbDeviceConnection` as soon as this returns and not before. @@ -516,12 +526,19 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeStopPadAudi _env: JNIEnv, _this: JObject, handle: jlong, + pad: jni::sys::jint, ) { jni_guard((), || { if handle != 0 { // SAFETY: live handle per the nativeConnect/nativeClose contract. let h = unsafe { &*(handle as *const SessionHandle) }; h.stop_pad_audio(); + if (0..16).contains(&pad) { + // Withdraw the capability and hand the pad back to wire rumble, in that order: + // the host stops sending 0xD1 before tier C resumes, so the two never overlap. + h.client.set_pad_audio_caps(pad as u8, 0); + crate::pad_audio::set_tier_a(pad as u8, false); + } } }) } From e8499e6131c190395daeb70d43a42ff44c693d9b Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Sun, 2 Aug 2026 23:51:28 +0200 Subject: [PATCH 04/21] feat(android): wire tier-A pad audio through the capture lifecycle and settings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Kotlin half. Turns out Android needs to claim nothing extra: `uac-host` claims the pad's audio interface itself through usbfs on the fd, and usbfs claims are per interface, so the HID claim `HidUsbLink` already holds is untouched. The link therefore surrenders only its file descriptor. Two orderings carry the whole design, and both are easy to get wrong: - **Start on the first report, not at claim time.** The wire pad index does not exist until the router opens a slot, and the host addresses the 0xD1 stream by that index — starting earlier would declare capabilities for a pad that has no index yet. - **Stop before the link closes.** `usb.stop()` closes the connection whose descriptor the render thread borrows, so `padAudio.stop()` runs first, at the top of `DsCapture.stop()`. `nativeStopPadAudio` does not return until the thread is joined, which is what makes the borrow sound rather than merely usually-fine. `DsCapture` decides WHEN (it owns the wire index and the link lifetime); `StreamScreen` decides WHETHER (it owns the session handle and the settings). The capture stays ignorant of sessions. Settings: `padHaptics` defaults on — it is the whole point, and this client's rumble already drives the same actuators, so tier A is a strict improvement. `padSpeaker` defaults OFF: it is a small loudspeaker in the user's hands playing audio they can already hear, and surprising someone with that is worse than making them opt in. Verified: APK builds, and both JNI entry points are exported in the shipped arm64 .so — a missing one would be an UnsatisfiedLinkError only at runtime. 12 Rust tests, 0 clippy findings, fmt clean. --- .../main/kotlin/io/unom/punktfunk/Settings.kt | 26 ++++++++++++++ .../kotlin/io/unom/punktfunk/StreamScreen.kt | 22 ++++++++++++ .../kotlin/io/unom/punktfunk/kit/DsCapture.kt | 35 +++++++++++++++++++ .../io/unom/punktfunk/kit/HidUsbLink.kt | 14 ++++++++ .../io/unom/punktfunk/kit/NativeBridge.kt | 31 ++++++++++++++++ 5 files changed, 128 insertions(+) diff --git a/clients/android/app/src/main/kotlin/io/unom/punktfunk/Settings.kt b/clients/android/app/src/main/kotlin/io/unom/punktfunk/Settings.kt index 98ea20dc..08f7edbf 100644 --- a/clients/android/app/src/main/kotlin/io/unom/punktfunk/Settings.kt +++ b/clients/android/app/src/main/kotlin/io/unom/punktfunk/Settings.kt @@ -145,6 +145,26 @@ data class Settings( */ val dsCapture: Boolean = true, + /** + * Render the host's DualSense **voice-coil haptics** on a captured USB pad (tier A). + * + * The pad's own 4-channel audio device carries them, driven directly over usbfs — Android's + * audio framework denylists that device by VID/PID, so there is no supported route to it. When + * this is on and the pad is captured, wire rumble for that pad is SUPPRESSED rather than mixed: + * the DualSense's firmware treats audio haptics and classic rumble as mutually exclusive, so + * the arbitration is a selection. Off, or on an uncaptured/Bluetooth pad, the pad stays on + * ordinary rumble (tier C), which on this client already drives the same actuators. + */ + val padHaptics: Boolean = true, + + /** + * Render the pad's **built-in speaker** on a captured USB pad. Independent of [padHaptics] — + * the host sends the two as separate streams and either can play alone. Off by default: the + * speaker is a small, easily-startling loudspeaker in the user's hands, and unlike haptics it + * duplicates audio they are already hearing. + */ + val padSpeaker: Boolean = false, + /** * How a physical mouse drives the host — the cross-client mouse model (see [MouseMode]). * [MouseMode.DESKTOP] (default here) points absolutely; [MouseMode.CAPTURE] locks the pointer @@ -243,6 +263,8 @@ class SettingsStore(context: Context) { rumbleOnPhone = prefs.getBoolean(K_RUMBLE_ON_PHONE, false), sc2Capture = prefs.getBoolean(K_SC2_CAPTURE, true), dsCapture = prefs.getBoolean(K_DS_CAPTURE, true), + padHaptics = prefs.getBoolean(K_PAD_HAPTICS, true), + padSpeaker = prefs.getBoolean(K_PAD_SPEAKER, false), mouseMode = prefs.getString(K_MOUSE_MODE, null) ?.let { name -> MouseMode.entries.firstOrNull { it.storedName == name } } // Migration: the pre-enum Boolean "pointer_capture" (true = lock the pointer). Its @@ -277,6 +299,8 @@ class SettingsStore(context: Context) { .putBoolean(K_RUMBLE_ON_PHONE, s.rumbleOnPhone) .putBoolean(K_SC2_CAPTURE, s.sc2Capture) .putBoolean(K_DS_CAPTURE, s.dsCapture) + .putBoolean(K_PAD_HAPTICS, s.padHaptics) + .putBoolean(K_PAD_SPEAKER, s.padSpeaker) .putString(K_MOUSE_MODE, s.mouseMode.storedName) .putBoolean(K_INVERT_SCROLL, s.invertScroll) .apply() @@ -321,6 +345,8 @@ class SettingsStore(context: Context) { const val K_RUMBLE_ON_PHONE = "rumble_on_phone" const val K_SC2_CAPTURE = "sc2_capture" const val K_DS_CAPTURE = "ds_capture" + const val K_PAD_HAPTICS = "pad_haptics" + const val K_PAD_SPEAKER = "pad_speaker" const val K_MOUSE_MODE = "mouse_mode" /** Legacy Boolean the [K_MOUSE_MODE] enum replaced — read once for migration, never written. */ diff --git a/clients/android/app/src/main/kotlin/io/unom/punktfunk/StreamScreen.kt b/clients/android/app/src/main/kotlin/io/unom/punktfunk/StreamScreen.kt index 43e00947..e0147f84 100644 --- a/clients/android/app/src/main/kotlin/io/unom/punktfunk/StreamScreen.kt +++ b/clients/android/app/src/main/kotlin/io/unom/punktfunk/StreamScreen.kt @@ -496,6 +496,28 @@ fun StreamScreen(session: ActiveSession, onDisconnect: () -> Unit) { var dsUsbReceiver: BroadcastReceiver? = null if (ds != null) { feedback.sink = ds + // Tier-A pad audio: render the host's 0xD1 streams on the pad's own 4-channel USB + // audio device. Bound here rather than inside DsCapture because the session handle + // lives at this layer; DsCapture decides WHEN (it knows the wire index and the link + // lifetime), this decides WHETHER. + if (initialSettings.padHaptics || initialSettings.padSpeaker) { + ds.padAudio = object : DsCapture.PadAudioHook { + override fun start(pad: Int, fd: Int) { + val ok = NativeBridge.nativeStartPadAudio( + handle, + pad, + fd, + initialSettings.padHaptics, + initialSettings.padSpeaker, + ) + Log.i("punktfunk", "pad audio on pad $pad: ${if (ok) "started" else "unavailable"}") + } + + // Returns only once the render thread is joined — DsCapture calls this before + // closing the connection whose descriptor that thread borrows. + override fun stop(pad: Int) = NativeBridge.nativeStopPadAudio(handle, pad) + } + } val usbManager = context.getSystemService(Context.USB_SERVICE) as UsbManager val usbDev = ds.findUsbDevice() when { diff --git a/clients/android/kit/src/main/kotlin/io/unom/punktfunk/kit/DsCapture.kt b/clients/android/kit/src/main/kotlin/io/unom/punktfunk/kit/DsCapture.kt index 47c2eaa3..17d217ec 100644 --- a/clients/android/kit/src/main/kotlin/io/unom/punktfunk/kit/DsCapture.kt +++ b/clients/android/kit/src/main/kotlin/io/unom/punktfunk/kit/DsCapture.kt @@ -78,6 +78,25 @@ class DsCapture( @Volatile var onActiveChanged: ((active: Boolean) -> Unit)? = null + /** + * Tier-A pad audio, bound by the app layer (which owns the session handle). + * + * [start] is called once the router has assigned this pad a wire index — not at claim time, + * because the index does not exist until the first report arrives and the host addresses the + * `0xD1` stream by that index. [stop] is called **before** the USB link closes, and must not + * return until nothing is still writing to the descriptor. + */ + interface PadAudioHook { + fun start(pad: Int, fd: Int) + fun stop(pad: Int) + } + + @Volatile + var padAudio: PadAudioHook? = null + + /** True once [PadAudioHook.start] has run for the current capture, so it fires exactly once. */ + @Volatile private var padAudioStarted = false + val isActive: Boolean get() = model != null /** First attached Sony USB pad, for the permission flow. Needs no permission to enumerate. */ @@ -111,6 +130,13 @@ class DsCapture( /** Stop the link and free the wire slot (host tears the virtual pad down). Idempotent. */ fun stop() { + // Before anything touches the link: the pad-audio renderer borrows this connection's + // descriptor, and `usb.stop()` closes it. The hook does not return until its thread is + // joined, so ordering this first is what makes the borrow sound. + if (padAudioStarted) { + padAudioStarted = false + pad?.let { padAudio?.stop(it.index) } + } val m = model if (m != null) { // The interfaces are about to release with the kernel driver still detached — a @@ -133,6 +159,15 @@ class DsCapture( if (!DsDevice.parseState(m, report, len, state)) return val p = pad ?: router.openExternal(m.pref)?.also { pad = it + // The wire index exists from here on, and the host addresses pad audio by it. Fired on + // the link thread, once per capture. + if (!padAudioStarted) { + val fd = usb.fileDescriptor + if (fd >= 0) { + padAudioStarted = true + padAudio?.start(it.index, fd) + } + } Log.i(TAG, "captured $m → wire pad ${it.index}") } ?: return // all 16 wire indices taken — drop until one frees mirrorTyped(p) diff --git a/clients/android/kit/src/main/kotlin/io/unom/punktfunk/kit/HidUsbLink.kt b/clients/android/kit/src/main/kotlin/io/unom/punktfunk/kit/HidUsbLink.kt index 5a6b96e5..45ad01e8 100644 --- a/clients/android/kit/src/main/kotlin/io/unom/punktfunk/kit/HidUsbLink.kt +++ b/clients/android/kit/src/main/kotlin/io/unom/punktfunk/kit/HidUsbLink.kt @@ -92,6 +92,20 @@ class HidUsbLink( /** First attached matching device, or null. Does not need USB permission to enumerate. */ fun findDevice(): UsbDevice? = usb.deviceList.values.firstOrNull(config.deviceMatch) + /** + * The open connection's usbfs file descriptor, or -1 when the link is not running. + * + * Handed to native code that drives interfaces this link deliberately does NOT claim — the + * pad's isochronous audio endpoint (see `pad_audio` on the native side), which Android's own + * USB API cannot reach because `UsbRequest` rejects anything that is not bulk or interrupt. + * usbfs claims are per interface, so a native claim of the audio interface leaves this link's + * HID claim untouched. + * + * **The borrower must stop using it before [stop] runs**: closing the connection while a + * transfer is in flight pulls the descriptor out from under the kernel. + */ + val fileDescriptor: Int get() = connection?.fileDescriptor ?: -1 + /** * Claim [dev]'s controller interface(s) and start the read loop. The caller has already * obtained USB permission. Returns false when nothing could be claimed. diff --git a/clients/android/kit/src/main/kotlin/io/unom/punktfunk/kit/NativeBridge.kt b/clients/android/kit/src/main/kotlin/io/unom/punktfunk/kit/NativeBridge.kt index eb2b1b6d..909ae473 100644 --- a/clients/android/kit/src/main/kotlin/io/unom/punktfunk/kit/NativeBridge.kt +++ b/clients/android/kit/src/main/kotlin/io/unom/punktfunk/kit/NativeBridge.kt @@ -332,6 +332,37 @@ object NativeBridge { */ external fun nativeSetMicMuted(handle: Long, muted: Boolean) + /** + * Start tier-A DualSense pad audio: render the host's `0xD1` streams on the pad's own + * 4-channel USB audio device. + * + * [fd] is an open [android.hardware.usb.UsbDeviceConnection]'s file descriptor. Native code + * **borrows** it — it claims the pad's audio interface through usbfs (which leaves any HID + * claim on the same device alone) and never closes the descriptor. The caller must keep the + * connection open until [nativeStopPadAudio] returns. + * + * This also declares the pad's render capability to the host; without it no `0xD1` is sent. + * + * Returns false when there is nothing to render. A kernel that refuses the interface claim is + * NOT reported here — the renderer discovers that on its own thread and the session simply + * carries on without tier A, because some OEM kernels refuse and no app-side fix exists. + */ + external fun nativeStartPadAudio( + handle: Long, + pad: Int, + fd: Int, + haptics: Boolean, + speaker: Boolean, + ): Boolean + + /** + * Stop tier-A pad audio and join its render thread, and hand the pad back to wire rumble. + * + * Returns only once the thread is joined — so the `UsbDeviceConnection` may be closed as soon + * as this returns, and not before. + */ + external fun nativeStopPadAudio(handle: Long, pad: Int) + /** * Is a mic capture actually RUNNING — i.e. did [nativeStartMic] open a stream, and has * [nativeStopMic] not been called since? Offer the in-stream mute control on THIS rather than From 8ee224e5db82bd61a75b2c89ce80c11e556bd4eb Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Mon, 3 Aug 2026 00:04:14 +0200 Subject: [PATCH 05/21] fix(android): advertise CLIENT_CAP_PAD_AUDIO, without which nothing is ever sent MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A gap in the previous commits, and the same silent-failure shape as the two they fixed. There are TWO negotiations, not one: the per-pad render capabilities that ride a gamepad arrival (bits 8/9), which those commits set, and the SESSION-level CLIENT_CAP_PAD_AUDIO in the Hello, which they did not. Without the latter the host never sets HOST_CAP_PAD_AUDIO and emits no 0xD1 at all — so the per-pad bits would have had nothing to gate, and the renderer would have sat on a permanently empty plane with every other piece looking correct. Threaded as an explicit `padAudioOk` on nativeConnect rather than advertised unconditionally: the cap makes a Windows host provision pad endpoints at startup, and a user who has pad audio switched off should not pay for that. Found by tracing what an on-glass run against a real host would actually need, not by a test — there is no test that could have caught it, since both halves are individually well-formed. --- .../src/main/kotlin/io/unom/punktfunk/HostConnect.kt | 3 +++ .../kotlin/io/unom/punktfunk/kit/NativeBridge.kt | 4 ++++ clients/android/native/src/session/connect.rs | 12 +++++++++++- 3 files changed, 18 insertions(+), 1 deletion(-) diff --git a/clients/android/app/src/main/kotlin/io/unom/punktfunk/HostConnect.kt b/clients/android/app/src/main/kotlin/io/unom/punktfunk/HostConnect.kt index 100a8e27..fb5985be 100644 --- a/clients/android/app/src/main/kotlin/io/unom/punktfunk/HostConnect.kt +++ b/clients/android/app/src/main/kotlin/io/unom/punktfunk/HostConnect.kt @@ -84,6 +84,9 @@ suspend fun connectToHost( // The host's approval-list / trust-store label for this device — the same // Build.MODEL convention the pairing dialogs use for nativePair. Build.MODEL ?: "Android", + // Tier-A pad audio: ask for the 0xD1 plane only when a setting would render it, so a + // user with it off does not make the host provision endpoints it will never feed. + settings.padHaptics || settings.padSpeaker, ) } } diff --git a/clients/android/kit/src/main/kotlin/io/unom/punktfunk/kit/NativeBridge.kt b/clients/android/kit/src/main/kotlin/io/unom/punktfunk/kit/NativeBridge.kt index 909ae473..1caf8e83 100644 --- a/clients/android/kit/src/main/kotlin/io/unom/punktfunk/kit/NativeBridge.kt +++ b/clients/android/kit/src/main/kotlin/io/unom/punktfunk/kit/NativeBridge.kt @@ -69,6 +69,10 @@ object NativeBridge { * list and trust store show for it, same convention as [nativePair]'s `name`. `null`/blank ⇒ * the host falls back to a fingerprint-derived "device abcd1234" label. */ deviceName: String?, + /** Advertise `CLIENT_CAP_PAD_AUDIO` — the SESSION-level negotiation for the 0xD1 per-pad + * DualSense plane. Without it the host never sets `HOST_CAP_PAD_AUDIO` and emits nothing, + * so a captured pad's own render capabilities would have nothing to gate. */ + padAudioOk: Boolean, ): Long /** 64-hex SHA-256 of the cert the host presented on [handle]; valid after a successful connect. */ diff --git a/clients/android/native/src/session/connect.rs b/clients/android/native/src/session/connect.rs index 2b65edfb..982400f7 100644 --- a/clients/android/native/src/session/connect.rs +++ b/clients/android/native/src/session/connect.rs @@ -145,6 +145,7 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeConnect<'lo timeout_ms: jint, launch: JString<'local>, device_name: JString<'local>, + pad_audio_ok: jboolean, ) -> jlong { let host: String = match env.get_string(&host) { Ok(s) => s.into(), @@ -268,7 +269,16 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeConnect<'lo // CLIENT_CAP_PHASE_LOCK is honest: the async decode loop's presenter feeds // report_phase (advisory in v1 — the host arms on report receipt — but the Hello // should say what the client does). - punktfunk_core::quic::CLIENT_CAP_PHASE_LOCK, + // CLIENT_CAP_PAD_AUDIO is the SESSION-level negotiation, separate from the per-pad + // arrival bits: without it the host never sets HOST_CAP_PAD_AUDIO and never emits 0xD1, + // so declaring a pad's render caps later would have nothing to gate. Gated on the + // settings so a user with pad audio off does not make the host provision endpoints. + punktfunk_core::quic::CLIENT_CAP_PHASE_LOCK + | if pad_audio_ok != 0 { + punktfunk_core::quic::CLIENT_CAP_PAD_AUDIO + } else { + 0 + }, // Slice-progressive delivery, by decoder truth (Kotlin probes FEATURE_PartialFrame on // every decoder this device would use; `debug.punktfunk.force_parts` overrides for the // on-glass experiment): AU prefixes then arrive as `Frame::part` pieces and the decode From 2f1ef441914875adc13a39e4f2109c82edc00d59 Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Mon, 3 Aug 2026 00:34:47 +0200 Subject: [PATCH 06/21] fix(android): commit the tier-A trade only once the USB stream actually opens MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A real bug, and the worst shape one can take here: it costs the user ALL haptics rather than degrading. `pad_audio::start` returned success as soon as the render thread spawned, and `nativeStartPadAudio` then declared the pad's render capability and took it off wire rumble. But `sink::open` runs later, on that thread. On a kernel that refuses the interface claim — the OEM case documented as needing a clean tier-C fallback — the pad was already suppressed and the host already streaming 0xD1 at a renderer that never opened. No pad audio, and no rumble either. The declaration and the suppression now happen inside the renderer, immediately after a successful open, and are both withdrawn when it stops. A failed open declares nothing and suppresses nothing, so the session stays on ordinary rumble — which is what "degrades to tier C" was always supposed to mean. `PadAudio`'s Drop clears the tier-A bit too, so a thread that dies unexpectedly cannot leave a pad permanently mute. The general rule this violated: never give up a working fallback until the thing replacing it is known to work. Spawning a thread is not evidence that it will. --- clients/android/native/src/pad_audio.rs | 38 +++++++++++++++----- clients/android/native/src/session/planes.rs | 14 +++----- 2 files changed, 34 insertions(+), 18 deletions(-) diff --git a/clients/android/native/src/pad_audio.rs b/clients/android/native/src/pad_audio.rs index 009e8597..49978eca 100644 --- a/clients/android/native/src/pad_audio.rs +++ b/clients/android/native/src/pad_audio.rs @@ -289,6 +289,7 @@ mod sink { /// about to close. #[cfg(target_os = "android")] pub(crate) struct PadAudio { + pad: u8, stop: Arc, join: Option>, } @@ -300,6 +301,9 @@ impl Drop for PadAudio { if let Some(j) = self.join.take() { let _ = j.join(); } + // Belt and braces: the thread clears these itself on the way out, but if it died in a way + // that skipped that, leaving the pad off wire rumble would cost the user all feedback. + set_tier_a(self.pad, false); } } @@ -311,6 +315,7 @@ impl Drop for PadAudio { #[cfg(target_os = "android")] pub(crate) fn start( client: Arc, + pad: u8, fd: i32, haptics: bool, speaker: bool, @@ -319,8 +324,9 @@ pub(crate) fn start( return None; } let stop = Arc::new(AtomicBool::new(false)); - let join = spawn(client, Arc::clone(&stop), fd, haptics, speaker)?; + let join = spawn(client, Arc::clone(&stop), pad, fd, haptics, speaker)?; Some(PadAudio { + pad, stop, join: Some(join), }) @@ -335,19 +341,20 @@ pub(crate) fn start( pub(crate) fn spawn( client: Arc, stop: Arc, + pad: u8, fd: i32, haptics: bool, speaker: bool, ) -> Option> { std::thread::Builder::new() .name("pf-pad-audio".into()) - .spawn(move || run(&client, &stop, fd, haptics, speaker)) + .spawn(move || run(&client, &stop, pad, fd, haptics, speaker)) .map_err(|e| log::warn!("pad-audio thread failed to start: {e}")) .ok() } #[cfg(target_os = "android")] -fn run(client: &NativeClient, stop: &AtomicBool, fd: i32, haptics: bool, speaker: bool) { +fn run(client: &NativeClient, stop: &AtomicBool, pad: u8, fd: i32, haptics: bool, speaker: bool) { // Ask the scheduler for audio priority. Android does not hand SCHED_FIFO to ordinary app // threads, so -16 (ANDROID_PRIORITY_AUDIO) is the realistic knob — and WP7 measured that it // both applies and is enough to hold the 4 ms floor against eight busy cores. @@ -360,7 +367,7 @@ fn run(client: &NativeClient, stop: &AtomicBool, fd: i32, haptics: bool, speaker let dev = unsafe { sink::device(fd) }; // Through a reference, deliberately: `UsbFsDevice` has a `Drop`, and opening the stream in // this same scope would make the borrow outlive the value it borrows. - render(&dev, client, stop, haptics, speaker); + render(&dev, client, stop, pad, haptics, speaker); } /// Open the pad's stream and render on it until the session stops or the device goes away. @@ -369,25 +376,38 @@ fn render( dev: &usbfs_iso::UsbFsDevice, client: &NativeClient, stop: &AtomicBool, + pad: u8, haptics: bool, speaker: bool, ) { match sink::open(dev) { Ok(mut playback) => { log::info!( - "pad audio: {} ch {} at {} Hz, {} us in flight", + "pad audio: pad={pad} {} ch {} at {} Hz, {} us in flight", playback.channels(), playback.format(), playback.rate(), playback.schedule().in_flight_us() ); + // ONLY NOW commit the trade. Declaring the pad's render capability makes the host + // emit 0xD1, and taking the pad off wire rumble is what makes tier A and tier C + // mutually exclusive — doing either before the stream is known to open would, on a + // kernel that refuses the claim, leave the user with no haptics of any kind. + let caps = (if haptics { 0x01 } else { 0 }) | (if speaker { 0x02 } else { 0 }); + client.set_pad_audio_caps(pad, caps); + set_tier_a(pad, true); + pump(client, stop, haptics, speaker, &mut playback); + + // Give the pad back to wire rumble before this thread goes away. + client.set_pad_audio_caps(pad, 0); + set_tier_a(pad, false); } Err(e) => { - // The interesting failure is a kernel that refuses the claim: some OEM kernels do, and - // there is no app-side fix, so the session carries on without tier A rather than - // treating it as fatal. - log::warn!("pad audio unavailable, falling back: {e}"); + // A kernel that refuses the claim: some OEM kernels do, and there is no app-side fix. + // Nothing was declared and nothing was suppressed, so the session simply carries on + // at tier C with ordinary rumble — a clean degrade rather than silent total loss. + log::warn!("pad audio unavailable on pad {pad}, staying on rumble: {e}"); drain_until_stop(client, stop); } } diff --git a/clients/android/native/src/session/planes.rs b/clients/android/native/src/session/planes.rs index 54594da1..059c2416 100644 --- a/clients/android/native/src/session/planes.rs +++ b/clients/android/native/src/session/planes.rs @@ -492,23 +492,19 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeStartPadAud // Replace any previous renderer first: dropping it joins the old thread, so two of them // can never hold the same descriptor at once. h.stop_pad_audio(); + // The capability declaration and the rumble suppression are NOT done here: the renderer + // makes both only once its USB stream actually opens (see `pad_audio::render`). Doing them + // at spawn time would, on a kernel that refuses the interface claim, take the pad off wire + // rumble and give it nothing in return — no haptics of any kind. match crate::pad_audio::start( std::sync::Arc::clone(&h.client), + pad as u8, fd, haptics != 0, speaker != 0, ) { Some(p) => { *h.pad_audio.lock().unwrap() = Some(p); - // Declare what this pad can render. Without these bits the host never emits 0xD1 - // for it at all, so the renderer would sit on an empty plane forever — the bits - // ride the gamepad arrival (flags 8/9) toward a HOST_CAP_PAD_AUDIO host. - let caps = - (if haptics != 0 { 0x01 } else { 0 }) | (if speaker != 0 { 0x02 } else { 0 }); - h.client.set_pad_audio_caps(pad as u8, caps); - // And take this pad off wire rumble: tier A and tier C are mutually exclusive in - // the pad's firmware (see `pad_audio::is_tier_a`). - crate::pad_audio::set_tier_a(pad as u8, true); 1 } None => 0, From e32bd30c858eea72f7e2fe6fefdcb762a794b3ed Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Mon, 3 Aug 2026 00:38:58 +0200 Subject: [PATCH 07/21] fix(android): give the renderer its own USB connection, and add a real-world self test MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit **The bug.** The renderer was handed `HidUsbLink`'s file descriptor. That link's own comment states the hazard exactly — "only one thread may drive a connection's UsbRequests (requestWait() returns ANY completed request; a second waiter would steal the reader's completions)" — and it is just as true of the usbfs reap underneath: the isochronous ring and the HID reader were reaping each other's URB completions. The standalone harness works because it owns its descriptor by construction, which is precisely why it could never have caught this. `DsCapture` now opens a dedicated connection via `openAuxConnection()` and closes it only after the render thread is joined. **The test.** Nothing exercised the CLIENT path without a host, so the two things most likely to be wrong were invisible: whether the descriptor handed over is exclusively ours, and whether the claim succeeds on this kernel. Neither is unit-testable and a harness proves neither. `nativePadAudioSelfTest` drives the voice coils with a tone through the real path — the same aux connection, claim, sink and write loop the renderer uses — and is triggered by `adb shell setprop debug.punktfunk.pad_audio_selftest 3`, matching this repo's existing debug.punktfunk.* convention. It runs INSTEAD of the renderer for that capture, never alongside it: two engines on one descriptor is the fault being tested for, and I nearly shipped it into the test itself. Underruns are deliberately not a failure condition — that is producer pacing. The pass condition is data reaching the bus. --- .../kotlin/io/unom/punktfunk/kit/DsCapture.kt | 46 +++++++- .../io/unom/punktfunk/kit/HidUsbLink.kt | 20 ++++ .../io/unom/punktfunk/kit/NativeBridge.kt | 9 ++ clients/android/native/src/pad_audio.rs | 100 ++++++++++++++++++ clients/android/native/src/session/planes.rs | 25 +++++ 5 files changed, 197 insertions(+), 3 deletions(-) diff --git a/clients/android/kit/src/main/kotlin/io/unom/punktfunk/kit/DsCapture.kt b/clients/android/kit/src/main/kotlin/io/unom/punktfunk/kit/DsCapture.kt index 17d217ec..b4da2f31 100644 --- a/clients/android/kit/src/main/kotlin/io/unom/punktfunk/kit/DsCapture.kt +++ b/clients/android/kit/src/main/kotlin/io/unom/punktfunk/kit/DsCapture.kt @@ -97,6 +97,15 @@ class DsCapture( /** True once [PadAudioHook.start] has run for the current capture, so it fires exactly once. */ @Volatile private var padAudioStarted = false + /** + * The renderer's OWN connection to the pad. + * + * It must not share [usb]'s descriptor: two transfer engines on one usbfs descriptor reap each + * other's completions (see [HidUsbLink.openAuxConnection]), which strands both the HID reader + * and the audio ring. Closed only after the hook's stop has returned. + */ + @Volatile private var padAudioConn: android.hardware.usb.UsbDeviceConnection? = null + val isActive: Boolean get() = model != null /** First attached Sony USB pad, for the permission flow. Needs no permission to enumerate. */ @@ -135,7 +144,11 @@ class DsCapture( // joined, so ordering this first is what makes the borrow sound. if (padAudioStarted) { padAudioStarted = false + // stop() joins the render thread, so nothing is using the descriptor after it returns + // — only then is it safe to close the connection that owns it. pad?.let { padAudio?.stop(it.index) } + padAudioConn?.close() + padAudioConn = null } val m = model if (m != null) { @@ -161,11 +174,38 @@ class DsCapture( pad = it // The wire index exists from here on, and the host addresses pad audio by it. Fired on // the link thread, once per capture. - if (!padAudioStarted) { - val fd = usb.fileDescriptor + if (!padAudioStarted && padAudio != null) { + // A dedicated connection, NOT usb.fileDescriptor — see padAudioConn. + val conn = usb.openAuxConnection() + val fd = conn?.fileDescriptor ?: -1 if (fd >= 0) { + padAudioConn = conn padAudioStarted = true - padAudio?.start(it.index, fd) + // Real-world self test, opt-in: `adb shell setprop debug.punktfunk.pad_audio_selftest 3` + // drives the voice coils for N seconds through the actual client path before + // the renderer takes over — the one check that proves the descriptor, the + // interface claim and the write path all work on THIS device, without needing + // a host to be streaming. Same convention as debug.punktfunk.force_parts. + val secs = runCatching { + Class.forName("android.os.SystemProperties") + .getMethod("get", String::class.java, String::class.java) + .invoke(null, "debug.punktfunk.pad_audio_selftest", "0") as String + }.getOrNull()?.toIntOrNull() ?: 0 + if (secs > 0) { + // Diagnostic mode: the self test OWNS this descriptor for the capture, and + // the renderer must not also drive it — two engines on one usbfs + // descriptor reap each other's completions, which is precisely the fault + // this test exists to expose. + Thread({ + val r = NativeBridge.nativePadAudioSelfTest(fd, secs, 60) + Log.i(TAG, "pad audio self-test → ${if (r > 0) "PASS ($r frames)" else "FAIL ($r)"}") + }, "pf-pad-selftest").start() + } else { + padAudio?.start(it.index, fd) + } + } else { + conn?.close() + Log.w(TAG, "pad audio: could not open a second USB connection") } } Log.i(TAG, "captured $m → wire pad ${it.index}") diff --git a/clients/android/kit/src/main/kotlin/io/unom/punktfunk/kit/HidUsbLink.kt b/clients/android/kit/src/main/kotlin/io/unom/punktfunk/kit/HidUsbLink.kt index 45ad01e8..c81db817 100644 --- a/clients/android/kit/src/main/kotlin/io/unom/punktfunk/kit/HidUsbLink.kt +++ b/clients/android/kit/src/main/kotlin/io/unom/punktfunk/kit/HidUsbLink.kt @@ -92,6 +92,26 @@ class HidUsbLink( /** First attached matching device, or null. Does not need USB permission to enumerate. */ fun findDevice(): UsbDevice? = usb.deviceList.values.firstOrNull(config.deviceMatch) + /** + * Open a SECOND connection to the same device, for a consumer that needs its own descriptor. + * + * **Not a convenience — a correctness requirement.** `UsbDeviceConnection.requestWait()` + * returns *any* completed request on that connection, and the same is true of the usbfs reap + * ioctl underneath it: two independent transfer engines sharing one descriptor steal each + * other's completions. This link's reader owns its connection exclusively (see the note on + * [outQueue]), so anything else driving transfers on this device — the isochronous audio + * renderer — must open its own. + * + * usbfs allows the same device to be opened many times, and claims are per (descriptor, + * interface), so a claim made on this connection does not conflict with one made on that. + * + * The caller owns the returned connection and must close it. + */ + fun openAuxConnection(): UsbDeviceConnection? { + val dev = device ?: return null + return usb.openDevice(dev) + } + /** * The open connection's usbfs file descriptor, or -1 when the link is not running. * diff --git a/clients/android/kit/src/main/kotlin/io/unom/punktfunk/kit/NativeBridge.kt b/clients/android/kit/src/main/kotlin/io/unom/punktfunk/kit/NativeBridge.kt index 1caf8e83..e8a93e60 100644 --- a/clients/android/kit/src/main/kotlin/io/unom/punktfunk/kit/NativeBridge.kt +++ b/clients/android/kit/src/main/kotlin/io/unom/punktfunk/kit/NativeBridge.kt @@ -367,6 +367,15 @@ object NativeBridge { */ external fun nativeStopPadAudio(handle: Long, pad: Int) + /** + * Drive the pad with a test tone through the real render path — no host, no session. + * + * [fd] must come from a connection **nothing else is driving transfers on**: two engines on + * one usbfs descriptor reap each other's completions. Blocks for roughly [seconds]; run it off + * the main thread. Returns sample frames written, or negative on failure. + */ + external fun nativePadAudioSelfTest(fd: Int, seconds: Int, hz: Int): Int + /** * Is a mic capture actually RUNNING — i.e. did [nativeStartMic] open a stream, and has * [nativeStopMic] not been called since? Offer the in-stream mute control on THIS rather than diff --git a/clients/android/native/src/pad_audio.rs b/clients/android/native/src/pad_audio.rs index 49978eca..b979c748 100644 --- a/clients/android/native/src/pad_audio.rs +++ b/clients/android/native/src/pad_audio.rs @@ -280,6 +280,106 @@ mod sink { } } +// ---- the self test ------------------------------------------------------------------------------ + +/// Drive the pad directly with a synthetic tone, through **the real client path**. +/// +/// This exists because the two things most likely to be wrong here cannot be unit-tested and are +/// invisible without a host: whether the descriptor Kotlin handed over is one this renderer may +/// drive exclusively, and whether the interface claim succeeds on this kernel. A standalone +/// harness proves neither — it owns its descriptor by construction, which is exactly the condition +/// that was violated when this renderer was handed the HID link's fd and the two engines began +/// stealing each other's URB completions. +/// +/// Opens the sink the same way [`render`] does and writes a sine into the voice-coil pair, which +/// is felt rather than heard. Returns sample frames written, or a negative [`SelfTest`] code. +/// +/// # Safety +/// +/// `fd` must be a live usbfs descriptor whose connection outlives the call, and which **nothing +/// else is driving transfers on**. +#[cfg(target_os = "android")] +pub(crate) unsafe fn self_test(fd: i32, seconds: i32, hz: i32) -> i32 { + // SAFETY: the caller's contract. + let dev = unsafe { sink::device(fd) }; + let mut playback = match sink::open(&dev) { + Ok(p) => p, + Err(e) => { + log::warn!("pad audio self-test: could not open the stream: {e}"); + return SelfTest::OPEN_FAILED; + } + }; + log::info!( + "pad audio self-test: {} ch {} at {} Hz, {} us in flight", + playback.channels(), + playback.format(), + playback.rate(), + playback.schedule().in_flight_us() + ); + + let rate = playback.rate(); + let channels = playback.channels() as usize; + let frames_per_chunk = (rate as usize / 1000).max(1); + let mut chunk = vec![0i16; frames_per_chunk * channels]; + let mut phase = 0.0f32; + let step = std::f32::consts::TAU * hz.clamp(20, 500) as f32 / rate as f32; + let total = u64::from(rate) * seconds.clamp(1, 30) as u64; + let mut written = 0u64; + + while written < total { + for frame in chunk.chunks_mut(channels) { + let sample = (phase.sin() * 16_384.0) as i16; + phase += step; + if phase >= std::f32::consts::TAU { + phase -= std::f32::consts::TAU; + } + frame.fill(0); + // Channels 2 and 3 are the voice coils; the speaker pair stays silent so a pass is + // unambiguously FELT rather than merely audible. + for c in 2..channels { + frame[c] = sample; + } + } + if let Err(e) = playback.write_interleaved(&chunk) { + log::warn!("pad audio self-test: write failed after {written} frames: {e}"); + return SelfTest::WRITE_FAILED; + } + written += frames_per_chunk as u64; + } + let _ = playback.drain(Duration::from_millis(500)); + + let stats = playback.stats(); + log::info!( + "pad audio self-test: {} frames, {} urbs, {} underruns, {} short bytes, {} urb errors", + playback.frames_written(), + stats.urbs_completed, + stats.underruns, + stats.short_bytes, + stats.urb_errors + ); + // Underruns are a producer-pacing property and deliberately NOT a failure here: the question + // this answers is whether the client can drive the pad at all. Data reaching the bus is the + // pass condition. + if stats.urb_errors > 0 || playback.frames_written() == 0 { + return SelfTest::NO_DATA; + } + playback.frames_written().min(i32::MAX as u64) as i32 +} + +/// Negative results from [`self_test`]. Positive values are sample frames written. +#[cfg(target_os = "android")] +pub(crate) struct SelfTest; + +#[cfg(target_os = "android")] +impl SelfTest { + /// The claim or stream open failed — the OEM-kernel case, or a descriptor another engine owns. + pub(crate) const OPEN_FAILED: i32 = -1; + /// The stream opened but a write failed part-way. + pub(crate) const WRITE_FAILED: i32 = -2; + /// It ran, but nothing reached the bus. + pub(crate) const NO_DATA: i32 = -3; +} + // ---- the renderer worker ----------------------------------------------------------------------- /// A running renderer: the stop flag and the thread, joined on drop. diff --git a/clients/android/native/src/session/planes.rs b/clients/android/native/src/session/planes.rs index 059c2416..54fb79db 100644 --- a/clients/android/native/src/session/planes.rs +++ b/clients/android/native/src/session/planes.rs @@ -512,6 +512,31 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeStartPadAud }) } +/// `NativeBridge.nativePadAudioSelfTest(fd, seconds, hz): Int` — drive the pad directly with a +/// tone through the real client render path, with no host and no session involved. +/// +/// The check a standalone harness cannot make: it owns its descriptor by construction, so it can +/// never reveal that the client handed the renderer a descriptor something else was already +/// driving. Returns sample frames written, or negative on failure (see `pad_audio::SelfTest`). +#[no_mangle] +#[cfg(target_os = "android")] +pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativePadAudioSelfTest( + _env: JNIEnv, + _this: JObject, + fd: jni::sys::jint, + seconds: jni::sys::jint, + hz: jni::sys::jint, +) -> jni::sys::jint { + jni_guard(-1, || { + if fd < 0 { + return -1; + } + // SAFETY: Kotlin holds the owning UsbDeviceConnection open across this call and drives no + // other transfers on it (it opens a dedicated connection for exactly this). + unsafe { crate::pad_audio::self_test(fd, seconds, hz) } + }) +} + /// `NativeBridge.nativeStopPadAudio(handle, pad)` — stop tier-A pad audio and join its thread. /// /// Returns only once the render thread is joined, which is the point: Kotlin may close the From 4fd240deabde986e4fa0f6c21843fed382e95cb0 Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Mon, 3 Aug 2026 09:38:46 +0200 Subject: [PATCH 08/21] test(android): make the pad-audio self test reachable without a host MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The self test shipped in the previous commit was gated behind a capture, which needs a stream, which needs a host — so it depended on precisely the thing it exists to rule out. It could not have been run in the situation that motivated it. It is now a "Test haptics" button on the DualSense passthrough card in Settings → Controllers → Connected controllers, which is reachable with no session at all. It opens its OWN connection to the pad — the same rule the renderer follows, and the rule whose violation caused the fault this test looks for — runs the tone on a worker thread, and reports a plain-language result: which of open / write / no-data failed, or how many frames reached the pad. The debug-property trigger stays for the in-session case; this is the one that answers "can this phone drive this pad at all" before a host is even involved. --- .../io/unom/punktfunk/ControllersScreen.kt | 73 ++++++++++++++++--- 1 file changed, 62 insertions(+), 11 deletions(-) diff --git a/clients/android/app/src/main/kotlin/io/unom/punktfunk/ControllersScreen.kt b/clients/android/app/src/main/kotlin/io/unom/punktfunk/ControllersScreen.kt index 239e80d0..6c56bee5 100644 --- a/clients/android/app/src/main/kotlin/io/unom/punktfunk/ControllersScreen.kt +++ b/clients/android/app/src/main/kotlin/io/unom/punktfunk/ControllersScreen.kt @@ -410,17 +410,68 @@ private fun DsRow(usbDev: android.hardware.usb.UsbDevice) { Text("Grant USB access") } } - else -> Text( - if (model == DsDevice.Model.DUALSHOCK4) { - "Ready — captured at stream start: rumble, lightbar and gyro are " + - "driven directly." - } else { - "Ready — captured at stream start: rumble, adaptive triggers, lightbar " + - "and gyro are driven directly." - }, - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) + else -> { + Text( + if (model == DsDevice.Model.DUALSHOCK4) { + "Ready — captured at stream start: rumble, lightbar and gyro are " + + "driven directly." + } else { + "Ready — captured at stream start: rumble, adaptive triggers, lightbar " + + "and gyro are driven directly." + }, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + // Pad-audio self test. Deliberately reachable WITHOUT a stream: it exists to + // answer "can this phone drive this pad's audio endpoint at all", and gating + // that behind a live session would make it depend on the very thing one wants + // to rule out when a session misbehaves. DualSense only — the DS4 has no + // 4-channel haptics device. + if (model != DsDevice.Model.DUALSHOCK4) { + var testing by remember { mutableStateOf(false) } + var result by remember { mutableStateOf(null) } + result?.let { + Text( + it, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + OutlinedButton( + enabled = !testing, + onClick = { + testing = true + result = null + Thread({ + // Its OWN connection: the renderer's descriptor must never be + // shared with another transfer engine, and that applies to + // this test as much as to the real path. + val conn = runCatching { usbManager.openDevice(usbDev) }.getOrNull() + val fd = conn?.fileDescriptor ?: -1 + val r = if (fd >= 0) { + io.unom.punktfunk.kit.NativeBridge.nativePadAudioSelfTest(fd, 3, 60) + } else { + -1 + } + conn?.close() + val msg = when { + r > 0 -> "Haptics test passed — $r frames to the pad." + r == -1 -> "Could not open the pad's audio interface. " + + "Some kernels refuse it; the pad still works normally." + r == -2 -> "The audio stream stopped part-way." + else -> "The stream opened but no audio reached the pad." + } + android.os.Handler(android.os.Looper.getMainLooper()).post { + result = msg + testing = false + } + }, "pf-pad-selftest-ui").start() + }, + ) { + Text(if (testing) "Testing…" else "Test haptics") + } + } + } } } } From 6fed1510babb230fb598f1e098c3e20590738ed7 Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Mon, 3 Aug 2026 10:01:05 +0200 Subject: [PATCH 09/21] test(android): report renderer stats even when the plane is silent MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The renderer now reports once a second regardless of traffic — frames in, samples decoded, peak level, frames written, underruns, short bytes. The first version reported only after a frame arrived, which made the single most diagnostic state unreportable: an idle plane and a dead renderer looked identical (both silent). That cost a debugging round on real hardware, where the absence of any line had to be triangulated against usbfs interface claims and `dumpsys input` to work out which of the two it was. The peak is of the decoded PCM, and it is the discriminator that matters: frames arriving with peak=0 means the host's capture is hearing silence — a routing problem upstream — whereas a non-zero peak means real signal is reaching the pad and anything still wrong is downstream of the write. --- clients/android/native/src/pad_audio.rs | 36 +++++++++++++++++++++++++ 1 file changed, 36 insertions(+) diff --git a/clients/android/native/src/pad_audio.rs b/clients/android/native/src/pad_audio.rs index b979c748..1417522c 100644 --- a/clients/android/native/src/pad_audio.rs +++ b/clients/android/native/src/pad_audio.rs @@ -537,10 +537,34 @@ fn pump( ) { let mut mixer = QuadMixer::new(); let mut streams: [Option; 2] = [None, None]; + // Periodic accounting. Without it the only way to tell "the host is sending nothing" from + // "frames arrive but render silently" is to guess, and those two have completely different + // causes — one is host-side routing, the other is here. + let mut frames_in = 0u64; + let mut samples_in = 0u64; + let mut peak = 0i32; + let mut last_report = std::time::Instant::now(); let mut pcm: Vec = Vec::with_capacity(MAX_FRAME_SAMPLES * 2); let mut out: Vec = Vec::with_capacity(MAX_BUFFER_FRAMES * PAD_CHANNELS); while !stop.load(Ordering::Relaxed) { + // Report BEFORE the frame gate. Silence on the plane is a legitimate — and highly + // diagnostic — state: it means the host's capture hears nothing, which is a routing + // problem upstream rather than anything here. Reporting only when a frame arrives makes + // that state indistinguishable from the renderer being dead. + if last_report.elapsed() >= Duration::from_secs(1) { + let st = playback.stats(); + log::info!( + "pad audio: {frames_in} frames in, {samples_in} samples, peak={peak}, \ + {} written, {} underruns, {} short", + playback.frames_written(), + st.underruns, + st.short_bytes + ); + last_report = std::time::Instant::now(); + peak = 0; + } + let Some(frame) = client.next_pad_audio(Duration::from_millis(10)) else { continue; }; @@ -556,6 +580,7 @@ fn pump( continue; } + frames_in += 1; let k = usize::from(frame.kind).min(1); let st = match &mut streams[k] { Some(s) => s, @@ -589,6 +614,17 @@ fn pump( match st.dec.decode(&frame.opus, &mut pcm, false) { Ok(n) => { st.frame_samples = n; + samples_in += n as u64; + // Peak of what actually decoded: distinguishes "frames arriving but silent" + // (a host-side routing problem) from "frames arriving with signal that is not + // reaching the actuators" (a problem here). + peak = peak.max( + pcm[..n * 2] + .iter() + .map(|s| i32::from(s.abs())) + .max() + .unwrap_or(0), + ); mixer.push(frame.kind, &pcm[..n * 2]); } Err(e) => log::debug!("pad audio: opus decode failed: {e}"), From 45cb525035d1c8c1d41e31d249d2a1f0167a022f Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Mon, 3 Aug 2026 10:05:52 +0200 Subject: [PATCH 10/21] wip(host): pad-endpoint tone devtest --- .../src/audio/windows/pad_endpoint.rs | 93 +++++++++++++++++++ crates/punktfunk-host/src/devtest.rs | 27 ++++++ 2 files changed, 120 insertions(+) diff --git a/crates/punktfunk-host/src/audio/windows/pad_endpoint.rs b/crates/punktfunk-host/src/audio/windows/pad_endpoint.rs index 019908fa..a73886ef 100644 --- a/crates/punktfunk-host/src/audio/windows/pad_endpoint.rs +++ b/crates/punktfunk-host/src/audio/windows/pad_endpoint.rs @@ -1475,6 +1475,99 @@ impl PadLoopbackCapturer { } } +/// Render a test tone straight into a pad's audio endpoint. +/// +/// The point is iteration speed. Without this, exercising the pad-audio chain means launching a +/// game that renders DualSense haptics and hoping it targets the right endpoint — minutes per +/// attempt, and a failure tells you nothing about *which* link broke. This drives the endpoint +/// directly, so the rest of the chain (loopback capture → gate → Opus → 0xD1 → client → the pad's +/// actuators) can be tested in seconds and in isolation from whether any game cooperates. +/// +/// The tone goes into the BACK channel pair, because that is the pair the framer routes to the +/// haptics kind — the voice coils. The front pair stays silent, so a pass is felt in the grips and +/// cannot be confused with the pad's speaker. +pub(crate) fn render_test_tone(endpoint_id: &str, seconds: u32, hz: f32) -> Result<()> { + wasapi::initialize_mta() + .ok() + .context("initialize COM (MTA) for the tone render")?; + + // By id, never a default-device resolve: the whole question being answered is whether THIS + // endpoint is the one the capture sees. + let device = wasapi::DeviceEnumerator::new() + .context("device enumerator")? + .get_device(endpoint_id) + .with_context(|| format!("pad endpoint {endpoint_id} not found"))?; + 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, + None, + ); + let (default_period, _min) = audio_client.get_device_period().context("device period")?; + audio_client + .initialize_client( + &desired, + &Direction::Render, + &StreamMode::EventsShared { + autoconvert: true, + buffer_duration_hns: default_period, + }, + ) + .context("initialize render client")?; + let h_event = audio_client.set_get_eventhandle().context("event handle")?; + let render = audio_client + .get_audiorenderclient() + .context("IAudioRenderClient")?; + let buf_frames = audio_client.get_buffer_size().context("buffer size")? as usize; + let block = PAD_CHANNELS as usize * std::mem::size_of::(); + // Start on silence so the stream opens without a glitch, exactly as the mic pump does. + let _ = render.write_to_device(buf_frames, &vec![0u8; buf_frames * block], None); + audio_client.start_stream().context("start render stream")?; + + let total = u64::from(SAMPLE_RATE) * u64::from(seconds.clamp(1, 60)); + let step = std::f32::consts::TAU * hz / SAMPLE_RATE as f32; + let mut phase = 0.0f32; + let mut written = 0u64; + let mut bytes = vec![0u8; buf_frames * block]; + + while written < total { + if h_event.wait_for_event(1000).is_err() { + anyhow::bail!("render event timed out after {written} frames"); + } + let free = audio_client + .get_available_space_in_frames() + .context("available space")? as usize; + let n = free.min((total - written) as usize); + if n == 0 { + continue; + } + for f in 0..n { + let s = (phase.sin() * 0.5) as f32; + phase += step; + if phase >= std::f32::consts::TAU { + phase -= std::f32::consts::TAU; + } + for c in 0..PAD_CHANNELS as usize { + // Back pair only — the haptics kind. + let v: f32 = if c >= 2 { s } else { 0.0 }; + let at = (f * PAD_CHANNELS as usize + c) * 4; + bytes[at..at + 4].copy_from_slice(&v.to_le_bytes()); + } + } + render + .write_to_device(n, &bytes[..n * block], None) + .context("write tone")?; + written += n as u64; + } + // Let the tail drain before tearing the stream down. + std::thread::sleep(Duration::from_millis(200)); + let _ = audio_client.stop_stream(); + Ok(()) +} + impl Drop for PadLoopbackCapturer { fn drop(&mut self) { self.stop.store(true, Ordering::SeqCst); diff --git a/crates/punktfunk-host/src/devtest.rs b/crates/punktfunk-host/src/devtest.rs index 7c649c5b..a7ca7306 100644 --- a/crates/punktfunk-host/src/devtest.rs +++ b/crates/punktfunk-host/src/devtest.rs @@ -527,6 +527,33 @@ pub fn pad_endpoint(args: &[String]) -> Result<()> { Ok(()) } }, + // `punktfunk-host pad-endpoint tone [seconds] [hz]` — drive the endpoint directly so + // the whole pad-audio chain can be exercised without a game. Without this, every attempt + // costs a game launch and a failure does not say which link broke. + Some("tone") => { + let secs: u32 = args.get(2).and_then(|s| s.parse().ok()).unwrap_or(5); + let hz: f32 = args.get(3).and_then(|s| s.parse().ok()).unwrap_or(60.0); + let Some(ep) = pe::endpoint_for(idx) else { + println!( + "pad-endpoint tone: no provisioned endpoint for pad {idx} — run `ensure` first" + ); + return Ok(()); + }; + if ep.endpoint_id.is_empty() { + println!("pad-endpoint tone: pad {idx} has no endpoint id yet"); + return Ok(()); + } + println!( + "pad-endpoint tone: {hz} Hz into the BACK pair (haptics) of {} for {secs}s", + ep.endpoint_id + ); + pe::render_test_tone(&ep.endpoint_id, secs, hz)?; + println!( + "pad-endpoint tone: done. A connected client with pad audio enabled should have \ + buzzed; the host log shows whether the gate opened." + ); + Ok(()) + } Some("status") => pe::print_status(idx), _ => anyhow::bail!("usage: punktfunk-host pad-endpoint [--index N]"), } From 212bdc3b0829a24274a32b0dfec2a17b74226c10 Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Mon, 3 Aug 2026 10:09:49 +0200 Subject: [PATCH 11/21] fix(devtest): resolve the pad endpoint by system lookup, not the service's cache --- crates/punktfunk-host/src/devtest.rs | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/crates/punktfunk-host/src/devtest.rs b/crates/punktfunk-host/src/devtest.rs index a7ca7306..ae7dede7 100644 --- a/crates/punktfunk-host/src/devtest.rs +++ b/crates/punktfunk-host/src/devtest.rs @@ -533,9 +533,11 @@ pub fn pad_endpoint(args: &[String]) -> Result<()> { Some("tone") => { let secs: u32 = args.get(2).and_then(|s| s.parse().ok()).unwrap_or(5); let hz: f32 = args.get(3).and_then(|s| s.parse().ok()).unwrap_or(60.0); - let Some(ep) = pe::endpoint_for(idx) else { + // `find` (a system lookup), NOT `endpoint_for` (the service's in-process cache): + // this runs as a separate CLI process and has no cache of its own. + let Some(ep) = pe::find(idx)? else { println!( - "pad-endpoint tone: no provisioned endpoint for pad {idx} — run `ensure` first" + "pad-endpoint tone: no pad-audio devnode for pad {idx} — run `ensure` first" ); return Ok(()); }; From 9409d0a04c04dbe04ce009dbe92a7325eaca8416 Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Mon, 3 Aug 2026 11:01:22 +0200 Subject: [PATCH 12/21] fix(host/pad-audio): provisioning stops corrupting the heap, and the endpoint stops being resolved by a freed string MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two defects sat between the pad-audio endpoint and any sound. Neither was where the symptom pointed. `windows 0.62` implements `Drop for PROPVARIANT` as `PropVariantClear(self)`. Every variant `set_store_value` builds borrows memory Rust owns — a `Vec`, a `&GUID`, a `&'static [u8]` — so each stamp handed that pointer to `CoTaskMemFree`. The file said the opposite in a comment, which is why it looked safe. The damage surfaced late: `pad-endpoint ensure` died with STATUS_HEAP_CORRUPTION (0xC0000374) partway through stamping, leaving the endpoint with whatever subset had landed and `needs_aeb_kick` stuck true forever. With the variants held in `ManuallyDrop`, `ensure` exits 0 and all seven stamps read back served for the first time. `wasapi 0.23`'s `DeviceEnumerator::get_device` builds its argument as `PCWSTR::from_raw(HSTRING::from(id).as_ptr())`; the `HSTRING` is a temporary, so `GetDevice` reads freed memory. That is where the `IAudioClient: 0x80070002` came from — not from the endpoint, which activates fine. Resolving through `open_mmdevice`, which keeps its buffer alive, retires the error in both the tone devtest and the loopback capture. Also adds the instrument that separated these: the tone path now reports the raw `IMMDevice::Activate` result alongside the crate's, and `pad-endpoint tone --endpoint ` can drive any endpoint, so "this process cannot activate anything" and "this endpoint is broken" stop looking identical. Verified on .173: ensure exit=0, 7/7 stamps served, needs_aeb_kick=false, 0x80070002 gone. Host clippy clean; 360 tests pass (the one mgmt display failure reproduces on a clean tree). Co-Authored-By: Claude Opus 5 (1M context) --- .../src/audio/windows/pad_endpoint.rs | 95 ++++++++++++++----- crates/punktfunk-host/src/devtest.rs | 43 ++++++--- 2 files changed, 101 insertions(+), 37 deletions(-) diff --git a/crates/punktfunk-host/src/audio/windows/pad_endpoint.rs b/crates/punktfunk-host/src/audio/windows/pad_endpoint.rs index a73886ef..55ec9e32 100644 --- a/crates/punktfunk-host/src/audio/windows/pad_endpoint.rs +++ b/crates/punktfunk-host/src/audio/windows/pad_endpoint.rs @@ -62,7 +62,9 @@ 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::Media::Audio::{ + IAudioClient, IMMDevice, IMMDeviceEnumerator, MMDeviceEnumerator, +}; use windows::Win32::System::Com::StructuredStorage::{ PropVariantClear, PROPVARIANT, PROPVARIANT_0, PROPVARIANT_0_0, PROPVARIANT_0_0_0, }; @@ -326,11 +328,19 @@ fn endpoint_guid_part(endpoint_id: &str) -> Result<&str> { .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) --------------------------------- +// --- PROPVARIANT plumbing ------------------------------------------------------------------- +// +// ⚠ `windows 0.62` DOES implement `Drop for PROPVARIANT`, as `PropVariantClear(self)`. Every +// variant below points at memory Rust owns — a `Vec`, a borrowed `&GUID`, a `&'static +// [u8]` — so letting one drop hands that pointer to `CoTaskMemFree` and corrupts the heap. The +// symptom is not local: provisioning died later with STATUS_HEAP_CORRUPTION (0xC0000374), +// leaving the endpoint half-stamped, which then looked like a stamping-permissions problem. +// Hence `ManuallyDrop`: these variants borrow, so nothing must ever clear them. (Variants that +// come back OWNED from `GetValue` are a different matter and ARE cleared, in `stamp_served`.) -fn pv_lpwstr(w: &[u16]) -> PROPVARIANT { - PROPVARIANT { +/// A `VT_LPWSTR` variant borrowing `w`, which must outlive it and stay NUL-terminated. +fn pv_lpwstr(w: &[u16]) -> ManuallyDrop { + ManuallyDrop::new(PROPVARIANT { Anonymous: PROPVARIANT_0 { Anonymous: ManuallyDrop::new(PROPVARIANT_0_0 { vt: VT_LPWSTR, @@ -342,11 +352,12 @@ fn pv_lpwstr(w: &[u16]) -> PROPVARIANT { }, }), }, - } + }) } -fn pv_clsid(g: &GUID) -> PROPVARIANT { - PROPVARIANT { +/// A `VT_CLSID` variant borrowing `g`, which must outlive it. +fn pv_clsid(g: &GUID) -> ManuallyDrop { + ManuallyDrop::new(PROPVARIANT { Anonymous: PROPVARIANT_0 { Anonymous: ManuallyDrop::new(PROPVARIANT_0_0 { vt: VT_CLSID, @@ -358,11 +369,12 @@ fn pv_clsid(g: &GUID) -> PROPVARIANT { }, }), }, - } + }) } -fn pv_blob(b: &[u8]) -> PROPVARIANT { - PROPVARIANT { +/// A `VT_BLOB` variant borrowing `b`, which must outlive it. +fn pv_blob(b: &[u8]) -> ManuallyDrop { + ManuallyDrop::new(PROPVARIANT { Anonymous: PROPVARIANT_0 { Anonymous: ManuallyDrop::new(PROPVARIANT_0_0 { vt: VT_BLOB, @@ -377,7 +389,7 @@ fn pv_blob(b: &[u8]) -> PROPVARIANT { }, }), }, - } + }) } fn pv_string(pv: &PROPVARIANT) -> Option { @@ -804,6 +816,46 @@ fn open_mmdevice(endpoint_id: &str) -> Result { } } +/// Open a [`wasapi::Device`] for an endpoint id WITHOUT the crate's `DeviceEnumerator::get_device`. +/// +/// `wasapi 0.23` builds that call's argument as +/// `PCWSTR::from_raw(HSTRING::from(device_id).as_ptr())`. The `HSTRING` is a temporary, so it is +/// dropped at the end of THAT statement and `IMMDeviceEnumerator::GetDevice` reads freed memory on +/// the next line. Whether the endpoint is found then depends on what the allocator happened to +/// leave behind — a heisenbug whose failure mode is `0x80070002` (ERROR_FILE_NOT_FOUND) for an id +/// that is perfectly valid. [`open_mmdevice`] keeps its wide buffer alive across the call, so +/// resolve there and only borrow the crate's wrapper around the resulting interface. +fn open_wasapi_device(endpoint_id: &str) -> Result { + let dev = open_mmdevice(endpoint_id)?; + wasapi::Device::from_immdevice(dev) + .map_err(|e| anyhow!("wrap IMMDevice {endpoint_id} as a wasapi Device: {e}")) +} + +/// Log whether this endpoint can be activated IN THIS PROCESS, at both layers. +/// +/// The whole pad-audio bring-up stalled on an `IAudioClient: 0x80070002` that named no layer: the +/// endpoint resolved, the property store opened, and only activation failed — which is equally +/// consistent with a dead endpoint, a process that cannot activate anything, and a bad argument +/// handed to the COM call. Reporting the raw `IMMDevice::Activate` result next to the crate's +/// tells them apart in one run instead of one rebuild each. +fn probe_activation(endpoint_id: &str) { + match open_mmdevice(endpoint_id) { + Err(e) => tracing::error!(endpoint = %endpoint_id, error = %format!("{e:#}"), + "activation probe: GetDevice failed"), + Ok(dev) => { + // SAFETY: standard COM activation on a COM-initialized thread; the returned + // interface is dropped immediately (the probe only wants the HRESULT). + match unsafe { dev.Activate::(CLSCTX_ALL, None) } { + Ok(_) => tracing::info!(endpoint = %endpoint_id, + "activation probe: raw IMMDevice::Activate(IAudioClient) OK"), + Err(e) => tracing::error!(endpoint = %endpoint_id, + hr = %format!("{:#010x}", e.code().0), + "activation probe: raw IMMDevice::Activate(IAudioClient) FAILED"), + } + } + } +} + /// 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 @@ -829,17 +881,17 @@ fn set_store_value(store: &IPropertyStore, s: &Stamp) -> Result<()> { 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)?; + 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)?; + // 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)?; + unsafe { store.SetValue(&s.key, &*pv) }.context(s.label)?; } } Ok(()) @@ -1491,11 +1543,10 @@ pub(crate) fn render_test_tone(endpoint_id: &str, seconds: u32, hz: f32) -> Resu .ok() .context("initialize COM (MTA) for the tone render")?; + probe_activation(endpoint_id); // By id, never a default-device resolve: the whole question being answered is whether THIS // endpoint is the one the capture sees. - let device = wasapi::DeviceEnumerator::new() - .context("device enumerator")? - .get_device(endpoint_id) + let device = open_wasapi_device(endpoint_id) .with_context(|| format!("pad endpoint {endpoint_id} not found"))?; let mut audio_client = device.get_iaudioclient().context("IAudioClient")?; let desired = WaveFormat::new( @@ -1614,10 +1665,8 @@ fn pad_capture_thread( // 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 device = open_wasapi_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, diff --git a/crates/punktfunk-host/src/devtest.rs b/crates/punktfunk-host/src/devtest.rs index ae7dede7..72b1c181 100644 --- a/crates/punktfunk-host/src/devtest.rs +++ b/crates/punktfunk-host/src/devtest.rs @@ -504,6 +504,14 @@ pub fn pad_endpoint(args: &[String]) -> Result<()> { .nth(1) .and_then(|s| s.parse().ok()) .unwrap_or(0); + // `--endpoint ` drives ANY render endpoint, not just a provisioned pad one. It is the + // discriminator between "this process cannot activate anything" and "our endpoint is broken": + // aim the same binary at a known-good endpoint and see whether it succeeds there. + let endpoint_override: Option = args + .iter() + .skip_while(|a| *a != "--endpoint") + .nth(1) + .cloned(); match args.get(1).map(String::as_str) { Some("ensure") => { let p = pe::ensure(idx)?; @@ -533,23 +541,30 @@ pub fn pad_endpoint(args: &[String]) -> Result<()> { Some("tone") => { let secs: u32 = args.get(2).and_then(|s| s.parse().ok()).unwrap_or(5); let hz: f32 = args.get(3).and_then(|s| s.parse().ok()).unwrap_or(60.0); - // `find` (a system lookup), NOT `endpoint_for` (the service's in-process cache): - // this runs as a separate CLI process and has no cache of its own. - let Some(ep) = pe::find(idx)? else { - println!( - "pad-endpoint tone: no pad-audio devnode for pad {idx} — run `ensure` first" - ); - return Ok(()); + let endpoint_id = match endpoint_override { + Some(id) => id, + None => { + // `find` (a system lookup), NOT `endpoint_for` (the service's in-process + // cache): this runs as a separate CLI process and has no cache of its own. + let Some(ep) = pe::find(idx)? else { + println!( + "pad-endpoint tone: no pad-audio devnode for pad {idx} — run \ + `ensure` first" + ); + return Ok(()); + }; + if ep.endpoint_id.is_empty() { + println!("pad-endpoint tone: pad {idx} has no endpoint id yet"); + return Ok(()); + } + ep.endpoint_id + } }; - if ep.endpoint_id.is_empty() { - println!("pad-endpoint tone: pad {idx} has no endpoint id yet"); - return Ok(()); - } println!( - "pad-endpoint tone: {hz} Hz into the BACK pair (haptics) of {} for {secs}s", - ep.endpoint_id + "pad-endpoint tone: {hz} Hz into the BACK pair (haptics) of {endpoint_id} for \ + {secs}s" ); - pe::render_test_tone(&ep.endpoint_id, secs, hz)?; + pe::render_test_tone(&endpoint_id, secs, hz)?; println!( "pad-endpoint tone: done. A connected client with pad audio enabled should have \ buzzed; the host log shows whether the gate opened." From 143454590f25722212eae44e52b48374982e5c4f Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Mon, 3 Aug 2026 11:14:43 +0200 Subject: [PATCH 13/21] test(host/pad-audio): let a stamp subset be re-provisioned, and confirm the endpoint really is 4ch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `PUNKTFUNK_PAD_AUDIO_STAMPS` narrows `ensure` to a named subset of the seven stamps (unset keeps all of them, so the shipping path is unchanged). The MMDevices Properties ACL denies even an elevated `reg delete`, so the only way to ask "which stamp breaks this endpoint" was to re-provision with subsets. Using it settled that nothing does. Once the heap corruption is out of the way and stamping completes in ONE pass, the full set yields an endpoint that is 4ch/48k/mask 0x33 with both directions open — render and the loopback capture that feeds the 0xD1 plane — and `pad-endpoint tone` renders without error. The intermediate reading, that the Steam driver was stereo-only and the feature needed a different carrier, was a confounded A/B: the "stamped" sample had accumulated its stamps across heap-corrupted runs. Asked properly — in EXCLUSIVE mode, which reaches the driver instead of the engine's mix format — that driver reports 2ch, 4ch and 8ch, the same shape a real DualSense reports. Co-Authored-By: Claude Opus 5 (1M context) --- .../src/audio/windows/pad_endpoint.rs | 25 ++++++++++++++++--- 1 file changed, 22 insertions(+), 3 deletions(-) diff --git a/crates/punktfunk-host/src/audio/windows/pad_endpoint.rs b/crates/punktfunk-host/src/audio/windows/pad_endpoint.rs index 55ec9e32..adc70909 100644 --- a/crates/punktfunk-host/src/audio/windows/pad_endpoint.rs +++ b/crates/punktfunk-host/src/audio/windows/pad_endpoint.rs @@ -233,6 +233,25 @@ fn stamps_for(pad_index: u8) -> [Stamp; 7] { ] } +/// The stamps to actually apply, honouring the `PUNKTFUNK_PAD_AUDIO_STAMPS` bisect hook. +/// +/// Unset (the shipping path) means all seven. Set to a comma-separated list of labels — e.g. +/// `desc,name,container` — and only those are written or checked. This exists because a fully +/// stamped endpoint cannot be opened at all (`IAudioClient::Initialize` → `AUDCLNT_E_UNSUPPORTED +/// _FORMAT` for EVERY format, including its own mix format) while a bare one opens fine, and the +/// MMDevices ACL blocks editing the values directly — so the only way to find the poison stamp is +/// to re-provision with subsets. +fn active_stamps(pad_index: u8) -> Vec { + let all = stamps_for(pad_index); + match std::env::var("PUNKTFUNK_PAD_AUDIO_STAMPS") { + Err(_) => all.into_iter().collect(), + Ok(list) => { + let want: HashSet<&str> = list.split(',').map(str::trim).collect(); + all.into_iter().filter(|s| want.contains(s.label)).collect() + } + } +} + // --- small encoding helpers ---------------------------------------------------------------- /// NUL-terminated UTF-16. @@ -901,7 +920,7 @@ fn set_store_value(store: &IPropertyStore, s: &Stamp) -> Result<()> { /// 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 stamps = active_stamps(pad_index); let dev = open_mmdevice(endpoint_id)?; let pending: Vec<&Stamp> = { // SAFETY: read-only property store on a COM-initialized thread. @@ -1114,7 +1133,7 @@ fn all_served(endpoint_id: &str, pad_index: u8) -> bool { let Ok(store) = (unsafe { dev.OpenPropertyStore(STGM_READ) }) else { return false; }; - stamps_for(pad_index) + active_stamps(pad_index) .iter() .all(|s| stamp_served(&store, s)) } @@ -1238,7 +1257,7 @@ pub(crate) fn print_status(pad_index: u8) -> Result<()> { // 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) { + for s in active_stamps(pad_index) { let stored = match &s.value { StampValue::Str(v) => props .get_value::(reg_value_name(&s.key)) From 0d0e7e68615d6dc977be1a0de2726ffe0c3ee546 Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Mon, 3 Aug 2026 11:15:48 +0200 Subject: [PATCH 14/21] style(host/pad-audio): drop a redundant f32 cast in the tone devtest clippy's `unnecessary_cast` fires on it, which fails CI's -D warnings. Co-Authored-By: Claude Opus 5 (1M context) --- crates/punktfunk-host/src/audio/windows/pad_endpoint.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/punktfunk-host/src/audio/windows/pad_endpoint.rs b/crates/punktfunk-host/src/audio/windows/pad_endpoint.rs index adc70909..374ac6ca 100644 --- a/crates/punktfunk-host/src/audio/windows/pad_endpoint.rs +++ b/crates/punktfunk-host/src/audio/windows/pad_endpoint.rs @@ -1615,7 +1615,7 @@ pub(crate) fn render_test_tone(endpoint_id: &str, seconds: u32, hz: f32) -> Resu continue; } for f in 0..n { - let s = (phase.sin() * 0.5) as f32; + let s = phase.sin() * 0.5; phase += step; if phase >= std::f32::consts::TAU { phase -= std::f32::consts::TAU; From 35285afafc0810c3880799cd391f9f0278b2627e Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Mon, 3 Aug 2026 11:44:29 +0200 Subject: [PATCH 15/21] fix(host/pad-audio): retire the freed-string endpoint lookup everywhere, and make provisioning converge MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two loose ends from the pad-audio bring-up. `wasapi 0.23`'s `DeviceEnumerator::get_device` passes `GetDevice` a pointer into an `HSTRING` temporary that was already dropped, so it resolves whatever the allocator left behind and misses ids that are perfectly valid. Only the pad-audio path had been moved off it; the remaining four callers include desktop loopback capture and the default-endpoint judgement, where a spurious miss silently downgrades a capturable default to Unknown. The host now resolves through `open_wasapi_device` (raw COM, buffer kept alive). `pf-client-core` cannot share that helper — it pins a different `windows` revision than `wasapi` does, so the two `IMMDevice` types are incompatible — and instead scans the active collection by id, which touches only safe crate APIs. Provisioning also stopped latching a transient. A stamp lands, a check run immediately afterwards reports all seven keys served, and AudioEndpointBuilder then reverts the three format keys behind us, leaving 4/7 for good. Since `needs_aeb_kick` is what makes startup restart AudioEndpointBuilder + Audiosrv, that transient meant bouncing the machine's whole audio stack on every host start, forever, chasing stamps a re-pass lands. `ensure` now stamps, lets AEB settle, and only then checks — repeating up to five times. Before: fresh provisions landed 4/7 with kick=true on 3 of 4 runs. After: 4 of 4 runs settle 7/7 with kick=false in 2.8s, identity intact (Wireless Controller / DualSense Wireless Controller / PFDS container), 4ch mask 0x33, render and loopback capture both opening, and `pad-endpoint tone` clean. Host clippy clean; 360 tests pass, the one mgmt display failure reproduces on a clean tree. The client-side helper is type-checked against wasapi on Windows in isolation — pf-client-core itself will not build on .173 (no ffmpeg/SDL3/Vulkan toolchain there), so its module integration is unverified. Co-Authored-By: Claude Opus 5 (1M context) --- crates/pf-client-core/src/audio_wasapi.rs | 32 ++++++++++++++- crates/pf-client-core/src/pad_audio.rs | 8 ++-- .../src/audio/windows/audio_control.rs | 10 +++-- .../src/audio/windows/pad_endpoint.rs | 40 +++++++++++++++++-- .../src/audio/windows/wasapi_cap.rs | 11 +++-- 5 files changed, 85 insertions(+), 16 deletions(-) diff --git a/crates/pf-client-core/src/audio_wasapi.rs b/crates/pf-client-core/src/audio_wasapi.rs index 2df9f3b8..2b01f22b 100644 --- a/crates/pf-client-core/src/audio_wasapi.rs +++ b/crates/pf-client-core/src/audio_wasapi.rs @@ -97,13 +97,43 @@ pub fn devices() -> Result<(Vec, Vec)> { /// Settings device pickers via session main), or the OS default. A picked device that's /// gone (unplugged USB DAC, remote session) falls back to the default with a warning — /// audio keeps working, like the PipeWire twin's `target.object` behavior. +/// Resolve an active endpoint by id WITHOUT `DeviceEnumerator::get_device`. +/// +/// That helper builds its argument as `PCWSTR::from_raw(HSTRING::from(id).as_ptr())` — the +/// `HSTRING` is a temporary, dropped at the end of that statement, so `GetDevice` reads freed +/// memory and misses ids that are perfectly valid. Scanning the active collection touches only +/// safe crate APIs, so it cannot regress the same way. (`punktfunk-host` fixes the same bug with +/// raw COM instead; this crate cannot, because it pins a different `windows` revision than +/// `wasapi` does, making the two `IMMDevice` types incompatible.) +pub(crate) fn device_by_id( + enumerator: &DeviceEnumerator, + direction: &Direction, + id: &str, +) -> Result { + let devices = enumerator + .get_device_collection(direction) + .map_err(|e| anyhow!("enumerate {direction:?} endpoints: {e}"))?; + let count = devices + .get_nbr_devices() + .map_err(|e| anyhow!("endpoint count: {e}"))?; + for i in 0..count { + let dev = devices + .get_device_at_index(i) + .map_err(|e| anyhow!("endpoint {i}: {e}"))?; + if dev.get_id().is_ok_and(|got| got == id) { + return Ok(dev); + } + } + anyhow::bail!("no active {direction:?} endpoint with id {id}") +} + fn pick_device( enumerator: &DeviceEnumerator, direction: &Direction, var: &str, ) -> Result { if let Some(id) = std::env::var(var).ok().filter(|v| !v.is_empty()) { - match enumerator.get_device(&id) { + match device_by_id(enumerator, direction, &id) { Ok(d) => { tracing::info!( var, diff --git a/crates/pf-client-core/src/pad_audio.rs b/crates/pf-client-core/src/pad_audio.rs index a5222b04..4f29c2e7 100644 --- a/crates/pf-client-core/src/pad_audio.rs +++ b/crates/pf-client-core/src/pad_audio.rs @@ -920,9 +920,11 @@ fn pad_render_thread( 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}"))?; + // Not `get_device`: that helper resolves through a freed string — see + // [`crate::audio_wasapi::device_by_id`]. + let device = + crate::audio_wasapi::device_by_id(&enumerator, &Direction::Render, 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)); diff --git a/crates/punktfunk-host/src/audio/windows/audio_control.rs b/crates/punktfunk-host/src/audio/windows/audio_control.rs index a39ec594..9dd46fde 100644 --- a/crates/punktfunk-host/src/audio/windows/audio_control.rs +++ b/crates/punktfunk-host/src/audio/windows/audio_control.rs @@ -304,11 +304,13 @@ pub(crate) fn restore_default_playback() { } /// Open a device by endpoint id, with a name for error context. +/// +/// Resolves through [`super::pad_endpoint::open_wasapi_device`], NOT the `wasapi` crate's +/// `DeviceEnumerator::get_device` — that one hands `GetDevice` a freed string (see the helper's +/// docs), so it fails at random on ids that are perfectly valid. pub(crate) fn open_endpoint(ep: &Endpoint) -> Result { - wasapi::DeviceEnumerator::new() - .map_err(|e| anyhow!("DeviceEnumerator: {e}"))? - .get_device(&ep.1) - .map_err(|e| anyhow!("open endpoint {:?}: {e}", ep.0)) + super::pad_endpoint::open_wasapi_device(&ep.1) + .map_err(|e| anyhow!("open endpoint {:?}: {e:#}", ep.0)) } // --- IPolicyConfig (undocumented): set a default audio endpoint by id, for all three roles. --- diff --git a/crates/punktfunk-host/src/audio/windows/pad_endpoint.rs b/crates/punktfunk-host/src/audio/windows/pad_endpoint.rs index 374ac6ca..6269a089 100644 --- a/crates/punktfunk-host/src/audio/windows/pad_endpoint.rs +++ b/crates/punktfunk-host/src/audio/windows/pad_endpoint.rs @@ -95,6 +95,12 @@ const MMDEV_RENDER_PATH: &str = r"SOFTWARE\Microsoft\Windows\CurrentVersion\MMDe 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); +/// How many times [`ensure`] re-stamps before giving up and asking for an AudioEndpointBuilder +/// kick (AEB reverts the format keys behind a fresh endpoint — see the loop in [`ensure`]). +const STAMP_ATTEMPTS: usize = 5; +/// How long to let AudioEndpointBuilder settle after a stamp BEFORE checking whether it held. +/// Checking immediately always reports success, including on the passes that get reverted. +const STAMP_SETTLE: Duration = Duration::from_millis(1200); /// One provisioned pad-audio endpoint. Persistent by design (endpoints survive host restarts); /// [`remove`] exists for tests + the `pad-endpoint remove` escape hatch only. @@ -844,7 +850,7 @@ fn open_mmdevice(endpoint_id: &str) -> Result { /// leave behind — a heisenbug whose failure mode is `0x80070002` (ERROR_FILE_NOT_FOUND) for an id /// that is perfectly valid. [`open_mmdevice`] keeps its wide buffer alive across the call, so /// resolve there and only borrow the crate's wrapper around the resulting interface. -fn open_wasapi_device(endpoint_id: &str) -> Result { +pub(crate) fn open_wasapi_device(endpoint_id: &str) -> Result { let dev = open_mmdevice(endpoint_id)?; wasapi::Device::from_immdevice(dev) .map_err(|e| anyhow!("wrap IMMDevice {endpoint_id} as a wasapi Device: {e}")) @@ -1162,8 +1168,35 @@ pub fn ensure(pad_index: u8) -> Result { wait_for_endpoint(&device_instance)? } }; - stamp_endpoint(&endpoint_id, pad_index) - .with_context(|| format!("stamp pad endpoint {endpoint_id}"))?; + // Stamp until it STAYS stamped. On a freshly created endpoint the write lands — a check run + // straight afterwards reports all seven served — and AudioEndpointBuilder then quietly reverts + // the three format keys behind us, leaving 4/7 for good. So the check has to happen after a + // settle, not immediately: verifying too early is exactly how this looked like "the stamps + // never took" for one debugging session and "the stamps took fine" for the next. + // + // Converging here matters beyond tidiness: `needs_aeb_kick` is what makes + // [`provision_at_startup`] restart AudioEndpointBuilder + Audiosrv, so latching the transient + // means bouncing the whole machine's audio stack on EVERY host start, forever, chasing stamps + // a re-pass would have landed. Once the endpoint has finished settling a re-stamp sticks + // permanently (measured stable over 30 s), and `stamp_endpoint` skips already-served keys, so + // the extra passes cost nothing once it has taken. + let mut served = false; + for attempt in 0..STAMP_ATTEMPTS { + stamp_endpoint(&endpoint_id, pad_index) + .with_context(|| format!("stamp pad endpoint {endpoint_id}"))?; + thread::sleep(STAMP_SETTLE); + served = all_served(&endpoint_id, pad_index); + if served { + if attempt > 0 { + tracing::debug!( + pad = pad_index, + attempt = attempt + 1, + "pad endpoint stamps held after a re-pass" + ); + } + break; + } + } // 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. @@ -1181,7 +1214,6 @@ pub fn ensure(pad_index: u8) -> Result { "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, diff --git a/crates/punktfunk-host/src/audio/windows/wasapi_cap.rs b/crates/punktfunk-host/src/audio/windows/wasapi_cap.rs index 2dfcbfe2..db2d5374 100644 --- a/crates/punktfunk-host/src/audio/windows/wasapi_cap.rs +++ b/crates/punktfunk-host/src/audio/windows/wasapi_cap.rs @@ -349,7 +349,7 @@ fn capture_once( if assert_plan { if let Some(d) = seen_default.as_deref() { if d != dev_id { - match judge_default(&en, &wiring, d) { + match judge_default(&wiring, d) { DefaultKind::Capturable(name) => { tracing::info!(default = %name, planned = %dev_name, "could not park the default playback on the planned endpoint — \ @@ -428,7 +428,7 @@ fn capture_once( ); return Ok(Next::Reopen(TargetMode::Follow)); } - return Ok(match judge_default(&en, &wiring, &nid) { + return Ok(match judge_default(&wiring, &nid) { DefaultKind::Capturable(name) => { tracing::info!(device = %name, "operator changed the output device mid-stream — following \ @@ -461,8 +461,11 @@ enum DefaultKind { Unknown, } -fn judge_default(en: &DeviceEnumerator, wiring: &wiring_plan::Wiring, id: &str) -> DefaultKind { - let Ok(dev) = en.get_device(id) else { +/// Resolves through [`super::pad_endpoint::open_wasapi_device`], NOT the `wasapi` crate's +/// `DeviceEnumerator::get_device` — that one hands `GetDevice` a freed string (see the helper's +/// docs), and a spurious miss here silently downgrades a capturable default to `Unknown`. +fn judge_default(wiring: &wiring_plan::Wiring, id: &str) -> DefaultKind { + let Ok(dev) = super::pad_endpoint::open_wasapi_device(id) else { return DefaultKind::Unknown; }; let name = dev.get_friendlyname().unwrap_or_default(); From 64a392634e757a1b3ed4f08a106b69c3c01509c0 Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Mon, 3 Aug 2026 12:09:38 +0200 Subject: [PATCH 16/21] test(host/pad-audio): prove the endpoint actually carries audio, channel-exact MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `pad-endpoint tone` only ever proved a render client could open the endpoint. Whether anything came back out of the loopback — and in the right channel pair — was still taken on faith, which is exactly the gap that let a stamped-but- unservable endpoint look healthy while a client sat on an empty plane. `pad-endpoint capture [seconds]` opens the real PadLoopbackCapturer and reports frames plus per-pair peaks, so the two halves together exercise render -> engine -> loopback -> pair routing with no game and no client attached. Run against each other on .173: pad-endpoint capture: 157920 frames over 7s, peak_front=0.0000 peak_back=0.5000 VERDICT: PASS - back pair only, front pair silent (channel-exact). 0.5 is the tone's own amplitude and the front pair is dead silent, which is the signal the 0xD1 framer routes to the voice coils. Same figure the program notes recorded on 2026-08-01 and nothing has been able to reproduce since. Co-Authored-By: Claude Opus 5 (1M context) --- .../src/audio/windows/pad_endpoint.rs | 48 +++++++++++++++++++ crates/punktfunk-host/src/devtest.rs | 18 +++++++ 2 files changed, 66 insertions(+) diff --git a/crates/punktfunk-host/src/audio/windows/pad_endpoint.rs b/crates/punktfunk-host/src/audio/windows/pad_endpoint.rs index 6269a089..0e029c86 100644 --- a/crates/punktfunk-host/src/audio/windows/pad_endpoint.rs +++ b/crates/punktfunk-host/src/audio/windows/pad_endpoint.rs @@ -1670,6 +1670,54 @@ pub(crate) fn render_test_tone(endpoint_id: &str, seconds: u32, hz: f32) -> Resu Ok(()) } +/// `pad-endpoint capture` devtest body: open the REAL loopback capture on a pad endpoint and +/// report what actually arrives, split by channel pair. +/// +/// The other half of [`render_test_tone`]. Run the two together — tone in one process, this in +/// another — and the entire host side is exercised with no game and no client: a render client +/// opens the endpoint, the engine loops it back, and the capture must see the tone in the BACK +/// pair only. That is the exact signal the `0xD1` framer routes to the voice coils, so a pass here +/// means everything upstream of the wire is sound. +/// +/// Reading the result: `peak_back` well above zero with `peak_front` at exactly zero is a pass. +/// Front energy means the pair routing is wrong. Silence in both means the endpoint provisioned +/// but carries no audio — which is what a stamped-but-unservable endpoint looked like, and is +/// precisely the state that used to be invisible until a client sat on an empty plane. +pub(crate) fn capture_probe(endpoint_id: &str, seconds: u32) -> Result<()> { + let mut cap = PadLoopbackCapturer::open(endpoint_id) + .with_context(|| format!("open pad loopback on {endpoint_id}"))?; + let deadline = Instant::now() + Duration::from_secs(u64::from(seconds.clamp(1, 60))); + let (mut frames, mut peak_front, mut peak_back) = (0u64, 0f32, 0f32); + while Instant::now() < deadline { + let chunk = cap.next_chunk().context("read pad loopback")?; + for f in chunk.chunks_exact(PAD_CHANNELS as usize) { + frames += 1; + peak_front = peak_front.max(f[0].abs()).max(f[1].abs()); + peak_back = peak_back.max(f[2].abs()).max(f[3].abs()); + } + } + println!( + "pad-endpoint capture: {frames} frames over {seconds}s, peak_front={peak_front:.4} \ + peak_back={peak_back:.4}" + ); + if frames == 0 { + println!(" VERDICT: FAIL — the capture opened but delivered nothing."); + } else if peak_back <= 0.0001 { + println!( + " VERDICT: silent — capture works, but nothing was rendered into the back pair. \ + Run `pad-endpoint tone` against this endpoint at the same time." + ); + } else if peak_front > 0.0001 { + println!( + " VERDICT: FAIL — front pair carries {peak_front:.4}; the haptics pair is leaking \ + into the pad's speaker." + ); + } else { + println!(" VERDICT: PASS — back pair only, front pair silent (channel-exact)."); + } + Ok(()) +} + impl Drop for PadLoopbackCapturer { fn drop(&mut self) { self.stop.store(true, Ordering::SeqCst); diff --git a/crates/punktfunk-host/src/devtest.rs b/crates/punktfunk-host/src/devtest.rs index 72b1c181..91c4acbb 100644 --- a/crates/punktfunk-host/src/devtest.rs +++ b/crates/punktfunk-host/src/devtest.rs @@ -571,6 +571,24 @@ pub fn pad_endpoint(args: &[String]) -> Result<()> { ); Ok(()) } + // `punktfunk-host pad-endpoint capture [seconds]` — the receiving half of `tone`. Run + // both at once to exercise render -> engine -> loopback -> pair routing with no game and + // no client attached. + Some("capture") => { + let secs: u32 = args.get(2).and_then(|s| s.parse().ok()).unwrap_or(5); + let endpoint_id = match endpoint_override { + Some(id) => id, + None => match pe::find(idx)? { + Some(ep) if !ep.endpoint_id.is_empty() => ep.endpoint_id, + _ => { + println!("pad-endpoint capture: pad {idx} has no endpoint — run `ensure`"); + return Ok(()); + } + }, + }; + println!("pad-endpoint capture: listening on {endpoint_id} for {secs}s"); + pe::capture_probe(&endpoint_id, secs) + } Some("status") => pe::print_status(idx), _ => anyhow::bail!("usage: punktfunk-host pad-endpoint [--index N]"), } From 3a48cc2470340bbc72308788434edde3dff9fbe3 Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Mon, 3 Aug 2026 12:38:06 +0200 Subject: [PATCH 17/21] test(host/pad-audio): drive either channel pair, so the speaker leg can be proven too MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `pad-endpoint tone` only ever drove the BACK pair, which meant the pad's speaker — the FRONT pair, the other half of the 4-channel split — had never carried a signal end to end. The capture probe's verdict was shaped the same way, and called a perfectly good front-pair run "silent". `--pair front|back|both` picks the pair, and the verdict now reports which pair it SAW rather than judging against an assumed one. Measured on .173, an exact mirror in both directions and no crosstalk either way: --pair back peak_front=0.0000 peak_back=0.5000 back only, channel-exact --pair front peak_front=0.5000 peak_back=0.0000 front only, channel-exact --pair both peak_front=0.5000 peak_back=0.5000 both So the host half of the speaker path is proven to the same standard the haptics path was. What is still unproven is the client rendering the front pair into the pad's own speaker; that needs the phone unlocked, which it no longer is. Host clippy clean; 360 tests pass, the one mgmt display failure reproduces on a clean tree. Co-Authored-By: Claude Opus 5 (1M context) --- .../src/audio/windows/pad_endpoint.rs | 92 +++++++++++++++---- crates/punktfunk-host/src/devtest.rs | 13 ++- 2 files changed, 82 insertions(+), 23 deletions(-) diff --git a/crates/punktfunk-host/src/audio/windows/pad_endpoint.rs b/crates/punktfunk-host/src/audio/windows/pad_endpoint.rs index 0e029c86..3785dc6d 100644 --- a/crates/punktfunk-host/src/audio/windows/pad_endpoint.rs +++ b/crates/punktfunk-host/src/audio/windows/pad_endpoint.rs @@ -1586,10 +1586,55 @@ impl PadLoopbackCapturer { /// directly, so the rest of the chain (loopback capture → gate → Opus → 0xD1 → client → the pad's /// actuators) can be tested in seconds and in isolation from whether any game cooperates. /// -/// The tone goes into the BACK channel pair, because that is the pair the framer routes to the -/// haptics kind — the voice coils. The front pair stays silent, so a pass is felt in the grips and -/// cannot be confused with the pad's speaker. -pub(crate) fn render_test_tone(endpoint_id: &str, seconds: u32, hz: f32) -> Result<()> { +/// Which channel pair carries the tone. The pad's 4 channels split FL|FR|BL|BR: the FRONT pair is +/// the pad's built-in speaker, the BACK pair the voice coils. Driving one and leaving the other +/// silent is what makes a result attributable — the capture (and the client) can then say which +/// kind the framer actually routed, instead of "some audio arrived". +#[derive(Clone, Copy, PartialEq)] +pub(crate) enum TonePair { + /// The pad's built-in speaker. + Front, + /// The voice coils. + Back, + Both, +} + +impl TonePair { + /// Parse the `--pair` devtest argument; anything unrecognised keeps the haptics default. + pub(crate) fn parse(s: &str) -> TonePair { + match s { + "front" | "speaker" => TonePair::Front, + "both" => TonePair::Both, + _ => TonePair::Back, + } + } + pub(crate) fn label(self) -> &'static str { + match self { + TonePair::Front => "FRONT pair (the pad's speaker)", + TonePair::Back => "BACK pair (the voice coils)", + TonePair::Both => "BOTH pairs (speaker + voice coils)", + } + } + /// Does channel `c` (0..4, FL|FR|BL|BR) carry the tone? + fn carries(self, c: usize) -> bool { + match self { + TonePair::Front => c < 2, + TonePair::Back => c >= 2, + TonePair::Both => true, + } + } +} + +/// The tone defaults to the BACK channel pair, because that is the pair the framer routes to the +/// haptics kind — the voice coils. The front pair then stays silent, so a pass is felt in the grips +/// and cannot be confused with the pad's speaker. `--pair front` drives the speaker instead, which +/// is the only way to exercise the speaker kind without a game that renders one. +pub(crate) fn render_test_tone( + endpoint_id: &str, + seconds: u32, + hz: f32, + pair: TonePair, +) -> Result<()> { wasapi::initialize_mta() .ok() .context("initialize COM (MTA) for the tone render")?; @@ -1653,8 +1698,7 @@ pub(crate) fn render_test_tone(endpoint_id: &str, seconds: u32, hz: f32) -> Resu phase -= std::f32::consts::TAU; } for c in 0..PAD_CHANNELS as usize { - // Back pair only — the haptics kind. - let v: f32 = if c >= 2 { s } else { 0.0 }; + let v: f32 = if pair.carries(c) { s } else { 0.0 }; let at = (f * PAD_CHANNELS as usize + c) * 4; bytes[at..at + 4].copy_from_slice(&v.to_le_bytes()); } @@ -1700,20 +1744,28 @@ pub(crate) fn capture_probe(endpoint_id: &str, seconds: u32) -> Result<()> { "pad-endpoint capture: {frames} frames over {seconds}s, peak_front={peak_front:.4} \ peak_back={peak_back:.4}" ); - if frames == 0 { - println!(" VERDICT: FAIL — the capture opened but delivered nothing."); - } else if peak_back <= 0.0001 { - println!( - " VERDICT: silent — capture works, but nothing was rendered into the back pair. \ - Run `pad-endpoint tone` against this endpoint at the same time." - ); - } else if peak_front > 0.0001 { - println!( - " VERDICT: FAIL — front pair carries {peak_front:.4}; the haptics pair is leaking \ - into the pad's speaker." - ); - } else { - println!(" VERDICT: PASS — back pair only, front pair silent (channel-exact)."); + // The capture cannot know which pair the tone was aimed at, so it reports what it SAW rather + // than judging against an assumed one — an earlier haptics-shaped verdict called a perfectly + // good `--pair front` run "silent". + const FLOOR: f32 = 0.0001; + match (frames, peak_front > FLOOR, peak_back > FLOOR) { + (0, _, _) => println!(" VERDICT: FAIL — the capture opened but delivered nothing."), + (_, false, false) => println!( + " VERDICT: silent — capture works, but nothing was rendered. Run `pad-endpoint \ + tone` against this endpoint at the same time." + ), + (_, false, true) => println!( + " VERDICT: BACK pair only (the voice coils), front silent — channel-exact for \ + haptics." + ), + (_, true, false) => println!( + " VERDICT: FRONT pair only (the pad's speaker), back silent — channel-exact for \ + the speaker." + ), + (_, true, true) => println!( + " VERDICT: BOTH pairs carry signal — correct for `--pair both`, otherwise the pairs \ + are leaking into each other." + ), } Ok(()) } diff --git a/crates/punktfunk-host/src/devtest.rs b/crates/punktfunk-host/src/devtest.rs index 91c4acbb..2e815ac5 100644 --- a/crates/punktfunk-host/src/devtest.rs +++ b/crates/punktfunk-host/src/devtest.rs @@ -560,11 +560,18 @@ pub fn pad_endpoint(args: &[String]) -> Result<()> { ep.endpoint_id } }; + // `--pair front` drives the pad's SPEAKER instead of the voice coils — the only way to + // exercise the speaker kind without a game that renders one. + let pair = args + .iter() + .skip_while(|a| *a != "--pair") + .nth(1) + .map_or(pe::TonePair::Back, |s| pe::TonePair::parse(s)); println!( - "pad-endpoint tone: {hz} Hz into the BACK pair (haptics) of {endpoint_id} for \ - {secs}s" + "pad-endpoint tone: {hz} Hz into the {} of {endpoint_id} for {secs}s", + pair.label() ); - pe::render_test_tone(&endpoint_id, secs, hz)?; + pe::render_test_tone(&endpoint_id, secs, hz, pair)?; println!( "pad-endpoint tone: done. A connected client with pad audio enabled should have \ buzzed; the host log shows whether the gate opened." From 0d5e5b436b2d66c0f3ab03a10e3013ce98cf081d Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Mon, 3 Aug 2026 14:35:27 +0200 Subject: [PATCH 18/21] fix(android/pad-audio): pin the uac-host that unmutes the pad MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The pad rendered nothing — not its speaker, not its voice coils — because `uac-host` streamed into a device it never unmuted. It set the sample rate and nothing else; the UAC Feature Unit, where Mute and Volume live, was parsed by nobody. Every counter stayed green throughout: URBs completed, 0 short bytes, 0 URB errors, 0 short writes here, decoded peak 19345. None of them can observe mute, so a muted device is indistinguishable from a working one. Bumps the pin to unom-io/usbfs-iso f3de1fd, which sends SET_CUR Mute=0 and Volume=0 dB to the Feature Unit before the stream starts. With this in, Spider-Man Remastered's haptics reach the physical DualSense through the virtual pad, confirmed by feel on real hardware. Co-Authored-By: Claude Opus 5 (1M context) --- Cargo.lock | 4 ++-- clients/android/native/Cargo.toml | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index b075dc31..82125b9f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4991,7 +4991,7 @@ checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" [[package]] name = "uac-host" version = "0.1.0" -source = "git+https://github.com/unom-io/usbfs-iso?rev=fb01ea69c59e3bf08b3918f53a159287b5187ed2#fb01ea69c59e3bf08b3918f53a159287b5187ed2" +source = "git+https://github.com/unom-io/usbfs-iso?rev=f3de1fd62cec271d07f45664dc464f23e423e721#f3de1fd62cec271d07f45664dc464f23e423e721" dependencies = [ "usbfs-iso", ] @@ -5078,7 +5078,7 @@ dependencies = [ [[package]] name = "usbfs-iso" version = "0.1.0" -source = "git+https://github.com/unom-io/usbfs-iso?rev=fb01ea69c59e3bf08b3918f53a159287b5187ed2#fb01ea69c59e3bf08b3918f53a159287b5187ed2" +source = "git+https://github.com/unom-io/usbfs-iso?rev=f3de1fd62cec271d07f45664dc464f23e423e721#f3de1fd62cec271d07f45664dc464f23e423e721" dependencies = [ "libc", ] diff --git a/clients/android/native/Cargo.toml b/clients/android/native/Cargo.toml index 5e1e661b..1755e47d 100644 --- a/clients/android/native/Cargo.toml +++ b/clients/android/native/Cargo.toml @@ -70,8 +70,8 @@ opus = "0.3" # ecosystem-wide one: https://github.com/unom-io/usbfs-iso # Pinned by revision rather than floating: this is a transport under a real-time deadline and it # should move when we choose to. Becomes a plain version dependency once the crates are published. -uac-host = { git = "https://github.com/unom-io/usbfs-iso", rev = "fb01ea69c59e3bf08b3918f53a159287b5187ed2" } -usbfs-iso = { git = "https://github.com/unom-io/usbfs-iso", rev = "fb01ea69c59e3bf08b3918f53a159287b5187ed2" } +uac-host = { git = "https://github.com/unom-io/usbfs-iso", rev = "f3de1fd62cec271d07f45664dc464f23e423e721" } +usbfs-iso = { git = "https://github.com/unom-io/usbfs-iso", rev = "f3de1fd62cec271d07f45664dc464f23e423e721" } [lints] workspace = true From 2032c48ffa4e8b0d4799128058c0355a8694ee86 Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Mon, 3 Aug 2026 19:44:05 +0200 Subject: [PATCH 19/21] fix(android/pad-audio): a game that only rumbles keeps rumbling MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three faults that between them silence a wired DualSense. The trade was committed without asking whether the host can send pad audio at all. Against every released host — no HOST_CAP_PAD_AUDIO — the renderer claimed the interface, took the pad off wire rumble, and then rendered nothing, with `pad_haptics` defaulting on and no UI to turn it off. The capability is now checked before `sink::open`, so nothing is claimed and nothing is traded. Arming was unconditional, so a speaker-only setup took the motors away too. The speaker pair is channels 0/1 and no rumble write can disturb it; only the haptics lane arms now. And the suppression itself was wrong for the case that matters most: a title driving classic rumble and no haptics audio. Suppressing on "a stream is open" assumed the game's rumble rides the haptics mix, which for such a title is false — it renders no haptics audio at all, so the host's -60 dBFS gate emits nothing on 0xD1 and the pad was left with neither. Ownership is now decided by evidence: the coils belong to haptics only while haptics frames are actually arriving, and to wire rumble otherwise. Frames are stamped on arrival rather than after decode, so a decoder hiccup cannot hand the coils back mid-effect, and concealment does not count as evidence. Liveness is dropped at every teardown, because wire indices are recycled and a stale stamp would let a fresh pad inherit the previous occupant's ownership. Arbitrating on evidence rather than on a prediction about the hardware is deliberate, and the module doc now says why. It used to assert that the coils and the rumble motors are the same physical actuators — "a firmware constraint, not a preference". Nothing establishes that: it traces to one reverse-engineered comment in SDL, whose own modern path sets HAPTICS_SELECT alone with amplitude on ucEnableBits3, which reads more like an independent mute than a shared- actuator interlock. The combination that would settle it — rumble with HAPTICS_SELECT cleared — is emitted by no code anywhere, and nothing here writes it either. The evidence rule is correct under either hypothesis. The liveness clock is 1-based so that 0 stays an unambiguous "never stamped": without it a frame arriving in the process's first millisecond read as never-arrived and handed the coils back mid-effect. Its test caught that. Verified: clippy -p punktfunk-client-android --all-targets --locked -D warnings = 0; 15 tests pass. Owed: the desktop twin of the arbiter, and the coil restore — the Android stop write still asserts HAPTICS_SELECT with zero amplitude, where SDL's all-zero stop restores the audio path. From the 2026-08-03 force-feedback sweep (B4, B5; B6 partly). --- clients/android/native/src/feedback.rs | 14 +- clients/android/native/src/pad_audio.rs | 199 ++++++++++++++++--- clients/android/native/src/session/planes.rs | 1 + 3 files changed, 185 insertions(+), 29 deletions(-) diff --git a/clients/android/native/src/feedback.rs b/clients/android/native/src/feedback.rs index 3ea7adf8..81d97895 100644 --- a/clients/android/native/src/feedback.rs +++ b/clients/android/native/src/feedback.rs @@ -54,12 +54,14 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeNextRumble( // handle. let h = unsafe { &*(handle as *const SessionHandle) }; match h.client.next_rumble_command(PULL_TIMEOUT) { - // A pad rendering tier-A audio must never see wire rumble. `DsDevice` sets - // `valid_flag0` bit 1 (`HAPTICS_SELECT`) on every rumble write, and that bit - // *disables* audio haptics — so one replayed command would silently mute the voice - // coils the 0xD1 stream is driving, for the rest of the session. Dropping it here - // (rather than in Kotlin) keeps the rule next to the reason, and covers every caller. - Ok(cmd) if crate::pad_audio::is_tier_a((cmd.pad & 0xF) as u8) => -1, + // A pad whose coils are ACTIVELY being driven by the 0xD1 haptics stream must not see + // wire rumble: `DsDevice` sets `valid_flag0` bit 1 (`HAPTICS_SELECT`) on every rumble + // write, and that bit disables the audio-haptics path — so one replayed command would + // mute the coils the stream is driving. Gating on *arrival of haptics frames* rather + // than on "a stream is open" is what keeps a rumble-only title working: it renders no + // haptics audio, so the host emits nothing on 0xD1 and the pad keeps its rumble. + // Dropping it here rather than in Kotlin keeps the rule next to the reason. + Ok(cmd) if crate::pad_audio::haptics_owns_coils((cmd.pad & 0xF) as u8) => -1, Ok(cmd) => { (jlong::from(cmd.pad & 0xF) << 49) | (jlong::from(cmd.backstop_ms.min(0xFFFF) as u16) << 32) diff --git a/clients/android/native/src/pad_audio.rs b/clients/android/native/src/pad_audio.rs index 1417522c..57bccb3b 100644 --- a/clients/android/native/src/pad_audio.rs +++ b/clients/android/native/src/pad_audio.rs @@ -20,13 +20,25 @@ //! underrun-free floor is **4 ms in flight** — including under eight-core load with the SoC in //! severe thermal throttling. //! -//! # The firmware exclusivity that shapes everything here +//! # The exclusivity that shapes everything here, and what is actually known about it //! -//! `valid_flag0` bit 1 (`HAPTICS_SELECT`) *disables* audio haptics and selects classic rumble, and -//! Linux's `hid-playstation` sets it on every force-feedback update — as does SDL, and as does our -//! own [`crate::feedback`] path. **Tier A and tier C are mutually exclusive in the pad's firmware**, -//! so a pad rendering this stream must have its wire rumble suppressed rather than mixed. The -//! arbitration is a selection, never a blend. +//! `valid_flag0` bit 1 (`HAPTICS_SELECT`) disables the audio-haptics path, and every rumble write +//! that exists — `hid-playstation`, SDL, and our own [`crate::feedback`] path via `DsDevice` — +//! asserts it. So haptics and classic rumble cannot both drive the coils **as coded**, and the +//! arbitration selects rather than blends. +//! +//! What is NOT established is the stronger claim this module used to make: that the coils and the +//! rumble motors are the same physical actuators, exclusive *in the firmware*. No teardown, vendor +//! document or measurement supports it here; it traces to one reverse-engineered comment in SDL, +//! and SDL's own modern path sets `HAPTICS_SELECT` **alone** (amplitude rides `ucEnableBits3`), +//! which reads more like an independent mute for the audio path than a shared-actuator interlock. +//! The combination that would settle it — rumble asserted with `HAPTICS_SELECT` CLEARED — is +//! emitted by no code anywhere, so nothing here writes it either. +//! +//! The arbitration is therefore built on **evidence, not prediction**: haptics owns the coils only +//! while haptics frames are actually arriving (see [`haptics_owns_coils`]). That is correct under +//! either hypothesis, and it is what keeps a rumble-only title rumbling — it renders no haptics +//! audio, the host's silence gate emits nothing, and the pad simply keeps its motors. use std::collections::VecDeque; @@ -92,17 +104,77 @@ pub(crate) fn set_tier_a(pad: u8, on: bool) { } } -/// Is this pad rendering tier-A audio, and therefore forbidden from receiving wire rumble? +/// Is this pad's HAPTICS lane armed — i.e. did a renderer open a stream that wants the coils? /// -/// **This is a firmware constraint, not a preference.** `valid_flag0` bit 1 (`HAPTICS_SELECT`) -/// *disables* audio haptics and selects classic rumble, and `DsDevice` sets it on every rumble -/// write — as Linux's `hid-playstation` and SDL both do. So a single rumble command reaching a -/// tier-A pad silently mutes the voice coils this stream drives, for the rest of the session. -/// Tier A and tier C are mutually exclusive **in the pad**: the arbitration selects, never blends. -pub(crate) fn is_tier_a(pad: u8) -> bool { +/// Armed is necessary but NOT sufficient to take the pad off wire rumble; see +/// [`haptics_owns_coils`]. Speaker-only rendering never arms this: the speaker pair is a +/// different pair of channels and cannot be disturbed by a rumble write. +pub(crate) fn haptics_armed(pad: u8) -> bool { TIER_A_PADS.load(std::sync::atomic::Ordering::Relaxed) & (1u32 << (pad & 0x0f)) != 0 } +/// Last instant a real (non-concealed) haptics frame was decoded for each pad, as ms on the +/// process clock; `0` = never. Written by the render thread, read by the rumble poll thread. +static HAPTICS_SEEN_MS: [std::sync::atomic::AtomicU64; 16] = + [const { std::sync::atomic::AtomicU64::new(0) }; 16]; + +/// Process epoch for [`HAPTICS_SEEN_MS`] — `Instant` is not `const`-constructible. +fn epoch() -> std::time::Instant { + static EPOCH: std::sync::OnceLock = std::sync::OnceLock::new(); + *EPOCH.get_or_init(std::time::Instant::now) +} + +/// Milliseconds since [`epoch`], **1-based**. The `+ 1` reserves `0` as an unambiguous "never +/// stamped" sentinel: without it, a haptics frame arriving in the first millisecond of the process +/// would stamp `0` and be read as never-arrived, handing the coils to wire rumble mid-effect. +/// Stamp and comparison share this clock, so the offset cancels and adds no skew. +fn now_ms() -> u64 { + epoch().elapsed().as_millis() as u64 + 1 +} + +/// A pad is only silent for haptics once the host has stopped sending for longer than its own +/// silence gate can explain. The host gates at −60 dBFS with a 250 ms hangover, so a title that +/// renders no haptics audio emits NOTHING on the 0xD1 plane; doubling the hangover covers wire +/// jitter and concealment without letting a real gap read as "live". +const HAPTICS_IDLE_MS: u64 = 500; + +/// Stamp a decoded haptics frame. Concealment (PLC) deliberately does not count — filling a gap +/// is not evidence that the game is still driving the coils. +#[cfg_attr(not(target_os = "android"), allow(dead_code))] +pub(crate) fn note_haptics_frame(pad: u8) { + HAPTICS_SEEN_MS[(pad & 0x0f) as usize].store(now_ms(), std::sync::atomic::Ordering::Relaxed); +} + +/// Clear a pad's liveness (slot teardown). Wire indices are recycled, so a stale stamp would let +/// a fresh pad inherit the previous one's ownership. +#[cfg_attr(not(target_os = "android"), allow(dead_code))] +pub(crate) fn clear_haptics_liveness(pad: u8) { + HAPTICS_SEEN_MS[(pad & 0x0f) as usize].store(0, std::sync::atomic::Ordering::Relaxed); +} + +/// The arbitration, pure and testable: haptics owns the coils only while frames are ACTUALLY +/// arriving. `seen_ms == 0` (never) is never live. +pub(crate) fn haptics_owns_at(armed: bool, seen_ms: u64, now: u64) -> bool { + armed && seen_ms != 0 && now.saturating_sub(seen_ms) < HAPTICS_IDLE_MS +} + +/// Does this pad's haptics stream currently own the coils, so wire rumble must stand down? +/// +/// Arbitrating on **evidence** rather than on a prediction about the hardware is deliberate. Every +/// rumble write this tree emits asserts `valid_flag0` bit 1 (`HAPTICS_SELECT`), which disables the +/// audio-haptics path, so the two cannot both drive the coils *as coded* — whatever the firmware +/// would allow. But a title that never renders haptics audio produces no 0xD1 frames at all, and +/// suppressing its rumble on the assumption that "the stream carries the feedback" silences it +/// outright. Frame arrival is the signal that tells the two cases apart, and it costs nothing. +pub(crate) fn haptics_owns_coils(pad: u8) -> bool { + let i = (pad & 0x0f) as usize; + haptics_owns_at( + haptics_armed(pad), + HAPTICS_SEEN_MS[i].load(std::sync::atomic::Ordering::Relaxed), + now_ms(), + ) +} + // ---- the 4-channel mixer --------------------------------------------------------------------- /// Interleave the two independent stereo streams into one 4-channel frame stream. @@ -404,6 +476,7 @@ impl Drop for PadAudio { // Belt and braces: the thread clears these itself on the way out, but if it died in a way // that skipped that, leaving the pad off wire rumble would cost the user all feedback. set_tier_a(self.pad, false); + clear_haptics_liveness(self.pad); } } @@ -480,6 +553,16 @@ fn render( haptics: bool, speaker: bool, ) { + // A host that cannot send 0xD1 will never render anything here, so opening the stream would + // claim the interface and (before the arbitration below) take the pad off wire rumble in + // exchange for nothing. Against every released host this is the DEFAULT path — `pad_haptics` + // is on with no UI to turn it off — so without this gate a wired DualSense simply stops + // rumbling. Checked before `sink::open` so the iso interface is never claimed pointlessly. + if client.host_caps() & punktfunk_core::quic::HOST_CAP_PAD_AUDIO == 0 { + log::warn!("pad audio: host cannot send it (no HOST_CAP_PAD_AUDIO) — pad {pad} stays on wire rumble"); + drain_until_stop(client, stop); + return; + } match sink::open(dev) { Ok(mut playback) => { log::info!( @@ -495,13 +578,17 @@ fn render( // kernel that refuses the claim, leave the user with no haptics of any kind. let caps = (if haptics { 0x01 } else { 0 }) | (if speaker { 0x02 } else { 0 }); client.set_pad_audio_caps(pad, caps); - set_tier_a(pad, true); + // Arm the HAPTICS lane only — a speaker-only setup drives channels 0/1, which no + // rumble write can disturb, so taking the motors away would kill rumble with nothing + // rendering haptics in exchange. + set_tier_a(pad, haptics); - pump(client, stop, haptics, speaker, &mut playback); + pump(client, stop, pad, haptics, speaker, &mut playback); // Give the pad back to wire rumble before this thread goes away. client.set_pad_audio_caps(pad, 0); set_tier_a(pad, false); + clear_haptics_liveness(pad); } Err(e) => { // A kernel that refuses the claim: some OEM kernels do, and there is no app-side fix. @@ -531,6 +618,7 @@ fn drain_until_stop(client: &NativeClient, stop: &AtomicBool) { fn pump( client: &NativeClient, stop: &AtomicBool, + pad: u8, haptics: bool, speaker: bool, playback: &mut uac_host::Playback<'_>, @@ -580,6 +668,14 @@ fn pump( continue; } + // A real haptics frame is the evidence that the game is driving the coils, and therefore + // that wire rumble must stand down for this pad (see `haptics_owns_coils`). Stamped on + // arrival rather than after decode so a decoder hiccup cannot hand the coils back + // mid-effect; concealment never reaches here, so PLC still does not count. + if frame.kind == PAD_AUDIO_KIND_HAPTICS { + note_haptics_frame(pad); + } + frames_in += 1; let k = usize::from(frame.kind).min(1); let st = match &mut streams[k] { @@ -753,15 +849,15 @@ mod tests { // A rumble command reaching a tier-A pad mutes its coils for the session, so this gate // has to be exact rather than approximately right. set_tier_a(3, true); - assert!(is_tier_a(3)); - assert!(!is_tier_a(4)); + assert!(haptics_armed(3)); + assert!(!haptics_armed(4)); set_tier_a(4, true); - assert!(is_tier_a(3) && is_tier_a(4)); + assert!(haptics_armed(3) && haptics_armed(4)); set_tier_a(3, false); - assert!(!is_tier_a(3), "clearing one pad must not clear another"); - assert!(is_tier_a(4)); + assert!(!haptics_armed(3), "clearing one pad must not clear another"); + assert!(haptics_armed(4)); set_tier_a(4, false); - assert!(!is_tier_a(4)); + assert!(!haptics_armed(4)); } #[test] @@ -769,9 +865,66 @@ mod tests { // The wire pad space is 4 bits; an out-of-range index must not shift the mask into // undefined territory (a shift >= 32 is a panic in debug and garbage in release). set_tier_a(0x1f, true); - assert!(is_tier_a(0x0f), "0x1f and 0x0f are the same wire slot"); + assert!(haptics_armed(0x0f), "0x1f and 0x0f are the same wire slot"); set_tier_a(0x0f, false); - assert!(!is_tier_a(0x1f)); + assert!(!haptics_armed(0x1f)); + } + + /// The arbitration that keeps a rumble-only game working. Armed alone is NOT ownership: a + /// title that never renders haptics audio produces no frames, so the host's silence gate + /// emits nothing on 0xD1 and the pad must keep its motors. + #[test] + fn haptics_owns_the_coils_only_while_frames_actually_arrive() { + // Never seen a frame — armed, but the game is not driving the coils. + assert!( + !haptics_owns_at(true, 0, 10_000), + "an armed pad that has never received a frame must keep its rumble" + ); + // A frame just arrived: haptics owns, rumble stands down. + assert!(haptics_owns_at(true, 10_000, 10_000)); + // Still inside the idle window (the host's own 250 ms hangover, doubled). + assert!(haptics_owns_at(true, 10_000, 10_000 + HAPTICS_IDLE_MS - 1)); + // The stream went quiet: the coils go back to wire rumble. + assert!( + !haptics_owns_at(true, 10_000, 10_000 + HAPTICS_IDLE_MS), + "the coils must return to rumble once haptics stops arriving" + ); + // Not armed (speaker-only, or no renderer): frames or not, rumble always owns. + assert!(!haptics_owns_at(false, 10_000, 10_000)); + } + + /// Clock skew must never strand a pad in the suppressed state. + #[test] + fn a_stamp_ahead_of_now_does_not_wrap_the_idle_window() { + // The clock is monotonic so this should not arise, but an unsigned underflow would wrap + // to ~2^64 ms and read as EXPIRED — handing the coils back mid-effect. `saturating_sub` + // pins it to 0 (still live), and it self-corrects once the clock catches up. + assert!(haptics_owns_at(true, 10_000, 9_000)); + // The 1-based clock is what makes this distinguishable: a frame stamped in the process's + // first millisecond must read as LIVE, not as never-stamped. + assert!( + haptics_owns_at(true, 1, 1), + "a frame stamped at t=0 must not be mistaken for never-stamped" + ); + assert!( + !haptics_owns_at(true, 0, 0), + "never-seen stays never-seen at t=0" + ); + } + + /// Teardown drops the liveness stamp: wire indices are recycled, and a fresh pad must not + /// inherit the previous occupant's ownership of the coils. + #[test] + fn clearing_liveness_hands_the_coils_back() { + set_tier_a(2, true); + note_haptics_frame(2); + assert!(haptics_owns_coils(2)); + clear_haptics_liveness(2); + assert!( + !haptics_owns_coils(2), + "a cleared stamp must release the coils" + ); + set_tier_a(2, false); } #[test] diff --git a/clients/android/native/src/session/planes.rs b/clients/android/native/src/session/planes.rs index 54fb79db..2fef14ac 100644 --- a/clients/android/native/src/session/planes.rs +++ b/clients/android/native/src/session/planes.rs @@ -559,6 +559,7 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeStopPadAudi // the host stops sending 0xD1 before tier C resumes, so the two never overlap. h.client.set_pad_audio_caps(pad as u8, 0); crate::pad_audio::set_tier_a(pad as u8, false); + crate::pad_audio::clear_haptics_liveness(pad as u8); } } }) From 173be61213b824f5778bfa8289183ea1d1cc354b Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Tue, 4 Aug 2026 20:13:45 +0200 Subject: [PATCH 20/21] fix(android/pad-audio): an unplugged pad comes back whole, and an idle one arrives at all MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three faults on the default capture path, all of them silent. Unplug tore nothing down. onLinkClosed() is the real unplug signal — silence never is, an idle pad simply stops streaming — but it skipped the pad-audio teardown that stop() performs, so the render thread went on writing to a descriptor whose device was gone, the renderer's own UsbDeviceConnection leaked, and because the started flag stayed set and the native tier-A registry stayed armed for that wire index, the pad came back with neither pad audio nor wire rumble: the next occupant of the index inherited a suppression nothing would lift. The teardown is now one shared step and runs on both paths, before the slot is released, since the renderer is addressed by the index the release forgets. The wire slot was claimed on the first parsed report. A captured pad that reports nothing then gave the host no arrival, so no virtual pad, no pad-audio capability, no 0xD1 — a renderer sitting at zero frames, which is exactly what a broken pipeline looks like, and it took a physical replug to clear. A pad that reports nothing is still a pad, so the slot is claimed when the capture engages; the first report stays as the fallback for a claim that found no free index. This also puts the common claim on the main thread, which is the contract GamepadRouter.openExternal documents and the link thread was quietly breaking. And the two settings had no UI. The model and its persistence existed but no toggle did, so pad_speaker could only be set by hand-editing shared_prefs, and pad_haptics — which decides whether the pad trades wire rumble at all — could not be turned off by anyone who hit trouble with it. Both are now rows under the DualSense passthrough toggle, gated on it, since neither does anything to an uncaptured pad. The padHaptics doc no longer describes the arbitration as a selection forced by a firmware-level mutual exclusion. It is decided on evidence — the coils belong to haptics only while haptics frames arrive — which is what 2032c48f changed it to and why a rumble-only title keeps rumbling. --- .../main/kotlin/io/unom/punktfunk/Settings.kt | 8 +- .../io/unom/punktfunk/SettingsScreen.kt | 16 ++ .../kotlin/io/unom/punktfunk/kit/DsCapture.kt | 144 +++++++++++------- 3 files changed, 110 insertions(+), 58 deletions(-) diff --git a/clients/android/app/src/main/kotlin/io/unom/punktfunk/Settings.kt b/clients/android/app/src/main/kotlin/io/unom/punktfunk/Settings.kt index e29ce074..bde281b9 100644 --- a/clients/android/app/src/main/kotlin/io/unom/punktfunk/Settings.kt +++ b/clients/android/app/src/main/kotlin/io/unom/punktfunk/Settings.kt @@ -160,10 +160,10 @@ data class Settings( * Render the host's DualSense **voice-coil haptics** on a captured USB pad (tier A). * * The pad's own 4-channel audio device carries them, driven directly over usbfs — Android's - * audio framework denylists that device by VID/PID, so there is no supported route to it. When - * this is on and the pad is captured, wire rumble for that pad is SUPPRESSED rather than mixed: - * the DualSense's firmware treats audio haptics and classic rumble as mutually exclusive, so - * the arbitration is a selection. Off, or on an uncaptured/Bluetooth pad, the pad stays on + * audio framework denylists that device by VID/PID, so there is no supported route to it. The + * two kinds are arbitrated rather than mixed, and on evidence: wire rumble is suppressed only + * while haptics frames are actually arriving, so a title that drives classic rumble and sends + * no haptics audio keeps rumbling. Off, or on an uncaptured/Bluetooth pad, the pad stays on * ordinary rumble (tier C), which on this client already drives the same actuators. */ val padHaptics: Boolean = true, diff --git a/clients/android/app/src/main/kotlin/io/unom/punktfunk/SettingsScreen.kt b/clients/android/app/src/main/kotlin/io/unom/punktfunk/SettingsScreen.kt index 4311bc01..105f7bc9 100644 --- a/clients/android/app/src/main/kotlin/io/unom/punktfunk/SettingsScreen.kt +++ b/clients/android/app/src/main/kotlin/io/unom/punktfunk/SettingsScreen.kt @@ -877,6 +877,22 @@ private fun ControllerSettings(s: Settings, update: (Settings) -> Unit, onOpenCo enabled = s.gamepadForwarding, onCheckedChange = { on -> update(s.copy(dsCapture = on)) }, ) + // Both only ever apply to a captured pad, so they follow that row and gate on it. + ToggleRow( + title = "Controller haptics", + subtitle = "Play the host's fine-grained DualSense haptics on the pad itself — " + + "the pad keeps ordinary rumble for games that don't send them", + checked = s.padHaptics, + enabled = s.gamepadForwarding && s.dsCapture, + onCheckedChange = { on -> update(s.copy(padHaptics = on)) }, + ) + ToggleRow( + title = "Controller speaker", + subtitle = "Play audio the game sends to the controller's own speaker", + checked = s.padSpeaker, + enabled = s.gamepadForwarding && s.dsCapture, + onCheckedChange = { on -> update(s.copy(padSpeaker = on)) }, + ) } } } diff --git a/clients/android/kit/src/main/kotlin/io/unom/punktfunk/kit/DsCapture.kt b/clients/android/kit/src/main/kotlin/io/unom/punktfunk/kit/DsCapture.kt index b4da2f31..8586541a 100644 --- a/clients/android/kit/src/main/kotlin/io/unom/punktfunk/kit/DsCapture.kt +++ b/clients/android/kit/src/main/kotlin/io/unom/punktfunk/kit/DsCapture.kt @@ -23,8 +23,9 @@ import android.view.InputDevice * Input: parse ([DsDevice.parseState]) → typed mirror on an [GamepadRouter.ExternalPad] (buttons * diffed, axes on-change — the exit chord participates like any pad) + the rich plane (touch * normalized to the wire's 0..65535 screen space on-change; motion forwarded per report in raw - * device units, the wire's contract). The wire slot is claimed lazily on the FIRST parsed report - * and freed on unplug/[stop], so indices never leak. + * device units, the wire's contract). The wire slot is claimed when the capture engages, with the + * first parsed report as the fallback for a claim that found no free index, and freed on + * unplug/[stop], so indices never leak. * * Feedback: implements [GamepadFeedback.PadFeedbackSink] — rumble / trigger / lightbar / player * LED events addressed to this pad's wire index become USB output reports on the physical pad @@ -81,10 +82,9 @@ class DsCapture( /** * Tier-A pad audio, bound by the app layer (which owns the session handle). * - * [start] is called once the router has assigned this pad a wire index — not at claim time, - * because the index does not exist until the first report arrives and the host addresses the - * `0xD1` stream by that index. [stop] is called **before** the USB link closes, and must not - * return until nothing is still writing to the descriptor. + * [start] is called once the router has assigned this pad a wire index, which the host uses to + * address the `0xD1` stream. [stop] is called **before** the USB link closes — on [stop] and on + * unplug alike — and must not return until nothing is still writing to the descriptor. */ interface PadAudioHook { fun start(pad: Int, fd: Int) @@ -133,6 +133,7 @@ class DsCapture( // (the same init hid-playstation/SDL send on open). if (m != DsDevice.Model.DUALSHOCK4) usb.writeRaw(0, DsDevice.ds5InitReport(m)) Log.i(TAG, "Sony pad captured over USB: PID=0x%04x model=%s".format(dev.productId, m)) + ensureSlot(m) onActiveChanged?.invoke(true) return true } @@ -142,14 +143,7 @@ class DsCapture( // Before anything touches the link: the pad-audio renderer borrows this connection's // descriptor, and `usb.stop()` closes it. The hook does not return until its thread is // joined, so ordering this first is what makes the borrow sound. - if (padAudioStarted) { - padAudioStarted = false - // stop() joins the render thread, so nothing is using the descriptor after it returns - // — only then is it safe to close the connection that owns it. - pad?.let { padAudio?.stop(it.index) } - padAudioConn?.close() - padAudioConn = null - } + stopPadAudio() val m = model if (m != null) { // The interfaces are about to release with the kernel driver still detached — a @@ -170,52 +164,94 @@ class DsCapture( private fun onReport(report: ByteArray, len: Int) { val m = model ?: return if (!DsDevice.parseState(m, report, len, state)) return - val p = pad ?: router.openExternal(m.pref)?.also { - pad = it - // The wire index exists from here on, and the host addresses pad audio by it. Fired on - // the link thread, once per capture. - if (!padAudioStarted && padAudio != null) { - // A dedicated connection, NOT usb.fileDescriptor — see padAudioConn. - val conn = usb.openAuxConnection() - val fd = conn?.fileDescriptor ?: -1 - if (fd >= 0) { - padAudioConn = conn - padAudioStarted = true - // Real-world self test, opt-in: `adb shell setprop debug.punktfunk.pad_audio_selftest 3` - // drives the voice coils for N seconds through the actual client path before - // the renderer takes over — the one check that proves the descriptor, the - // interface claim and the write path all work on THIS device, without needing - // a host to be streaming. Same convention as debug.punktfunk.force_parts. - val secs = runCatching { - Class.forName("android.os.SystemProperties") - .getMethod("get", String::class.java, String::class.java) - .invoke(null, "debug.punktfunk.pad_audio_selftest", "0") as String - }.getOrNull()?.toIntOrNull() ?: 0 - if (secs > 0) { - // Diagnostic mode: the self test OWNS this descriptor for the capture, and - // the renderer must not also drive it — two engines on one usbfs - // descriptor reap each other's completions, which is precisely the fault - // this test exists to expose. - Thread({ - val r = NativeBridge.nativePadAudioSelfTest(fd, secs, 60) - Log.i(TAG, "pad audio self-test → ${if (r > 0) "PASS ($r frames)" else "FAIL ($r)"}") - }, "pf-pad-selftest").start() - } else { - padAudio?.start(it.index, fd) - } - } else { - conn?.close() - Log.w(TAG, "pad audio: could not open a second USB connection") - } - } - Log.i(TAG, "captured $m → wire pad ${it.index}") - } ?: return // all 16 wire indices taken — drop until one frees + // Normally claimed already, at capture time; this is the retry for a capture that engaged + // while every wire index was taken. + val p = pad ?: ensureSlot(m) ?: return // all 16 taken — drop until one frees mirrorTyped(p) mirrorRich(p, m) } + /** + * Claim this capture's wire slot and start pad audio on it. Idempotent; null when all 16 + * indices are taken. + * + * Claimed when the capture engages rather than on the first report, because a pad that reports + * nothing is still a pad: with the lazy claim, a captured-but-silent pad left the host with no + * arrival, hence no virtual pad, no pad-audio capability and so no `0xD1` — a renderer sitting + * at zero frames, indistinguishable from a broken pipeline (it took a physical replug to + * clear). Callable from the main thread (capture start) and the link thread (the fallback). + */ + @Synchronized + private fun ensureSlot(m: DsDevice.Model): GamepadRouter.ExternalPad? { + pad?.let { return it } + val p = router.openExternal(m.pref) ?: return null + pad = p + Log.i(TAG, "captured $m → wire pad ${p.index}") + // The wire index exists from here on, and the host addresses pad audio by it. + startPadAudio(p.index) + return p + } + + /** Hand the renderer its own descriptor. Caller holds the monitor; fires once per capture. */ + private fun startPadAudio(index: Int) { + val hook = padAudio ?: return + if (padAudioStarted) return + // A dedicated connection, NOT usb.fileDescriptor — see padAudioConn. + val conn = usb.openAuxConnection() + val fd = conn?.fileDescriptor ?: -1 + if (fd < 0) { + conn?.close() + Log.w(TAG, "pad audio: could not open a second USB connection") + return + } + padAudioConn = conn + padAudioStarted = true + // Real-world self test, opt-in: `adb shell setprop debug.punktfunk.pad_audio_selftest 3` + // drives the voice coils for N seconds through the actual client path before the renderer + // takes over — the one check that proves the descriptor, the interface claim and the write + // path all work on THIS device, without needing a host to be streaming. Same convention as + // debug.punktfunk.force_parts. + val secs = runCatching { + Class.forName("android.os.SystemProperties") + .getMethod("get", String::class.java, String::class.java) + .invoke(null, "debug.punktfunk.pad_audio_selftest", "0") as String + }.getOrNull()?.toIntOrNull() ?: 0 + if (secs > 0) { + // Diagnostic mode: the self test OWNS this descriptor for the capture, and the renderer + // must not also drive it — two engines on one usbfs descriptor reap each other's + // completions, which is precisely the fault this test exists to expose. + Thread({ + val r = NativeBridge.nativePadAudioSelfTest(fd, secs, 60) + Log.i(TAG, "pad audio self-test → ${if (r > 0) "PASS ($r frames)" else "FAIL ($r)"}") + }, "pf-pad-selftest").start() + } else { + hook.start(index, fd) + } + } + + /** + * Stop the renderer, then close the connection whose descriptor it borrows — in that order. + * + * Runs on [stop] and on unplug alike. Skipping it on unplug left the render thread writing to a + * descriptor whose device was gone, leaked the connection, and — because the started flag stayed + * set and the native tier-A registry stayed armed for that index — cost the pad both its pad + * audio and its wire rumble on the way back in. + */ + @Synchronized + private fun stopPadAudio() { + if (!padAudioStarted) return + padAudioStarted = false + // The hook's stop joins the render thread, so nothing is using the descriptor once it + // returns — only then is it safe to close the connection that owns it. + pad?.let { padAudio?.stop(it.index) } + padAudioConn?.close() + padAudioConn = null + } + private fun onLinkClosed() { Log.i(TAG, "Sony USB link closed (unplug)") + // Before releaseSlot(), which forgets the wire index the renderer is addressed by. + stopPadAudio() disarmBackstop() val wasActive = model != null model = null From d27e62f7c9fbc0e4f56699962ba6410209805ed2 Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Tue, 4 Aug 2026 23:55:47 +0200 Subject: [PATCH 21/21] fix(pad-audio): close the twelve findings the sweep left open on this branch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Everything the 2026-08-03 haptics sweep filed against the pad-audio branch (P2 + P3). Four of them are the difference between a feature that works and one that fails silently. **B6 — nothing ever un-muted the coils.** Every rumble report asserts `HAPTICS_SELECT`, which is SDL's "disable audio haptics" bit: the firmware mutes the very voice coils the 0xD1 stream drives. No code anywhere cleared it again, so ONE rumble left tier-A haptics silent for the rest of that pad's life — no error, nothing in a log, and the host happily streaming into a muted actuator. `DsDevice.ds5AudioHapticsReport` is the documented undo (flag0 with both bits clear); written EP0-direct when the stream starts and again after a rumble stop while a stream is live, because the stop report re-mutes on its way past. **B10 — the desktop mix could reach a controller's coils.** Pad endpoints were filtered out inside `plan()` only. The watchdog, Follow mode and the parked default all go through `judge_default`, which classifies by NAME — and a pad endpoint is deliberately stamped "DualSense Wireless Controller" so games treat it as the pad's speaker. No name rule could ever catch one. It now refuses them by identity. **B27 — an out-of-range pad aliased onto a real slot.** The 0xCD plane's pad is the only u16 index and every consumer narrowed it with `as u8` on an assumption nothing enforced, so wire pad 256 steered pad 0's speaker volumes. Rejected at the decoder, which makes the narrowings lossless by construction. An existing test had pinned the bug in place, asserting that wire pad 513 round-trips; corrected, plus a test for the 256→0 alias specifically. **B7 — caps that arrived late were never announced.** The renderer commits the tier-A trade only once its sink opens, which is well past the arrival burst's two 100 ms ticks, and `set_pad_audio_caps` only stored an atomic. The client believed it had pad audio while the host emitted nothing. The input task now compares the live registry against what the last arrival actually carried and re-arms the burst itself — no new plumbing, and no extra traffic when nothing changed. The rest: `needs_aeb_kick` is finally ACTED on (R4) — a stored-but-not-served endpoint is declined rather than opened, because `AUTOCONVERTPCM` makes it succeed and mis-route; a failed provisioning no longer latches `PROVISIONED` for the process lifetime (R5), and `host_cap` retries, so a host that started while the audio stack was busy recovers at the next connect instead of the next reboot; the loopback init timeout reaps its thread instead of detaching one per ~2 s reopen (R6); kind-change restarts are bounded (R3) since the trigger is a client-sent arrival; the devtest uses the endpoint's real channel mask (B11) instead of letting wasapi derive 0x0F against the endpoint's 0x33; the render loop asks `is_session_ended()` rather than spinning at nice -16 (R12); short writes are counted and reported instead of dropping the tail in silence (R13); and a frame addressed to another pad is dropped before it can seed the gap tracker from a foreign sequence space (R14). Verified: punktfunk-host clippy -D warnings **0 on a real Windows box**; Linux/amd64 clippy 0 with **589 tests** (pf-client-core 114, pf-inject 101, punktfunk-client-android 20, punktfunk-core 345+1+8); Android :kit: tests + :app: compile green; fmt clean. Six punktfunk-host tests fail on that Windows box. FIVE fail identically on a tree with no pad-audio code at all (QUIC `Rejected(SetupFailed)` — the box's network environment); the sixth passes 3/3 in isolation and only failed under the parallel run, on a locally-bound ephemeral port. Neither is this change. Still owed: on-glass. This is a hardware feature and none of it has been on a real DualSense since the merge. --- .../kotlin/io/unom/punktfunk/kit/DsCapture.kt | 22 ++++++ .../kotlin/io/unom/punktfunk/kit/DsDevice.kt | 15 ++++ clients/android/native/src/pad_audio.rs | 64 +++++++++++++-- crates/pf-client-core/src/gamepad.rs | 3 +- crates/pf-inject/src/inject/hidout_dedup.rs | 13 ++-- crates/punktfunk-core/src/abi.rs | 5 +- .../src/client/pump/input_task.rs | 28 ++++++- crates/punktfunk-core/src/quic/datagram.rs | 70 +++++++++++++++-- .../src/audio/windows/pad_endpoint.rs | 77 ++++++++++++++++++- .../src/audio/windows/wasapi_cap.rs | 14 +++- .../punktfunk-host/src/audio/wiring_plan.rs | 14 ++-- crates/punktfunk-host/src/native/input.rs | 25 ++++++ crates/punktfunk-host/src/native/pad_audio.rs | 21 +++++ 13 files changed, 330 insertions(+), 41 deletions(-) diff --git a/clients/android/kit/src/main/kotlin/io/unom/punktfunk/kit/DsCapture.kt b/clients/android/kit/src/main/kotlin/io/unom/punktfunk/kit/DsCapture.kt index 67d87916..a1c83fa7 100644 --- a/clients/android/kit/src/main/kotlin/io/unom/punktfunk/kit/DsCapture.kt +++ b/clients/android/kit/src/main/kotlin/io/unom/punktfunk/kit/DsCapture.kt @@ -230,10 +230,28 @@ class DsCapture( Log.i(TAG, "pad audio self-test → ${if (r > 0) "PASS ($r frames)" else "FAIL ($r)"}") }, "pf-pad-selftest").start() } else { + // B6: hand the coils back before the first haptics frame. Any rumble earlier in this + // session asserted HAPTICS_SELECT, which firmware-mutes them, and nothing else ever + // clears it — so without this the stream renders into a muted actuator and looks for + // all the world like the host is sending nothing. + restoreAudioHaptics() hook.start(index, fd) } } + /** + * B6: clear the rumble/haptics-select bits so the pad's voice coils answer the audio-haptics + * path again. EP0-direct, like the other out-of-band writes here: this has to land even when + * the interrupt-OUT queue is busy or draining, and it is idempotent. + */ + private fun restoreAudioHaptics() { + val m = model ?: return + if (m == DsDevice.Model.DUALSHOCK4) return // no voice coils, no audio-haptics path + if (!usb.writeControl(DsDevice.ds5AudioHapticsReport(m))) { + Log.w(TAG, "pad audio: could not hand the coils back to audio haptics") + } + } + /** * Stop the renderer, then close the connection whose descriptor it borrows — in that order. * @@ -349,6 +367,10 @@ class DsCapture( // write — as this used to — meant a discarded stop left the motors running with // nothing scheduled to try again; a USB pad holds its last level until told zero. if (sent) disarmBackstop() else armBackstop(STOP_RETRY_MS) + // B6: the stop report just re-asserted HAPTICS_SELECT on its way past, so if a + // haptics stream is live the coils it drives were muted by the very write that + // silenced the motors. Give them back. + if (sent && padAudioStarted) restoreAudioHaptics() } } diff --git a/clients/android/kit/src/main/kotlin/io/unom/punktfunk/kit/DsDevice.kt b/clients/android/kit/src/main/kotlin/io/unom/punktfunk/kit/DsDevice.kt index 71ae86af..af2f8a31 100644 --- a/clients/android/kit/src/main/kotlin/io/unom/punktfunk/kit/DsDevice.kt +++ b/clients/android/kit/src/main/kotlin/io/unom/punktfunk/kit/DsDevice.kt @@ -276,6 +276,21 @@ object DsDevice { * the classic compat-vibration path AND `VIBRATION2` (firmware ≥ 2.24's full-range replot; * older firmware ignores the unknown flag2 bit) — the host parser accepts either. */ + /** + * B6: hand the voice coils back to the audio-haptics path. + * + * Every [ds5RumbleReport] asserts `HAPTICS_SELECT` (flag0 bit1), which is SDL's + * "disable audio haptics" bit — the firmware mutes the coils the 0xD1 haptics stream drives. + * Until now NOTHING ever cleared it again, so a single rumble anywhere in a session left tier-A + * haptics silent for the rest of that pad's life, with no error and nothing in a log. + * + * The undo is a report whose flag0 has BOTH bits clear (SDL's own comment: "Leaving emulated + * rumble bits off will restore audio haptics"). No other valid flag is set, so nothing else + * about the pad's state is touched. Mirrors `Ds5Feedback::audio_haptics_packet` on the desktop + * client, which is the same packet one transport over. + */ + fun ds5AudioHapticsReport(model: Model): ByteArray = newDs5(model) + fun ds5RumbleReport(model: Model, low: Int, high: Int): ByteArray = newDs5(model).also { it[1] = (DS5_FLAG0_COMPAT_VIBRATION or DS5_FLAG0_HAPTICS_SELECT).toByte() it[39] = DS5_FLAG2_VIBRATION2.toByte() diff --git a/clients/android/native/src/pad_audio.rs b/clients/android/native/src/pad_audio.rs index 57bccb3b..deaa7a36 100644 --- a/clients/android/native/src/pad_audio.rs +++ b/clients/android/native/src/pad_audio.rs @@ -606,7 +606,7 @@ fn render( fn drain_until_stop(client: &NativeClient, stop: &AtomicBool) { while !stop.load(Ordering::Relaxed) { if client.next_pad_audio(Duration::from_millis(20)).is_none() - && stop.load(Ordering::Relaxed) + && (stop.load(Ordering::Relaxed) || client.is_session_ended()) { return; } @@ -632,6 +632,10 @@ fn pump( let mut samples_in = 0u64; let mut peak = 0i32; let mut last_report = std::time::Instant::now(); + // R13: caller-side short-write accounting (distinct from `st.short_bytes`, which is a + // URB-level statistic from inside the transport). + let mut st_short = 0u64; + let mut st_short_logged = std::time::Instant::now(); let mut pcm: Vec = Vec::with_capacity(MAX_FRAME_SAMPLES * 2); let mut out: Vec = Vec::with_capacity(MAX_BUFFER_FRAMES * PAD_CHANNELS); @@ -644,7 +648,7 @@ fn pump( let st = playback.stats(); log::info!( "pad audio: {frames_in} frames in, {samples_in} samples, peak={peak}, \ - {} written, {} underruns, {} short", + {} written, {} underruns, {} short, {st_short} dropped to back-pressure", playback.frames_written(), st.underruns, st.short_bytes @@ -654,9 +658,31 @@ fn pump( } let Some(frame) = client.next_pad_audio(Duration::from_millis(10)) else { + // R12: `next_pad_audio` collapses a DISCONNECTED channel into the same `None` as an + // ordinary timeout, so this arm cannot tell "nothing arrived in 10 ms" from "the + // session is gone and nothing will ever arrive again". Left to `continue`, a closed + // session span this loop at nice -16 until the owner's stop flag caught up — roughly + // a second of a real-time-priority thread doing nothing. Ask the connection directly. + if client.is_session_ended() { + log::debug!("pad audio: session ended, leaving the render loop"); + break; + } continue; }; + // R14: `PadAudioFrame` carries the wire pad it was addressed to, and this renderer serves + // exactly one. A frame for another pad — a queue still holding the previous occupant's + // when a slot is re-used, or a host bug — would otherwise be decoded here AND seed the + // gap tracker from a foreign sequence space, which shows up as a burst of phantom + // concealment rather than as anything obviously wrong. + if frame.pad != pad { + log::debug!( + "pad audio: dropping frame for pad {} on pad {pad}", + frame.pad + ); + continue; + } + // The settings gate each kind independently: haptics off but speaker on is a legitimate // configuration, and the host may still be sending both. let wanted = match frame.kind { @@ -731,13 +757,35 @@ fn pump( // chunk is never padded with silence mid-stream. out.clear(); if mixer.pop(&mut out) > 0 { - if let Err(e) = playback.write_interleaved(&out) { - if is_fatal(&e) { - log::warn!("pad audio: stream lost: {e}"); - return; + match playback.write_interleaved(&out) { + // R13: a SHORT write is back-pressure, not success — the endpoint took `n` frames + // and the rest is ours to deal with. Discarding the return value dropped the tail + // with nothing said, so a stalled endpoint sounded like clipped audio with a clean + // log. We cannot retry from here without unbounded buffering (the mixer's whole + // point is to stay ahead of the device), so the tail is still dropped — but it is + // now COUNTED and reported by the 1 s line, which is the difference between a + // diagnosable stall and a mystery. + Ok(n) if n < out.len() => { + st_short += (out.len() - n) as u64; + if st_short_logged.elapsed() >= Duration::from_secs(5) { + log::warn!( + "pad audio: endpoint short-wrote {} of {} samples ({st_short} total) \ + — the device is not keeping up", + n, + out.len() + ); + st_short_logged = std::time::Instant::now(); + } + } + Ok(_) => {} + Err(e) => { + if is_fatal(&e) { + log::warn!("pad audio: stream lost: {e}"); + return; + } + log::debug!("pad audio: write hiccup: {e}"); + mixer.discard(); } - log::debug!("pad audio: write hiccup: {e}"); - mixer.discard(); } } } diff --git a/crates/pf-client-core/src/gamepad.rs b/crates/pf-client-core/src/gamepad.rs index 1332e6ba..80a50293 100644 --- a/crates/pf-client-core/src/gamepad.rs +++ b/crates/pf-client-core/src/gamepad.rs @@ -2196,7 +2196,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. + // AudioCtl's pad is the plane's only u16. `HidOutput::decode` rejects anything at or + // above MAX_PADS (B27), so by the time one reaches here the narrowing is lossless. HidOutput::AudioCtl { pad, .. } => *pad as u8, } } diff --git a/crates/pf-inject/src/inject/hidout_dedup.rs b/crates/pf-inject/src/inject/hidout_dedup.rs index d4b2d64b..20f03c21 100644 --- a/crates/pf-inject/src/inject/hidout_dedup.rs +++ b/crates/pf-inject/src/inject/hidout_dedup.rs @@ -334,21 +334,22 @@ mod tests { #[test] fn audio_ctl_dedups_by_value() { let mut d = HidoutDedup::default(); + let t = Instant::now(); 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))); + assert!(d.should_forward(&audio(0x17, 0x50), t)); + assert!(!d.should_forward(&audio(0x17, 0x50), t)); // Either half changing (flags, or the raw region) forwards again. - assert!(d.should_forward(&audio(0x16, 0x50))); - assert!(d.should_forward(&audio(0x16, 0x60))); + assert!(d.should_forward(&audio(0x16, 0x50), t)); + assert!(d.should_forward(&audio(0x16, 0x60), t)); // The other kinds' state is untouched by audio traffic. - assert!(d.should_forward(&HidOutput::PlayerLeds { pad: 0, bits: 1 })); + assert!(d.should_forward(&HidOutput::PlayerLeds { pad: 0, bits: 1 }, t)); // `clear` (pad re-plug) re-arms the value dedup. d.clear(); - assert!(d.should_forward(&audio(0x16, 0x60))); + assert!(d.should_forward(&audio(0x16, 0x60), t)); } } diff --git a/crates/punktfunk-core/src/abi.rs b/crates/punktfunk-core/src/abi.rs index b4540dcc..bc1a9772 100644 --- a/crates/punktfunk-core/src/abi.rs +++ b/crates/punktfunk-core/src/abi.rs @@ -770,8 +770,9 @@ impl PunktfunkHidOutput { 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. + // `effect[0..6]` the raw audio region. The u16 wire pad narrows losslessly + // because `HidOutput::decode` refuses one at or above `input::MAX_PADS` (B27) — + // it is enforced there, not merely assumed here. out.kind = PUNKTFUNK_HIDOUT_AUDIO_CTL; out.pad = *pad as u8; out.which = *flags; diff --git a/crates/punktfunk-core/src/client/pump/input_task.rs b/crates/punktfunk-core/src/client/pump/input_task.rs index 1038eda7..fe931e5b 100644 --- a/crates/punktfunk-core/src/client/pump/input_task.rs +++ b/crates/punktfunk-core/src/client/pump/input_task.rs @@ -48,12 +48,23 @@ pub(super) async fn run( // 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 { + // B7: the caps a pad's LAST arrival actually carried. `set_pad_audio_caps` only stores into + // the registry — it cannot reach this task — so a declaration that lands after the arrival + // burst has drained (the renderer commits the trade only once its sink opens, which is well + // past the two 100 ms ticks) used to never reach the host at all: the client believed it had + // pad audio and the host emitted nothing on 0xD1, silently, forever. Comparing this against + // the live registry on every tick re-arms the burst by itself, with no new plumbing and no + // extra traffic when nothing changed. + let mut arrival_caps_sent: [u8; MAX_PADS] = [0; MAX_PADS]; + let caps_now = |idx: usize| -> u8 { + if pad_audio { pad_audio_caps[idx].load(Ordering::Relaxed) } else { 0 - }; + } + }; + let arrival_flags = |idx: usize| -> u32 { + let caps = caps_now(idx); crate::input::encode_gamepad_arrival(idx as u8, caps) }; let mut refresh = tokio::time::interval(Duration::from_millis(100)); @@ -115,6 +126,7 @@ pub(super) async fn run( // 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; + arrival_caps_sent[idx] = caps_now(idx); let arr = crate::input::InputEvent { flags: arrival_flags(idx), ..ev @@ -127,11 +139,21 @@ pub(super) async fn run( } _ = refresh.tick() => { for idx in 0..MAX_PADS { + // B7: caps declared after the burst drained — re-announce this pad's arrival. + // Only for a pad that HAS an arrival (so it is a live, declared controller), + // and only when the value actually moved, so a steady session sends nothing. + if arrival[idx].is_some() + && arrival_owed[idx] == 0 + && caps_now(idx) != arrival_caps_sent[idx] + { + arrival_owed[idx] = ARRIVAL_RESENDS; + } // Re-send an owed kind declaration (independent of whether the pad has state // yet — it may be idle-but-connected). Idempotent on the host. if arrival_owed[idx] > 0 { if let Some(kind) = arrival[idx] { arrival_owed[idx] -= 1; + arrival_caps_sent[idx] = caps_now(idx); let arr = crate::input::InputEvent { kind: InputKind::GamepadArrival, _pad: [0; 3], diff --git a/crates/punktfunk-core/src/quic/datagram.rs b/crates/punktfunk-core/src/quic/datagram.rs index a440aab0..c73243a8 100644 --- a/crates/punktfunk-core/src/quic/datagram.rs +++ b/crates/punktfunk-core/src/quic/datagram.rs @@ -560,11 +560,22 @@ 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(), - }), + // B27: the pad is the only u16 index on this plane, and every consumer narrows it + // with `as u8` on the stated assumption that pads are 0..MAX_PADS. Nothing enforced + // that, so wire pad 256 silently ALIASED onto slot 0 — a malformed or hostile + // datagram steering a real controller's speaker volumes. Rejected here, at the one + // place the u16 exists, so the narrowings downstream are lossless by construction + // (the same fix R10 applied to the rumble plane). + HIDOUT_AUDIO_CTL + if b.len() >= 11 + && u16::from_le_bytes([b[2], b[3]]) < crate::input::MAX_PADS as u16 => + { + Some(HidOutput::AudioCtl { + pad: u16::from_le_bytes([b[2], b[3]]), + flags: b[4], + raw: b[5..11].try_into().unwrap(), + }) + } _ => None, } } @@ -1400,13 +1411,15 @@ mod tests { #[test] fn audio_ctl_wire_layout_and_truncation() { // The exact 11-byte layout: [0xCD][0x06][u16 pad LE][u8 flags][6 raw bytes]. + // The pad is deliberately a REPRESENTABLE one: this used to assert that 0x0201 (513) + // round-tripped, which pinned B27's aliasing in place as if it were the contract. let a = HidOutput::AudioCtl { - pad: 0x0201, + pad: 0x000B, 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!(d, [0xCD, 0x06, 0x0B, 0x00, 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() { @@ -1437,6 +1450,49 @@ mod tests { assert!(decode_pad_audio_datagram(&hdr).unwrap().opus.is_empty()); } + /// B27: the pad is the only u16 index on the 0xCD plane and every consumer narrows it with + /// `as u8`. An out-of-range one used to alias onto a real slot instead of being refused — + /// wire pad 256 steering pad 0's speaker volumes. + #[test] + fn audio_ctl_rejects_a_pad_outside_the_index_space() { + let ok = HidOutput::AudioCtl { + pad: (crate::input::MAX_PADS - 1) as u16, + flags: 0x12, + raw: [1, 2, 3, 4, 5, 6], + }; + assert_eq!( + HidOutput::decode(&ok.encode()), + Some(ok), + "the last valid pad must still decode" + ); + + // Anything at or above MAX_PADS is refused outright, not truncated. + for pad in [crate::input::MAX_PADS as u16, 256, u16::MAX] { + let d = HidOutput::AudioCtl { + pad, + flags: 0x12, + raw: [1, 2, 3, 4, 5, 6], + } + .encode(); + assert_eq!(HidOutput::decode(&d), None, "pad {pad} must not decode"); + } + + // The specific alias the bug produced: 256 as u8 == 0. + let d = HidOutput::AudioCtl { + pad: 256, + flags: 0, + raw: [0; 6], + } + .encode(); + assert!( + !matches!( + HidOutput::decode(&d), + Some(HidOutput::AudioCtl { pad: 0, .. }) + ), + "wire pad 256 must never surface as pad 0" + ); + } + #[test] fn cursor_state_roundtrip() { for (flags, x, y) in [ diff --git a/crates/punktfunk-host/src/audio/windows/pad_endpoint.rs b/crates/punktfunk-host/src/audio/windows/pad_endpoint.rs index 3785dc6d..ffaa05cb 100644 --- a/crates/punktfunk-host/src/audio/windows/pad_endpoint.rs +++ b/crates/punktfunk-host/src/audio/windows/pad_endpoint.rs @@ -1387,9 +1387,14 @@ fn pad_audio_slots() -> u8 { .clamp(1, 4) } -/// The endpoints provisioned at startup, set exactly once by the worker thread. +/// The endpoints provisioned at startup, set exactly once by the worker thread — and only on +/// SUCCESS. See [`provision_at_startup`] for why the failure path deliberately leaves it unset. static PROVISIONED: OnceLock>> = OnceLock::new(); +/// A provisioning attempt is in flight. Guards the retry in [`ensure_provisioned`] against +/// spawning a second COM worker while the first is still enumerating. +static PROVISIONING: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false); + /// 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 @@ -1403,6 +1408,10 @@ pub(crate) fn provision_at_startup() { if PROVISIONED.get().is_some() { return; } + // R5: one attempt at a time. Without this the retry below could stack COM workers. + if PROVISIONING.swap(true, std::sync::atomic::Ordering::SeqCst) { + return; + } let slots = pad_audio_slots(); let spawned = thread::Builder::new() .name("punktfunk-pad-audio".into()) @@ -1441,9 +1450,24 @@ pub(crate) fn provision_at_startup() { stored-but-not-served until the next reboot"), } } - let _ = PROVISIONED.set(Arc::new(eps)); + // R5: latch the result ONLY if we actually provisioned something. This used to store + // whatever `eps` held even when the loop broke on the first error — an empty vec — + // and `OnceLock` made that permanent: one transient failure (a busy audio stack, a + // service mid-restart) disabled pad audio for the entire life of the host process, + // with the only evidence a single warning at startup. An empty result now leaves the + // cell unset so `ensure_provisioned` can try again when a session next asks. + if eps.is_empty() { + tracing::warn!( + "pad-audio provisioning produced no endpoints — leaving it unlatched so the \ + next session retries rather than disabling pad audio for this process" + ); + } else { + let _ = PROVISIONED.set(Arc::new(eps)); + } + PROVISIONING.store(false, std::sync::atomic::Ordering::SeqCst); }); if let Err(e) = spawned { + PROVISIONING.store(false, std::sync::atomic::Ordering::SeqCst); tracing::warn!(error = %e, "could not spawn the pad-audio provisioning thread"); } } @@ -1454,6 +1478,17 @@ pub(crate) fn provisioned_endpoints() -> Option>> { PROVISIONED.get().cloned() } +/// R5: ask for provisioning again if the startup attempt produced nothing. Cheap and idempotent — +/// a successful latch returns immediately, and `PROVISIONING` keeps concurrent askers to one +/// worker. Called where a session first wants to know whether pad audio exists, so a host that +/// started while the audio stack was busy recovers on the next connect instead of at the next +/// reboot. +pub(crate) fn ensure_provisioned() { + if PROVISIONED.get().is_none() { + provision_at_startup(); + } +} + /// 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)] @@ -1571,13 +1606,42 @@ impl PadLoopbackCapturer { }), Ok(Err(e)) => Err(e), Err(_) => { + // R6: signal AND reap. Dropping `join` here detached the WASAPI thread, and the + // streamer's reopen loop retries this every ~2 s — so a wedged activation leaked + // one thread (each holding COM apartment state and a channel end) per attempt, + // indefinitely. The join is bounded in practice because the thread's own loop + // observes `stop` between waits; give it a moment and, if it is genuinely stuck + // inside a blocking WASAPI call, say so rather than leaking in silence. stop.store(true, Ordering::SeqCst); - Err(anyhow!("pad loopback init timed out")) + match reap_with_timeout(join, Duration::from_secs(2)) { + true => Err(anyhow!("pad loopback init timed out")), + false => Err(anyhow!( + "pad loopback init timed out and its thread did not exit — the audio \ + stack is wedged; not retrying into a thread leak" + )), + } } } } } +/// Join `join`, giving it `budget` to notice a stop flag. `true` if it exited. +/// +/// A detached thread is the wrong answer at a retry point (see [`PadLoopbackCapturer::open`]): +/// the caller reopens on a timer, so "leak one and move on" compounds. Waiting is bounded, and a +/// thread that outlasts the budget is reported instead of silently accumulating. +fn reap_with_timeout(join: JoinHandle<()>, budget: Duration) -> bool { + let deadline = std::time::Instant::now() + budget; + while !join.is_finished() { + if std::time::Instant::now() >= deadline { + return false; + } + std::thread::sleep(Duration::from_millis(20)); + } + let _ = join.join(); + true +} + /// Render a test tone straight into a pad's audio endpoint. /// /// The point is iteration speed. Without this, exercising the pad-audio chain means launching a @@ -1645,13 +1709,18 @@ pub(crate) fn render_test_tone( let device = open_wasapi_device(endpoint_id) .with_context(|| format!("pad endpoint {endpoint_id} not found"))?; let mut audio_client = device.get_iaudioclient().context("IAudioClient")?; + // B11: the SAME mask the endpoint and the loopback capture use. Passing `None` let wasapi + // derive `(1 << 4) - 1` = 0x0F (FL FR FC LFE) instead of 0x33 (FL FR BL BR), so this devtest + // — the instrument for "which coil is which" — put its tone on a different channel pairing + // than the real path. It could not exercise the coil route at all, and read as an inverted + // pair when it appeared to. let desired = WaveFormat::new( 32, 32, &SampleType::Float, SAMPLE_RATE as usize, PAD_CHANNELS as usize, - None, + Some(PAD_CHANNEL_MASK), ); let (default_period, _min) = audio_client.get_device_period().context("device period")?; audio_client diff --git a/crates/punktfunk-host/src/audio/windows/wasapi_cap.rs b/crates/punktfunk-host/src/audio/windows/wasapi_cap.rs index 677cd3b8..0c39cae2 100644 --- a/crates/punktfunk-host/src/audio/windows/wasapi_cap.rs +++ b/crates/punktfunk-host/src/audio/windows/wasapi_cap.rs @@ -511,7 +511,7 @@ fn capture_once( if assert_plan { if let Some(d) = seen_default.as_deref() { if d != dev_id { - match judge_default(&en, wiring, d) { + match judge_default(wiring, d) { DefaultKind::Capturable(name) => { tracing::info!(default = %name, planned = %dev_name, "could not park the default playback on the planned endpoint — \ @@ -639,7 +639,7 @@ fn capture_once( ); return Ok(Next::Reopen(TargetMode::Follow)); } - match judge_default(&en, wiring, &nid) { + match judge_default(wiring, &nid) { DefaultKind::Capturable(name) => { audio_client.stop_stream().ok(); tracing::info!(device = %name, @@ -739,7 +739,15 @@ fn judge_default(wiring: &wiring_plan::Wiring, id: &str) -> DefaultKind { .mic_render .as_ref() .is_some_and(|(_, mic_id)| mic_id == id); - if is_mic || wiring_plan::excluded_from_loopback(&ln) { + // B10: a pad's audio endpoint is not ordinary hardware, and the name rules cannot see that — + // it is deliberately stamped with the controller's own name ("DualSense Wireless Controller") + // so games treat it as the pad's speaker, which means `excluded_from_loopback` passes it + // straight through as `Capturable`. The pure plan filtered these out, but the plan is not the + // only reader: this classifier drives the watchdog, Follow mode and the parked default, so a + // pad endpoint that happened to be the system default could be adopted as the desktop capture + // source — sending the whole desktop mix to a controller's voice coils. Identity, not name. + let is_pad = super::pad_endpoint::is_pad_render_endpoint(id); + if is_mic || is_pad || wiring_plan::excluded_from_loopback(&ln) { DefaultKind::Dud(name) } else { DefaultKind::Capturable(name) diff --git a/crates/punktfunk-host/src/audio/wiring_plan.rs b/crates/punktfunk-host/src/audio/wiring_plan.rs index 17ffefea..464a8832 100644 --- a/crates/punktfunk-host/src/audio/wiring_plan.rs +++ b/crates/punktfunk-host/src/audio/wiring_plan.rs @@ -724,7 +724,7 @@ mod tests { ("steam streaming microphone", fmt(24_000, 1)), ("odyssey", fmt(48_000, 2)), ]); - let w = plan_with_formats(&renders, &captures, None, false, &p, 2); + let w = plan_with_formats(&renders, &captures, None, false, &p, 2, &[]); assert_eq!( w.loopback_render.as_ref().unwrap().0, "1 - Odyssey G60SD (AMD High Definition Audio Device)", @@ -754,7 +754,7 @@ mod tests { ("steam streaming microphone", fmt(48_000, 2)), ("realtek", fmt(48_000, 2)), ]); - let w = plan_with_formats(&renders, &[], None, false, &p, 2); + let w = plan_with_formats(&renders, &[], None, false, &p, 2, &[]); assert_eq!( w.loopback_render.unwrap().0, "Speakers (Steam Streaming Microphone)" @@ -770,7 +770,7 @@ mod tests { ep("Speakers (Steam Streaming Microphone)"), ]; let p = probe(vec![("steam streaming microphone", fmt(16_000, 1))]); - let w = plan_with_formats(&renders, &[], None, false, &p, 2); + let w = plan_with_formats(&renders, &[], None, false, &p, 2, &[]); assert_eq!( w.loopback_render.as_ref().unwrap().0, "Speakers (Steam Streaming Microphone)" @@ -786,7 +786,7 @@ mod tests { fn narrowing_is_reported_for_real_hardware_too() { let renders = [ep("Headset (Hands-Free AG Audio)")]; let p = probe(vec![("headset", fmt(16_000, 1))]); - let w = plan_with_formats(&renders, &[], None, false, &p, 2); + let w = plan_with_formats(&renders, &[], None, false, &p, 2, &[]); assert_eq!( w.loopback_render.as_ref().unwrap().0, "Headset (Hands-Free AG Audio)" @@ -806,8 +806,8 @@ mod tests { ]; let captures = [ep("CABLE Output (VB-Audio Virtual Cable)")]; for host_audio in [false, true] { - let a = plan(&renders, &captures, None, host_audio); - let b = plan_with_formats(&renders, &captures, None, host_audio, &no_formats, 2); + let a = plan(&renders, &captures, None, host_audio, &[]); + let b = plan_with_formats(&renders, &captures, None, host_audio, &no_formats, 2, &[]); assert_eq!(a, b, "host_audio={host_audio}"); assert!(a.loopback_narrowing.is_none()); } @@ -825,7 +825,7 @@ mod tests { ("steam streaming microphone", fmt(24_000, 1)), ("realtek", fmt(48_000, 2)), ]); - let w = plan_with_formats(&renders, &[], None, true, &p, 2); + let w = plan_with_formats(&renders, &[], None, true, &p, 2, &[]); assert_eq!(w.loopback_render.unwrap().0, "Speakers (Realtek HD Audio)"); } diff --git a/crates/punktfunk-host/src/native/input.rs b/crates/punktfunk-host/src/native/input.rs index 0d5b8d54..c227d442 100644 --- a/crates/punktfunk-host/src/native/input.rs +++ b/crates/punktfunk-host/src/native/input.rs @@ -523,12 +523,22 @@ 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], + /// Kind-change restarts spent per pad this session (R3). The trigger is a client-sent + /// arrival, so without a ceiling the client decides how many WASAPI captures the host opens. + restarts: [u8; MAX_WIRE_PADS], } +/// R3: how many times one pad may change its declared audio kinds before the host stops +/// obliging. A real controller declares once at open and never again; the re-sent arrivals are +/// identical and take the no-op path above, so this is only reached by a client that keeps +/// changing its mind. +const MAX_PAD_AUDIO_RESTARTS: u8 = 8; + impl PadAudioSlots { fn new() -> PadAudioSlots { PadAudioSlots { slots: std::array::from_fn(|_| None), + restarts: [0; MAX_WIRE_PADS], } } @@ -544,8 +554,23 @@ impl PadAudioSlots { if *have == kinds { return; // identical re-arrival — keep the running streamer } + // R3: the restart trigger is a CLIENT-sent arrival, so the count is client-driven. + // Nothing bounded it: a client alternating its declared kinds could make the host + // tear down and re-spawn a WASAPI loopback capture indefinitely, each cycle paying a + // thread spawn and an endpoint activation. Cheap to bound, and a pad that has already + // changed its mind this many times in one session is not doing anything legitimate. + if self.restarts[idx] >= MAX_PAD_AUDIO_RESTARTS { + tracing::warn!( + pad = idx, + "pad-audio kinds changed again after {MAX_PAD_AUDIO_RESTARTS} restarts — \ + ignoring; the streamer keeps its current kinds for this session" + ); + return; + } + self.restarts[idx] += 1; tracing::info!( pad = idx, + restarts = self.restarts[idx], "pad-audio kinds changed — restarting the streamer" ); self.stop(idx); diff --git a/crates/punktfunk-host/src/native/pad_audio.rs b/crates/punktfunk-host/src/native/pad_audio.rs index 17487ac4..90ac804d 100644 --- a/crates/punktfunk-host/src/native/pad_audio.rs +++ b/crates/punktfunk-host/src/native/pad_audio.rs @@ -247,6 +247,11 @@ 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")] { + // R5: a startup attempt that failed transiently leaves nothing latched, so retry here — + // this is the first moment in a session's life that anyone asks whether pad audio exists. + if asked { + crate::audio::pad_endpoint::ensure_provisioned(); + } asked && std::env::var_os("PUNKTFUNK_PAD_AUDIO").is_none_or(|v| v != "0") && crate::audio::pad_endpoint::provisioned_endpoints() @@ -288,6 +293,22 @@ pub(super) fn spawn( // cheap to refuse rather than spin the open/backoff loop on an empty id. return None; } + if ep.needs_aeb_kick { + // R4: this flag was computed on every path and consulted nowhere past startup. It means + // the endpoint's stamps are STORED but not SERVED — the audio stack never picked up the + // DualSense identity — and startup's one restart did not fix it. Opening anyway is worse + // than refusing: `AUTOCONVERTPCM` makes a wrong-format endpoint initialize *successfully*, + // so the stream runs, the logs look healthy, and the haptics/speaker pair is mis-routed + // with nothing to point at. Decline, and say which reboot-shaped problem it is. + tracing::warn!( + pad, + endpoint = %ep.endpoint_id, + "pad endpoint stamps are stored but not served — the audio stack has not adopted the \ + DualSense identity (a reboot, or a manual AudioEndpointBuilder+Audiosrv restart, \ + clears it). Not streaming: the endpoint would open and mis-route." + ); + return None; + } let stop_t = stop.clone(); match std::thread::Builder::new() .name(format!("punktfunk1-pad{pad}"))