Helldivers 2 tanked on an IDLE host: the pad DualSense speaker stayed visible and the recording default was parked forever #217

Merged
enricobuehler merged 4 commits from worktree-hd2-idle-recording-default into main 2026-08-14 08:41:14 +00:00
9 changed files with 449 additions and 93 deletions
+52
View File
@@ -14,6 +14,58 @@ with the version table of the release you are moving to, then read **Breaking ch
## v0.28.1 — in development
### The pad-audio "Wireless Controller" speaker hides while no client pad is attached
Field-confirmed (2026-08-14, the same Helldivers 2 reports as below): the per-pad audio endpoint
the Windows host mints — a Steam-Streaming-Speakers instance stamped with a DualSense's name,
container and 4 ch/48 kHz formats, **pre-provisioned at every host start** — is deliberately
indistinguishable from a real DualSense speaker. That disguise is the feature during a pad
session (libScePad titles route haptics audio at it) and a trap the rest of the time: an idle
Helldivers 2 finds the endpoint by identity, engages its DualSense-haptics path against a device
nothing services, and drops to 25 FPS 1% lows — with the host completely idle, no controller
plugged in, and no session ever run. The reporter isolating "the DualSense speaker" and disabling
it in mmsys.cpl restored full performance; that manual remedy is now automatic.
The endpoint now parks **hidden** (`DEVICE_STATE_DISABLED`, via `IPolicyConfig::
SetEndpointVisibility` — the exact call behind mmsys.cpl's Disable) whenever no client pad is
attached: provisioning hides it at startup (and a `PUNKTFUNK_PAD_AUDIO=0` host hides leftovers
from earlier runs), the per-pad streamer shows it for exactly the pad's lifetime — to a game,
indistinguishable from a DualSense arriving and leaving. The devnode, driver binding and stamps
stay put, so the flips raise no PnP traffic and the expensive provisioning still happens once at
boot.
**Operator-visible:** "Speakers (Wireless Controller)" now shows as *disabled* in the Sound
control panel while no client pad is connected — that is the parked state, not a defect. The
`pad-endpoint` devtest grew `show`/`hide` verbs; `tone`/`capture` need a `show` first.
### An idle Windows host no longer owns the box's default microphone
Field report (the second Helldivers 2 one — the first led to v0.28.0's mint-retry fix): with the
host **idle**, a locally played Helldivers 2 tanks to 25 FPS 1% lows, and Windows' own Sound
settings Recording tab goes unresponsive. Root cause: the audio wiring pass asserted *default
recording = the virtual mic's capture side* on **every** pass, including the mic pump's eager
boot pass — and `SetDefaultEndpoint` covers eCommunications, so every game's voice input bound a
virtual microphone whose feeder only runs during a stream. Nothing ever restored it: not session
end, not service stop. Games that hold an always-open voice capture (Helldivers 2 is Wwise +
in-game voice — its own wiki calls the game "finicky with audio devices") stall on that dead
endpoint.
The recording default is now **session-scoped**, exactly like the playback default has always
been: parked on the virtual mic only while a desktop-audio capture is open, the operator's device
remembered (plus an on-disk crash marker, `audio-default-rec.prev`), restored when the capture
closes, recovered at next boot after a crash, and unparked by the uninstaller. A game launched
*during* a stream still records the client's mic; one launched before the stream keeps the
operator's own microphone.
Boxes wedged by earlier builds (which recorded nothing to restore) heal themselves: an idle
wiring pass that finds the default recording sitting on the plan's mic capture moves it back to
the first real microphone.
**Operator-visible:** outside a stream, the default recording device is now whatever you set —
Punktfunk only takes it for the duration of a stream. If you *want* apps to record the client mic
while idle, select "Punktfunk Microphone" manually; the host no longer re-asserts it (idle
re-assertion used to stomp a manual choice within one mic-pump reopen).
### The Steam plugin synced nothing on Windows: its art is in Program Files, the art roots were not
Field report — the plugin installed, the grid stayed empty, and the only clue was one host warn per
@@ -26,13 +26,22 @@
//! mixing mono or at 24 kHz) loses to real hardware; see [`super::wiring_plan`]. **Never** the
//! Steam Streaming Speakers, whose loopback is silent — validated live;
//! * default **RECORDING** → the mic target's capture endpoint (VB-Cable "CABLE Output") so host apps
//! record the client's mic by default.
//! record the client's mic by default — applied, like the playback default, ONLY while a
//! desktop-audio capture is open. It used to be asserted on EVERY wiring pass, mic pump at boot
//! included, which left an IDLE box's default recording/communication device parked on a virtual
//! microphone nothing feeds — and games bind the default microphone at launch (`SetDefaultEndpoint`
//! covers eCommunications, so in-game voice binds it too). The 2026-08 Helldivers 2 field reports
//! measured that as 1% lows of 25 FPS in a LOCALLY played game while the host sat idle (HD2 is
//! Wwise + always-on voice, exactly the "finicky with audio devices" case its own wiki warns
//! about). An idle host must leave the box's audio defaults exactly as the operator set them.
//!
//! Because the playback default is *parked* on a silent sink during a stream, it is remembered
//! ([`park_default_playback`], plus an on-disk crash marker) and put back when the capture closes
//! ([`restore_default_playback`]) or, after a crash, on the next process's first wiring pass — an
//! operator must never be stranded with silent speakers. A default the operator changed themselves
//! mid-stream is respected (no restore over their choice).
//! Because both defaults are *parked* during a stream — playback on a silent sink, recording on the
//! virtual mic — the operator's devices are remembered ([`park_default_playback`] /
//! [`park_default_recording`], plus on-disk crash markers) and put back when the capture closes
//! ([`restore_default_playback`] / [`restore_default_recording`]) or, after a crash, on the next
//! process's first wiring pass — an operator must never be stranded with silent speakers or a dead
//! mic. A default the operator changed themselves mid-stream is respected (no restore over their
//! choice).
//!
//! The assignment rules are the PURE [`wiring_plan`](super::wiring_plan) module (unit-tested on every
//! platform); this module only enumerates endpoints, applies the plan, and logs. [`wire_now`] runs on
@@ -142,8 +151,8 @@ pub(crate) fn endpoint_fingerprint() -> u64 {
}
/// [`wire_now_full`] for callers that only need the assignment (the mic paths).
pub(crate) fn wire_now(set_playback: bool) -> Wiring {
wire_now_full(set_playback).wiring
pub(crate) fn wire_now(park_defaults: bool) -> Wiring {
wire_now_full(park_defaults).wiring
}
/// The most recent wiring verdict, as the LAST wiring pass computed it (the mic pump wires
@@ -170,13 +179,15 @@ fn pad_render_ids(renders: &[Endpoint]) -> Vec<String> {
/// 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
/// parks the default PLAYBACK device on the plan's loopback endpoint for the capture's lifetime
/// (the mic pump passes false: it runs while the host is idle and must not silence the box).
/// Must run on a COM-initialized thread (the WASAPI worker threads all `initialize_mta` first).
/// Logged only when the assignment changes, so per-open recomputation stays quiet in the steady
/// state.
pub(crate) fn wire_now_full(set_playback: bool) -> WiredPlan {
/// echo guard). `park_defaults` — true only from the desktop-audio capture open — additionally
/// parks the default PLAYBACK device on the plan's loopback endpoint and the default RECORDING
/// device on the virtual mic's capture side, both for the capture's lifetime (the mic pump passes
/// false: it runs while the host is idle and must neither silence the box nor hold its default
/// microphone — the idle-parked recording default is the 2026-08 Helldivers 2 tank, see the
/// module docs). Must run on a COM-initialized thread (the WASAPI worker threads all
/// `initialize_mta` first). Logged only when the assignment changes, so per-open recomputation
/// stays quiet in the steady state.
pub(crate) fn wire_now_full(park_defaults: bool) -> WiredPlan {
recover_orphaned_default();
let renders = list_endpoints(Direction::Render);
let captures = list_endpoints(Direction::Capture);
@@ -188,11 +199,11 @@ pub(crate) fn wire_now_full(set_playback: bool) -> WiredPlan {
// 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);
// Mix formats are read only when we are actually going to park the playback default (i.e. a
// Mix formats are read only when we are actually going to park the defaults (i.e. a
// desktop-audio capture is opening). The mic pump wires on every open while the host is idle
// and does not care which loopback endpoint wins, so it must not pay an IAudioClient
// activation per render endpoint on every pass.
let probe: &dyn Fn(&Endpoint) -> Option<MixFormat> = if set_playback {
let probe: &dyn Fn(&Endpoint) -> Option<MixFormat> = if park_defaults {
&mix_format_of
} else {
&wiring_plan::no_formats
@@ -311,30 +322,44 @@ pub(crate) fn wire_now_full(set_playback: bool) -> WiredPlan {
}
}
}
if set_playback {
// Recording-default hygiene, IDLE passes only: builds before 2026-08-14 parked the default
// recording on the virtual mic on EVERY wiring pass (boot included) and recorded nothing to
// restore — so an upgraded box would otherwise sit wedged on a microphone nothing feeds
// until the operator noticed (the Helldivers 2 idle tank; the session-scoped park below
// can't heal it either: it remembers a previous default only when the default isn't already
// ours). While nothing is parked, a default found sitting on the plan's mic capture moves to
// the first real microphone. Session passes own the default and are exempt; a box with no
// real microphone is left alone.
if !park_defaults && PARKED_REC.lock().unwrap().is_none() {
if let Some((mic_name, mic_id)) = &wiring.mic_capture {
if default_capture_id().as_deref() == Some(mic_id.as_str()) {
if let Some((name, id)) =
wiring_plan::real_capture(&captures, Some(mic_id.as_str()))
{
match set_default_endpoint(id) {
Ok(()) => tracing::info!(from = %mic_name, device = %name,
"default recording was left on the virtual mic outside a stream — \
moved it back to a real microphone"),
Err(e) => tracing::warn!(device = %name, error = %format!("{e:#}"),
"failed to move the default recording off the virtual mic"),
}
}
}
}
}
if park_defaults {
if let Some((name, id)) = &wiring.loopback_render {
let mic_id = wiring.mic_render.as_ref().map(|(_, m)| m.as_str());
park_default_playback(name, id, changed, mic_id);
}
}
if let Some((name, id)) = &wiring.mic_capture {
// `set_default_endpoint` is NOT a no-op on an unchanged default: it unconditionally
// fires SetDefaultEndpoint for all three roles (an audio-policy write plus a
// device-graph notification, each). Re-asserting on every wiring pass therefore both
// churned the policy store AND silently stomped an operator's own recording-device
// choice within one reopen cycle — write only when the plan changed or the default
// actually drifted off the target.
if changed || default_capture_id().as_deref() != Some(id.as_str()) {
match set_default_endpoint(id) {
Ok(()) => {
if changed {
tracing::info!(device = %name,
"audio wiring: default recording = virtual mic (apps record the client's mic)");
}
}
Err(e) => tracing::warn!(device = %name, error = %format!("{e:#}"),
"audio wiring: failed to set the default recording device"),
}
// The recording default is SESSION-SCOPED like the playback default, and for the same
// reason inverted: parking it while idle handed the box's default microphone (and, via
// eCommunications, every game's voice input) to a virtual mic nothing feeds — the
// 2026-08 Helldivers 2 idle tank (see the module docs). A game launched DURING the
// stream still binds the client's mic (this runs before the session's game does);
// one launched before the stream keeps the operator's mic, which is the honest answer.
if let Some((name, id)) = &wiring.mic_capture {
park_default_recording(name, id, changed);
}
}
done(wiring)
@@ -350,6 +375,26 @@ fn park_marker_path() -> std::path::PathBuf {
pf_paths::config_dir().join("audio-default.prev")
}
/// The operator's default recording endpoint while we have it parked on the virtual mic:
/// `(previous_id, id_we_set)` — the recording-side twin of [`PARKED`].
static PARKED_REC: Mutex<Option<(String, String)>> = Mutex::new(None);
/// On-disk crash marker mirroring [`PARKED_REC`] (two lines: previous id, set id).
fn rec_marker_path() -> std::path::PathBuf {
pf_paths::config_dir().join("audio-default-rec.prev")
}
/// Consume a park marker file: returns the PREVIOUS default's id when the marker existed AND the
/// current default still is the endpoint we set — a default the operator changed since wins, like
/// on every other restore path. The file is removed either way (it describes a park that is over).
fn take_marker(path: &std::path::Path, current_default: Option<String>) -> Option<String> {
let s = std::fs::read_to_string(path).ok()?;
let _ = std::fs::remove_file(path);
let mut lines = s.lines();
let (prev, set) = (lines.next()?, lines.next()?);
(current_default.as_deref() == Some(set)).then(|| prev.to_string())
}
/// 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).
@@ -374,31 +419,28 @@ pub(crate) fn default_capture_id() -> Option<String> {
.ok()
}
/// Once per process: if a crash marker from a previous run exists, the host died while the
/// playback default was parked — put the operator's device back, but only if the default still
/// IS the endpoint we set (a manual change since the crash wins). Runs on the first wiring pass
/// (the mic pump wires eagerly at host start, so this fires at boot, not at the first stream).
/// Once per process: if a crash marker from a previous run exists, the host died while a default
/// (playback and/or recording) was parked — put the operator's device back, but only if the
/// default still IS the endpoint we set (a manual change since the crash wins). Runs on the first
/// wiring pass (the mic pump wires eagerly at host start, so this fires at boot, not at the first
/// stream).
fn recover_orphaned_default() {
static ONCE: std::sync::Once = std::sync::Once::new();
ONCE.call_once(|| {
let path = park_marker_path();
let Ok(s) = std::fs::read_to_string(&path) else {
return;
};
let _ = std::fs::remove_file(&path);
let mut lines = s.lines();
let (Some(prev), Some(set)) = (lines.next(), lines.next()) else {
return;
};
if default_render_id().as_deref() != Some(set) {
return;
}
match set_default_endpoint(prev) {
Ok(()) => tracing::info!(
"restored the default playback device a previous host run left parked"
),
Err(e) => tracing::warn!(error = %format!("{e:#}"),
"failed to restore the default playback device left by a previous run"),
for (path, current, what) in [
(park_marker_path(), default_render_id(), "playback"),
(rec_marker_path(), default_capture_id(), "recording"),
] {
let Some(prev) = take_marker(&path, current) else {
continue;
};
match set_default_endpoint(&prev) {
Ok(()) => tracing::info!(
"restored the default {what} device a previous host run left parked"
),
Err(e) => tracing::warn!(error = %format!("{e:#}"),
"failed to restore the default {what} device left by a previous run"),
}
}
});
}
@@ -415,20 +457,18 @@ fn recover_orphaned_default() {
///
/// Returns whether a device was actually put back — the caller only logs it.
pub(crate) fn unpark_default_for_uninstall() -> bool {
let path = park_marker_path();
let Ok(s) = std::fs::read_to_string(&path) else {
return false;
};
let _ = std::fs::remove_file(&path);
let mut lines = s.lines();
let (Some(prev), Some(set)) = (lines.next(), lines.next()) else {
return false;
};
// A default the operator changed by hand since the park wins, exactly as on the recovery path.
if default_render_id().as_deref() != Some(set) {
return false;
let mut restored = false;
for (path, current) in [
(park_marker_path(), default_render_id()),
(rec_marker_path(), default_capture_id()),
] {
// A default the operator changed by hand since the park wins, exactly as on the
// recovery path (`take_marker` answers None then).
if let Some(prev) = take_marker(&path, current) {
restored |= set_default_endpoint(&prev).is_ok();
}
}
set_default_endpoint(prev).is_ok()
restored
}
/// Make `id` the default playback device for the duration of the desktop-audio capture,
@@ -469,6 +509,48 @@ fn park_default_playback(name: &str, id: &str, changed: bool, mic_id: Option<&st
}
}
/// Make `id` the default recording device for the duration of the desktop-audio capture —
/// [`park_default_playback`]'s recording twin, remembering the operator's current default (in
/// memory + the crash marker) the FIRST time so [`restore_default_recording`] can put it back.
/// Nothing is remembered when `id` already is the default — there is nothing to restore.
fn park_default_recording(name: &str, id: &str, changed: bool) {
let cur = default_capture_id();
if cur.as_deref() != Some(id) {
let mut parked = PARKED_REC.lock().unwrap();
match parked.as_mut() {
None => {
if let Some(prev) = cur.clone() {
let _ = std::fs::write(rec_marker_path(), format!("{prev}\n{id}"));
*parked = Some((prev, id.to_string()));
}
}
// Re-park onto a different endpoint mid-stream (plan changed): keep the ORIGINAL
// previous default, update what we set.
Some((prev, set)) if set != id => {
let _ = std::fs::write(rec_marker_path(), format!("{prev}\n{id}"));
*set = id.to_string();
}
Some(_) => {}
}
}
// `set_default_endpoint` is NOT a no-op on an unchanged default: it unconditionally fires
// SetDefaultEndpoint for all three roles (an audio-policy write plus a device-graph
// notification, each) — write only when the plan changed or the default actually drifted
// off the target, or the policy store churns on every reopen.
if changed || cur.as_deref() != Some(id) {
match set_default_endpoint(id) {
Ok(()) => {
if changed {
tracing::info!(device = %name,
"audio wiring: default recording = virtual mic (apps record the client's mic)");
}
}
Err(e) => tracing::warn!(device = %name, error = %format!("{e:#}"),
"audio wiring: failed to set the default recording device"),
}
}
}
/// Put the default playback device back on the endpoint we are already capturing, WITHOUT a
/// wiring pass (WP2.4).
///
@@ -507,6 +589,25 @@ pub(crate) fn restore_default_playback() {
}
}
/// Put the operator's default recording device back after streaming — the inverse of
/// [`park_default_recording`], with [`restore_default_playback`]'s exact rules: no-op if we never
/// parked it, and a default the operator changed themselves mid-stream is left alone. Must run on
/// a COM-initialized thread (called from the capture thread's exit path).
pub(crate) fn restore_default_recording() {
let Some((prev, set)) = PARKED_REC.lock().unwrap().take() else {
return;
};
let _ = std::fs::remove_file(rec_marker_path());
if default_capture_id().as_deref() != Some(set.as_str()) {
return;
}
match set_default_endpoint(&prev) {
Ok(()) => tracing::info!("default recording device restored after streaming"),
Err(e) => tracing::warn!(error = %format!("{e:#}"),
"failed to restore the default recording device after streaming"),
}
}
/// Open a device by endpoint id, with a name for error context.
///
/// Resolves through [`super::pad_endpoint::open_wasapi_device`] rather than the `wasapi` crate's
@@ -518,10 +619,11 @@ pub(crate) fn open_endpoint(ep: &Endpoint) -> Result<wasapi::Device> {
.map_err(|e| anyhow!("open endpoint {:?}: {e:#}", ep.0))
}
// --- IPolicyConfig (undocumented): set a default audio endpoint by id, for all three roles. ---
// --- IPolicyConfig (undocumented): default-endpoint and endpoint-visibility writes. ---
/// The `IPolicyConfig` vtable. Only `SetDefaultEndpoint` is called; the 10 methods between `Release`
/// and it (`GetMixFormat` … `SetPropertyValue`) are placeholders so the slot offset is correct.
/// The `IPolicyConfig` vtable. Only `SetDefaultEndpoint` and `SetEndpointVisibility` are called;
/// the 10 methods between `Release` and them (`GetMixFormat` … `SetPropertyValue`) are
/// placeholders so the slot offsets are correct.
#[repr(C)]
struct IPolicyConfigVtbl {
query_interface: unsafe extern "system" fn(
@@ -537,7 +639,11 @@ struct IPolicyConfigVtbl {
windows::core::PCWSTR,
u32,
) -> windows::core::HRESULT,
// SetEndpointVisibility follows — unused.
set_endpoint_visibility: unsafe extern "system" fn(
*mut c_void,
windows::core::PCWSTR,
i32,
) -> windows::core::HRESULT,
}
// This mirrors the vtable of the UNDOCUMENTED `IPolicyConfig` COM interface, so there is no header
@@ -546,18 +652,21 @@ struct IPolicyConfigVtbl {
// table" — so a field added, removed or resized above it does not fail to compile: it silently calls
// a DIFFERENT function through a mismatched signature, which is arbitrary-code territory rather
// than a wrong answer. The `_reserved` gap is what makes that easy to get wrong, since its ten slots
// carry no names to anchor a review. These assertions pin the two things the call actually depends
// on: the slot index of `set_default_endpoint`, and the size of the table up to it.
// carry no names to anchor a review. These assertions pin the things the calls actually depend
// on: the slot indexes of `set_default_endpoint` and `set_endpoint_visibility`, and the size of
// the table up to them.
const _: () = {
use std::mem::{offset_of, size_of};
type P = *const c_void;
// 3 IUnknown slots + 10 reserved = `set_default_endpoint` is slot 13 (0-based).
// 3 IUnknown slots + 10 reserved = `set_default_endpoint` is slot 13 (0-based),
// `set_endpoint_visibility` the slot after.
assert!(offset_of!(IPolicyConfigVtbl, query_interface) == 0);
assert!(offset_of!(IPolicyConfigVtbl, add_ref) == size_of::<P>());
assert!(offset_of!(IPolicyConfigVtbl, release) == 2 * size_of::<P>());
assert!(offset_of!(IPolicyConfigVtbl, _reserved) == 3 * size_of::<P>());
assert!(offset_of!(IPolicyConfigVtbl, set_default_endpoint) == 13 * size_of::<P>());
assert!(size_of::<IPolicyConfigVtbl>() == 14 * size_of::<P>());
assert!(offset_of!(IPolicyConfigVtbl, set_endpoint_visibility) == 14 * size_of::<P>());
assert!(size_of::<IPolicyConfigVtbl>() == 15 * size_of::<P>());
};
/// Set `device_id` as the default audio endpoint for eConsole/eMultimedia/eCommunications via the
@@ -603,3 +712,41 @@ pub(crate) fn set_default_endpoint(device_id: &str) -> Result<()> {
result
}
}
/// Show or hide an audio endpoint via the undocumented `IPolicyConfig::SetEndpointVisibility` —
/// the exact call behind mmsys.cpl's "Disable"/"Enable" device menu. A hidden endpoint drops to
/// `DEVICE_STATE_DISABLED`: it vanishes from every ACTIVE enumeration and cannot be opened, but
/// its devnode, driver binding and stamped identity all stay put — showing it again is instant
/// and raises no PnP traffic. pub(crate): the pad-endpoint provider parks its "Wireless
/// Controller" speaker hidden while no client pad is attached (a visible idle pad speaker makes
/// libScePad titles engage their DualSense-haptics path against an endpoint nothing services —
/// the 2026-08-14 Helldivers 2 field confirmation).
pub(crate) fn set_endpoint_visibility(device_id: &str, visible: bool) -> Result<()> {
use windows::core::{IUnknown, Interface, GUID, PCWSTR};
use windows::Win32::System::Com::{CoCreateInstance, CLSCTX_ALL};
const CLSID_POLICY_CONFIG: GUID = GUID::from_u128(0x870af99c_171d_4f9e_af0d_e63df40c2bc9);
const IID_IPOLICY_CONFIG: GUID = GUID::from_u128(0xf8679f50_850a_41cf_9c72_430f290290c8);
let wide: Vec<u16> = device_id.encode_utf16().chain(std::iter::once(0)).collect();
// SAFETY: same contract as `set_default_endpoint` — owned IUnknown from CoCreateInstance,
// QI'd pointer checked non-null, the call goes through the assertion-pinned vtable slot with
// a NUL-terminated UTF-16 id and an INT bool, and the QI'd pointer is Released before return.
unsafe {
let unk: IUnknown = CoCreateInstance(&CLSID_POLICY_CONFIG, None, CLSCTX_ALL)
.map_err(|e| anyhow!("CoCreateInstance(PolicyConfig): {e}"))?;
let mut raw: *mut c_void = std::ptr::null_mut();
unk.query(&IID_IPOLICY_CONFIG, &mut raw)
.ok()
.map_err(|e| anyhow!("QueryInterface(IPolicyConfig): {e}"))?;
if raw.is_null() {
bail!("IPolicyConfig QueryInterface returned null");
}
let vtbl = *(raw as *const *const IPolicyConfigVtbl);
let hr = ((*vtbl).set_endpoint_visibility)(raw, PCWSTR(wide.as_ptr()), visible as i32);
((*vtbl).release)(raw);
hr.ok()
.map_err(|e| anyhow!("SetEndpointVisibility({visible}): {e}"))
}
}
@@ -46,8 +46,8 @@ pub(crate) struct Removed {
pub endpoint_records: usize,
}
/// Restore the default playback device if we left it parked, then remove every audio devnode
/// this product minted, newest registry record and all.
/// Restore the default playback/recording devices if we left them parked, then remove every
/// audio devnode this product minted, newest registry record and all.
///
/// Best-effort throughout, like the rest of the (un)install path: a devnode that refuses to go
/// is counted and reported, never fatal — a non-zero exit here would abort the whole uninstaller
@@ -59,7 +59,7 @@ pub(crate) fn purge() -> Result<Removed> {
// what the operator had. Putting it back is the difference between "the box works again"
// and "the box works again, on the device it started with".
if audio_control::unpark_default_for_uninstall() {
println!("restored the default playback device this host had parked");
println!("restored the default audio device(s) this host had parked");
}
let mut out = Removed::default();
@@ -25,6 +25,12 @@
//! 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.
//! 4. **Visibility** ([`set_visibility`]): the endpoint parks HIDDEN (`DEVICE_STATE_DISABLED`)
//! whenever no client pad is attached — provisioning hides it at startup, the per-pad
//! streamer shows it for exactly the pad's lifetime. The DualSense disguise that makes games
//! route haptics at it during a session makes idle libScePad titles STALL on it otherwise
//! (Helldivers 2, field-confirmed 2026-08-14: 25 FPS 1% lows with the host idle). The
//! devnode, driver binding and stamps stay put, so flips raise no PnP traffic.
//!
//! 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
@@ -1484,6 +1490,10 @@ static PROVISIONING: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBo
pub(crate) fn provision_at_startup() {
if !pad_audio_enabled() {
tracing::info!("pad audio disabled (PUNKTFUNK_PAD_AUDIO=0)");
// Endpoints a previous run provisioned persist and stay VISIBLE — and a visible idle
// pad speaker is exactly what libScePad titles stall on (see [`set_visibility`]).
// Turning the feature off must also park the leftovers.
hide_leftover_endpoints();
return;
}
if PROVISIONED.get().is_some() {
@@ -1531,6 +1541,17 @@ pub(crate) fn provision_at_startup() {
stored-but-not-served until the next reboot"),
}
}
// Park every provisioned endpoint HIDDEN until a client pad actually attaches. The
// expensive work (devnode, driver bind, stamps, the AEB kick above) stays at boot —
// the #185 lesson: no PnP traffic at session boundaries — but the ENDPOINT must not
// sit visible on an idle box: libScePad titles (Helldivers 2, field-confirmed
// 2026-08-14) find the "Wireless Controller" speaker BY IDENTITY, engage their
// DualSense-haptics path against it, and stall on an endpoint nothing services —
// 1% lows of 25 FPS with the host completely idle. The per-pad streamer shows it
// for exactly the pad's lifetime, like a real DualSense arriving.
for pe in &eps {
set_visibility(&pe.endpoint_id, pe.pad_index, false);
}
// 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
@@ -1570,6 +1591,54 @@ pub(crate) fn ensure_provisioned() {
}
}
/// Show or hide a pad endpoint (best-effort, logged). Hidden = `DEVICE_STATE_DISABLED` via
/// [`audio_control::set_endpoint_visibility`] — the endpoint keeps its devnode, driver binding
/// and DualSense stamps, but vanishes from every ACTIVE enumeration and cannot be opened.
///
/// WHY pad endpoints park hidden: the stamp set exists so libScePad titles read the endpoint as
/// a real DualSense speaker and route haptics audio at it — during a pad session that is the
/// feature, on an idle box it is a trap. Helldivers 2 (field-confirmed 2026-08-14) finds the
/// idle "Wireless Controller" speaker, engages its DualSense-haptics path against an endpoint
/// nothing services, and drops to 25 FPS 1% lows with the host completely idle; the manual
/// community remedy is disabling the device in mmsys.cpl — this is that remedy, automated and
/// scoped to "no pad attached". Visibility flips raise no PnP traffic (the #185 lesson), only
/// an endpoint state notification — the same event a real pad's arrival/departure raises.
pub(crate) fn set_visibility(endpoint_id: &str, pad_index: u8, visible: bool) {
match audio_control::set_endpoint_visibility(endpoint_id, visible) {
Ok(()) => tracing::info!(pad = pad_index, endpoint = %endpoint_id,
state = if visible { "shown (client pad attached)" } else { "hidden (no pad attached)" },
"pad-audio endpoint visibility"),
Err(e) => tracing::warn!(pad = pad_index, endpoint = %endpoint_id, visible,
error = %format!("{e:#}"),
"pad-audio endpoint visibility change failed — an idle visible pad speaker can \
stall libScePad titles (disable it in mmsys.cpl as a manual fallback)"),
}
}
/// Hide any pad endpoints a previous run left behind — the `PUNKTFUNK_PAD_AUDIO=0` path, where
/// the provisioning worker never runs but persisted endpoints would otherwise stay visible (and
/// stall idle libScePad titles) forever.
fn hide_leftover_endpoints() {
let spawned = thread::Builder::new()
.name("punktfunk-pad-audio-hide".into())
.spawn(|| {
if wasapi::initialize_mta().ok().is_err() {
return;
}
for idx in 0..4u8 {
match find(idx) {
Ok(Some(pe)) if !pe.endpoint_id.is_empty() => {
set_visibility(&pe.endpoint_id, idx, false);
}
_ => {}
}
}
});
if let Err(e) = spawned {
tracing::warn!(error = %e, "could not spawn the pad-endpoint hide sweep");
}
}
/// 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)]
@@ -24,8 +24,8 @@
//! the set changes — the thread says why once, then parks on a cheap fingerprint poll and
//! re-plans the instant the set moves (the 2026-08 field case hammered a full wiring pass —
//! IPolicyConfig writes included — every 2 s for 8+ minutes without ever being able to
//! succeed). On thread exit (capturer dropped at stream end) the parked default playback
//! device is restored.
//! succeed). On thread exit (capturer dropped at stream end) the parked default playback AND
//! recording devices are restored — both defaults are strictly session-scoped.
use super::capture_policy::{CaptureStats, FightDamper, FIGHT_BACKOFF, STATS_EVERY};
use super::{audio_control, wiring_plan, AudioCapturer, SAMPLE_RATE};
@@ -290,9 +290,13 @@ fn capture_thread(
}
}
}
// Hand the default playback device back to the operator (no-op if we never parked it, or if
// they changed it themselves mid-stream). COM is initialized on this thread.
// Hand the default playback AND recording devices back to the operator (no-ops if we never
// parked them, or if they changed them themselves mid-stream). COM is initialized on this
// thread. The recording restore is what keeps the parked default session-scoped — an idle
// box holding the default microphone on a virtual mic nothing feeds is the 2026-08
// Helldivers 2 tank (see `audio_control`'s module docs).
audio_control::restore_default_playback();
audio_control::restore_default_recording();
Ok(())
}
@@ -261,8 +261,10 @@ fn resolve_target() -> Result<(wasapi::Device, String)> {
// on the cable while later plans paired the default recording with the minted microphone
// nothing wrote into (see `minted::ensure_blocking`). Instant once latched.
super::minted::ensure_blocking();
// set_playback=false: the mic pump runs while the host is idle — only the desktop-audio
// capture may park the playback default (on the silent sink) for a stream's lifetime.
// park_defaults=false: the mic pump runs while the host is idle — only the desktop-audio
// capture may park the box's defaults (playback on the silent sink, recording on the virtual
// mic) for a stream's lifetime. An idle box must keep the operator's own devices default —
// an idle-parked recording default is the 2026-08 Helldivers 2 tank (`audio_control` docs).
let mut wiring = audio_control::wire_now(false);
if wiring.mic_render.is_none() && !wiring.mic_withheld {
// A WITHHELD mic skips the install attempt: the Streaming Microphone exists — the plan
@@ -241,6 +241,30 @@ pub(crate) fn silent_sink(lname: &str) -> bool {
lname.contains("steam streaming microphone")
}
/// A capture endpoint that surfaces a VIRTUAL device's audio (cables, streaming mics, mixer
/// strips, the host's own minted "Punktfunk" microphone) rather than a real microphone. The
/// recording-default hygiene pass must never move the box's default onto one of these.
pub(crate) fn virtual_capture(lname: &str) -> bool {
lname.contains("cable output")
|| lname.contains("steam streaming")
|| lname.contains("voicemeeter")
|| lname.contains("virtual")
|| lname.contains("punktfunk")
}
/// The first REAL capture endpoint (skipping `avoid_id` and every [`virtual_capture`]) — where
/// the recording-default hygiene sends a default an earlier build left parked on the virtual mic
/// while the host is idle. `None` on a box with no real microphone: nothing sane to move to, so
/// the default is left alone.
pub(crate) fn real_capture<'a>(
captures: &'a [Endpoint],
avoid_id: Option<&str>,
) -> Option<&'a Endpoint> {
captures
.iter()
.find(|(n, id)| Some(id.as_str()) != avoid_id && !virtual_capture(&n.to_lowercase()))
}
/// A known-virtual device (cables/streaming endpoints). A render WITHOUT these markers is real
/// hardware — the best loopback source (apps render there by default and the operator can also
/// hear it).
@@ -1137,6 +1161,29 @@ mod tests {
assert!(both.contains("16000") && both.contains("channel"), "{both}");
}
/// The recording-default hygiene picker: skips every virtual capture (cable, streaming mic,
/// the minted "Punktfunk" pair, VoiceMeeter) and lands on the real microphone — the exact
/// recording-tab zoo of the 2026-08-14 Helldivers 2 field box.
#[test]
fn recording_hygiene_picks_the_real_microphone() {
let captures = [
ep("Microphone (2- Punktfunk)"),
ep("CABLE Output (VB-Audio Virtual Cable)"),
ep("Microphone (Steam Streaming Microphone)"),
ep("VoiceMeeter Output (VB-Audio VoiceMeeter VAIO)"),
ep("Desktop Microphone (2- Microsoft LifeCam HD-3000)"),
];
assert_eq!(
real_capture(&captures, None).unwrap().0,
"Desktop Microphone (2- Microsoft LifeCam HD-3000)"
);
// `avoid_id` guards the plan's own mic capture even when its name would pass the
// virtual test; with nothing else real, the answer is honestly None.
let only = [ep("Desk Mic (USB)")];
assert!(real_capture(&only, Some("id-desk mic (usb)")).is_none());
assert!(real_capture(&[], None).is_none());
}
/// Operator override beats the candidate order.
#[test]
fn env_override_wins() {
+28 -3
View File
@@ -623,12 +623,15 @@ pub fn dualsense_windows_test(args: &[String]) -> Result<()> {
Ok(())
}
/// Windows: pad-audio endpoint provisioning — `pad-endpoint ensure|remove|status [--index N]`.
/// Windows: pad-audio endpoint provisioning — `pad-endpoint
/// ensure|remove|status|tone|capture|show|hide [--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);
/// hatch only, endpoints are persistent by design; `show`/`hide` flip the endpoint's
/// visibility (the host parks it hidden while no client pad is attached — show it before
/// `tone`/`capture`). Stamping needs SYSTEM (the MMDevices ACL);
/// run `ensure` under the service account or PsExec when the property-store route is denied.
/// Windows: the audio-substrate toolbox (`windows-audio-endpoints-and-vbcable.md`) —
/// `audio-probe ssm|sink|sss-primary|mint|plan|cleanup [--keep]`. The S1S3 spikes (`ssm` =
@@ -744,7 +747,29 @@ pub fn pad_endpoint(args: &[String]) -> Result<()> {
pe::capture_probe(&endpoint_id, secs)
}
Some("status") => pe::print_status(idx),
_ => anyhow::bail!("usage: punktfunk-host pad-endpoint <ensure|remove|status> [--index N]"),
// `show`/`hide` — flip the endpoint's visibility (DEVICE_STATE_DISABLED). The host parks
// pad endpoints hidden while no client pad is attached (idle libScePad titles stall on a
// visible one — the 2026-08-14 Helldivers 2 field case); `tone`/`capture` need the
// endpoint SHOWN first, and `hide` puts the box back to the idle-safe state after.
Some(verb @ ("show" | "hide")) => {
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 {verb}: pad {idx} has no endpoint — run `ensure`");
return Ok(());
}
},
};
pe::set_visibility(&endpoint_id, idx, verb == "show");
println!("pad-endpoint {verb}: {endpoint_id}");
Ok(())
}
_ => anyhow::bail!(
"usage: punktfunk-host pad-endpoint \
<ensure|remove|status|tone|capture|show|hide> [--index N]"
),
}
}
+11 -1
View File
@@ -321,16 +321,26 @@ pub(super) fn spawn(
}
let stop_t = stop.clone();
let endpoint_id = ep.endpoint_id;
let vis_id = endpoint_id.clone();
match std::thread::Builder::new()
.name(format!("punktfunk1-pad{pad}"))
.spawn(move || {
// COM for the visibility flips (the capturer's opens run on their own thread).
let _ = wasapi::initialize_mta();
// The endpoint parks HIDDEN while no pad is attached — an idle visible "Wireless
// Controller" speaker makes libScePad titles engage their DualSense-haptics path
// against an endpoint nothing services (the 2026-08-14 Helldivers 2 field tank).
// Show it for exactly this pad's lifetime, like a real DualSense arriving; the
// capturer's open/backoff loop absorbs the moment audiosrv takes to re-activate.
crate::audio::pad_endpoint::set_visibility(&vis_id, pad, true);
pad_audio_thread(
conn,
pad,
kinds,
move || crate::audio::pad_endpoint::PadLoopbackCapturer::open(&endpoint_id),
stop_t,
)
);
crate::audio::pad_endpoint::set_visibility(&vis_id, pad, false);
}) {
Ok(join) => Some(PadAudioHandle {
stop,