Compare commits

...
Author SHA1 Message Date
enricobuehler 96f75f4e52 style: rustfmt the abandoned-devnode sweep
ci / rust-arm64 (pull_request) Successful in 1m20s
ci / bun-nix (pull_request) Successful in 20s
ci / docs-drift (pull_request) Successful in 21s
ci / docs-site (pull_request) Successful in 1m3s
ci / web (pull_request) Successful in 3m33s
android / android (pull_request) Successful in 10m5s
ci / rust (pull_request) Successful in 9m45s
2026-08-23 09:49:49 +02:00
enricobuehler 4c5b97cfe4 fix(host,audio): the registry stamp route reached for the Render hive even for capture endpoints
ci / rust-arm64 (pull_request) Successful in 1m21s
ci / rust (pull_request) Failing after 2m50s
ci / web (pull_request) Successful in 1m6s
ci / bun-nix (pull_request) Successful in 22s
ci / docs-site (pull_request) Successful in 1m7s
ci / docs-drift (pull_request) Successful in 23s
android / android (pull_request) Canceled after 7m19s
write_stamps falls back to a raw-registry write when the property store
denies it. That fallback built its path from MMDEV_RENDER_PATH unconditionally,
so stamping the minted microphone's CAPTURE endpoint reached for
...\MMDevices\Audio\Render\{capture-guid}\Properties - a key that cannot
exist. RegOpenKeyExW then failed, write_stamps returned the error, and
stamp_identity degraded to 'keeps the driver's default name'.

Invisible to the pad program, whose endpoints are render-only, and invisible on
any box where the property store route succeeds (both field logs show
registry=[] on every stamp line, so neither reporter ever took this path). It
only bites where the property store is denied - exactly the boxes the ACL
repair exists for.

The hive now follows the direction the endpoint id encodes, with render as the
default for anything unrecognised. Unit-tested.
2026-08-23 09:40:01 +02:00
enricobuehler 4beee17953 fix(host,audio): merge the abandoned-devnode tests into the existing module
ci / bun-nix (pull_request) Successful in 29s
ci / web (pull_request) Successful in 1m8s
ci / docs-drift (pull_request) Successful in 1m9s
ci / docs-site (pull_request) Successful in 1m35s
ci / rust (pull_request) Failing after 1m44s
ci / rust-arm64 (pull_request) Successful in 2m30s
android / android (pull_request) Successful in 5m48s
2026-08-23 09:08:57 +02:00
enricobuehler 4ad0055416 fix(host,audio): a host that died mid-mint left an orphan devnode, and the next start minted a duplicate
Minting an audio devnode is two PnP steps: SetupDiRegisterDeviceInfo makes it
real and bindable, then the owner marker goes into Device Parameters. A host
that dies between them - the 0.30.0 TLS-destructor abort did exactly this,
five times on one field box - leaves a registered, driver-bound, endpoint-
serving devnode carrying no marker.

Nothing resolved it afterwards. find_role_devnode matches on the marker, so
the next pass minted a SECOND devnode and the orphan stayed: a duplicate
'Punktfunk Speakers'/'Punktfunk Microphone' in the Sound zoo that no uninstall
removed, because devnode_cleanup is marker-matched too. A field box showed
exactly this shape - 'Punktfunk Speakers (3- Punktfunk)' beside an unstamped
'Punktfunk Speakers (4- Steam Streaming Speakers)' - in every wiring plan it
logged. Reproduced on .173 against the shipping 0.31.2 binary by clearing the
marker: ROOT\MEDIA\0005 was minted and 0004 was abandoned, still active and
still serving two live Punktfunk Microphone endpoints.

* minted.rs adopts before it mints. An unmarked ROOT\MEDIA\NNNN devnode
  carrying the role's Steam hardware id is re-marked and reused, so the
  endpoint GUID survives and no device-change broadcast is paid.
* devnode_cleanup sweeps the same shape, so orphans already on a box go at
  uninstall instead of outliving the product.

The instance prefix is what keeps both off Valve's own devices: Steam's
devnodes carry these hardware ids and are ROOT-enumerated too, but live under
ROOT\SteamStreamingSpeakers\* / ROOT\SteamStreamingMicrophone\*. Only
ROOT\MEDIA\* can come from our SetupDiCreateDeviceInfoW(DICD_GENERATE_ID).
is_abandoned_mint carries that rule with unit tests.
2026-08-23 09:03:52 +02:00
enricobuehler db9cd40079 The hand-back never checked that the panel came back, and a crashed host left game mode asleep (#375)
ci / bun-nix (push) Successful in 37s
ci / rust-arm64 (push) Successful in 1m31s
ci / web (push) Successful in 1m46s
ci / docs-site (push) Successful in 1m43s
ci / docs-drift (push) Successful in 1m48s
deb / build-publish-gamescope (push) Successful in 1m20s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 50s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 16s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 14s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 14s
deb / build-publish-client-arm64 (push) Successful in 2m2s
docker / builders (ci/flatpak-ci.Dockerfile, punktfunk-flatpak-ci) (push) Successful in 11s
docker / builders (ci/gamescope-trixie.Dockerfile, punktfunk-gamescope-trixie) (push) Successful in 12s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 13s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 15s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 1m44s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 2m7s
docker / builders-arm64cross (push) Successful in 23s
deb / build-publish-host (push) Successful in 5m35s
ci / rust (push) Successful in 7m21s
docker / deploy-docs (push) Successful in 51s
android / android (push) Successful in 10m16s
arch / build-publish (push) Successful in 10m38s
deb / build-publish (push) Successful in 5m18s
deb / smoke-install (push) Successful in 4m30s
windows-host / package (push) Successful in 16m42s
windows-host / winget-source (push) Skipped
windows-host / canary-manifest (push) Successful in 23s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 21m6s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 21m22s
Reproduced on both Bazzite 44.20260818 and Nobara f44: a host killed mid-takeover
left the box's Game Mode running `/usr/bin/sleep infinity` with the panel lit and
blank, permanently. Fixed and re-verified on both boxes against canary
0.32.0-0.ci15147.gc63e8cee, with no regression to the ordinary disconnect.

The hand-back also measures its own outcome now instead of trusting a systemd job
status, so any other route to a dark panel is caught and escalated rather than
logged as success.
2026-08-22 23:38:23 +00:00
enricobuehler b670b5d844 Merge pull request 'A TV negotiated the refresh its menu pinned, not the one it outputs' (#378) from worktree-tv-refresh-mismatch into main
ci / web (push) Successful in 2m11s
ci / docs-site (push) Successful in 2m1s
ci / rust-arm64 (push) Successful in 2m45s
ci / docs-drift (push) Successful in 1m27s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 13s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 18s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 23s
docker / builders (ci/flatpak-ci.Dockerfile, punktfunk-flatpak-ci) (push) Successful in 31s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 29s
docker / builders (ci/gamescope-trixie.Dockerfile, punktfunk-gamescope-trixie) (push) Successful in 39s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 36s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 41s
ci / bun-nix (push) Successful in 33s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 11s
docker / builders-arm64cross (push) Successful in 11s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 1m49s
docker / deploy-docs (push) Successful in 36s
ci / rust (push) Successful in 5m41s
android / android (push) Successful in 9m51s
2026-08-22 22:47:19 +00:00
enricobuehler 064ea3de7d fix(android): a TV negotiated the refresh its MENU pinned, not the one it outputs
ci / bun-nix (pull_request) Successful in 29s
ci / docs-drift (pull_request) Successful in 39s
ci / docs-site (pull_request) Successful in 1m12s
ci / web (pull_request) Successful in 1m29s
ci / rust-arm64 (pull_request) Successful in 2m17s
ci / rust (pull_request) Successful in 5m7s
android / android (pull_request) Successful in 6m22s
Field report: on Android TV / Fire Stick, latency explodes whenever the client's
refresh differs from the host's, and setting the refresh by hand is the only
workaround.

The client was manufacturing that mismatch itself, in three steps:

  1. `MainActivity.onCreate` pins the panel to its highest-refresh mode for the
     console UI (`setConsoleHighRefreshRate(true)`) — unconditionally, TVs
     included. That pin exists for phone refresh governors (Nothing OS's LTPO
     logic among them) which cap third-party apps at 60 Hz. No TV has one.
  2. At connect, `nativeDisplayMode` resolves "Native" refresh from
     `display.mode` — which now reports the mode the MENU pinned, not the TV's
     real HDMI output. So the session negotiates (say) 120.
  3. `StreamScreen` releases the pin again on TV, by design: there the decoder's
     own `setFrameRate(CHANGE_FRAME_RATE_ALWAYS)` governs the HDMI mode. The
     panel falls back to 60 while the host is already serving 120.

A 120 fps stream on a 60 Hz output, by construction, on exactly the two form
factors in the report. Picking a refresh explicitly is precisely what bypasses
step 2, which is why that is the workaround people found. The mode comparator
sorts refresh before area, so the same pin could also drop a 4K TV to 1080p120
and negotiate the stream at that.

Fixed at the choke point: `resolveHighRefreshMode` returns early on a TV, leaving
`highRefreshModeId` at 0, which `setConsoleHighRefreshRate` already treats as a
no-op — so all three of its callers are covered by the one guard. A TV that
genuinely wants 120 still gets it by choosing it, driven by the native mode
switch, exactly as the TV path documents.

Also in the same chain: `nativeDisplayMode` TRUNCATED the panel rate, so a TV
reporting the fractional NTSC rates over HDMI (59.94, 29.97, 23.976) asked the
host for 59 / 29 / 23 — rates no display mode has, which the host serves by
clamping down to the highest it advertises at or below. Rounded now, which also
makes it agree with `MainActivity.streamPanelFps`; the two describe the same
panel and must not disagree.
2026-08-23 00:21:50 +02:00
5 changed files with 218 additions and 12 deletions
@@ -515,8 +515,21 @@ class MainActivity : ComponentActivity() {
else -> KeyEvent.KEYCODE_DPAD_RIGHT
}
/** Resolve the panel's highest-refresh mode (same resolution) once, for [setConsoleHighRefreshRate]. */
/**
* Resolve the panel's highest-refresh mode (same resolution) once, for [setConsoleHighRefreshRate].
*
* NEVER on a TV, which leaves the id at `0` and makes every [setConsoleHighRefreshRate] call a
* no-op. The pin exists for phone refresh governors that cap third-party apps at 60 Hz; a TV has
* no such governor, and there it does active harm. `display.mode` is what [nativeDisplayMode]
* reads to resolve "Native" refresh at connect, so a menu-time pin makes the session negotiate
* the PINNED rate rather than the TV's real HDMI output — and [StreamScreen] then releases the
* pin on TV (the decoder's own mode switch governs there), dropping the panel back to 60 while
* the host is already serving 120. Every frame then waits out that mismatch, which is the
* "latency explodes unless I set the refresh by hand" field report: picking a refresh explicitly
* is precisely what bypasses the corrupted `nativeDisplayMode` answer.
*/
private fun resolveHighRefreshMode() {
if (isTvDevice(this)) return
@Suppress("DEPRECATION")
val disp = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) display else windowManager.defaultDisplay
highRefreshModeId = disp?.supportedModes?.maxWithOrNull(
@@ -478,7 +478,12 @@ fun nativeDisplayMode(context: Context): Triple<Int, Int, Int> {
val mode = display.mode
val w = mode.physicalWidth
val h = mode.physicalHeight
val hz = mode.refreshRate.toInt().coerceAtLeast(1)
// ROUNDED, not truncated: TVs report the fractional NTSC rates over HDMI (59.94, 29.97,
// 23.976), and `toInt()` turns 59.94 into 59 — a rate no display mode anywhere has, which the
// host then serves by clamping DOWN to the highest mode it advertises at or below it. Rounding
// also keeps this agreeing with `MainActivity.streamPanelFps`, which already rounds; the two
// describe the same panel and must not disagree.
val hz = kotlin.math.round(mode.refreshRate).toInt().coerceAtLeast(1)
return Triple(maxOf(w, h), minOf(w, h), hz)
}
@@ -26,17 +26,26 @@
use super::{audio_control, audio_probe, minted, pad_endpoint as pe};
use anyhow::Result;
use windows::Win32::Devices::DeviceAndDriverInstallation::SetupDiEnumDeviceInfo;
use windows::Win32::Devices::DeviceAndDriverInstallation::{
SetupDiEnumDeviceInfo, SPDRP_HARDWAREID,
};
/// The `Device Parameters` REG_DWORD each punktfunk-minted devnode family stamps on itself. The
/// VALUE is what differs per family; presence of the NAME is "this one is ours", which is all a
/// sweep needs.
const OWNER_MARKERS: [&str; 3] = [
pub(crate) const OWNER_MARKERS: [&str; 3] = [
pe::PAD_INDEX_VALUE,
minted::ROLE_MARKER,
audio_probe::PROBE_MARKER,
];
/// The Steam streaming hardware ids every audio devnode this product mints is created with —
/// the second half of the ABANDONED-devnode test in [`owned_devnodes`].
const MINTED_HWIDS: [&str; 2] = [
"ROOT\\SteamStreamingSpeakers",
"ROOT\\SteamStreamingMicrophone",
];
/// What one sweep removed. `endpoint_records` is counted separately from `devnodes` because the
/// registry half is best-effort by design — see [`delete_endpoint_record`].
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
@@ -117,11 +126,91 @@ fn owned_devnodes() -> Result<Vec<String>> {
.any(|m| pe::read_devparam_dword(&set, &did, m).is_some())
{
out.push(inst);
continue;
}
// ABANDONED: `ROOT\MEDIA\NNNN` carrying one of our minting hardware ids but no marker at
// all — a devnode registered by a host that died before the marker write landed. It is
// still bound and still serving endpoints, so leaving it behind is the "uninstalling
// punktfunk left Sound settings full of Punktfunk devices forever" report all over again.
//
// The instance prefix is what makes this safe, and it is NOT redundant with
// [`is_removable_instance`]: Steam's own devnodes carry these very hardware ids and are
// ROOT-enumerated too, but live under `ROOT\SteamStreamingSpeakers\*` /
// `ROOT\SteamStreamingMicrophone\*`. Only `ROOT\MEDIA\*` can have come from our
// `SetupDiCreateDeviceInfoW(… DICD_GENERATE_ID)`.
if is_abandoned_mint(
&inst,
&pe::devnode_multi_sz_prop(&set, &did, SPDRP_HARDWAREID),
) {
out.push(inst);
}
}
Ok(out)
}
/// The ABANDONED-devnode test, split out from the PnP enumeration so the rule that keeps this
/// sweep off VALVE'S OWN devices is checkable without a live devinfo set. See [`owned_devnodes`].
fn is_abandoned_mint(instance_id: &str, hwids: &[String]) -> bool {
instance_id
.to_ascii_uppercase()
.starts_with("ROOT\\MEDIA\\")
&& MINTED_HWIDS
.iter()
.any(|want| hwids.iter().any(|h| h.eq_ignore_ascii_case(want)))
}
#[cfg(test)]
mod abandoned_tests {
use super::is_abandoned_mint;
fn hw(s: &str) -> Vec<String> {
vec![s.to_string()]
}
#[test]
fn adopts_our_own_unmarked_devnodes() {
// What a host that died mid-mint leaves behind, either role.
assert!(is_abandoned_mint(
r"ROOT\MEDIA\0004",
&hw(r"ROOT\SteamStreamingMicrophone")
));
assert!(is_abandoned_mint(
r"ROOT\MEDIA\0002",
&hw(r"ROOT\SteamStreamingSpeakers")
));
// PnP casing is not guaranteed on either half.
assert!(is_abandoned_mint(
r"root\media\0009",
&hw(r"root\steamstreamingspeakers")
));
}
#[test]
fn never_matches_valves_own_devices() {
// THE safety rule: Steam's devnodes carry the very same hardware ids and are ROOT-
// enumerated too — only the instance prefix separates them from ours.
assert!(!is_abandoned_mint(
r"ROOT\STEAMSTREAMINGMICROPHONE\0000",
&hw(r"ROOT\SteamStreamingMicrophone")
));
assert!(!is_abandoned_mint(
r"ROOT\STEAMSTREAMINGSPEAKERS\0000",
&hw(r"ROOT\SteamStreamingSpeakers")
));
}
#[test]
fn never_matches_other_vendors_or_real_hardware() {
// VB-Cable mints ROOT\MEDIA devnodes too — a different hardware id is all that saves it.
assert!(!is_abandoned_mint(r"ROOT\MEDIA\0000", &hw("VBAudioVACWDM")));
assert!(!is_abandoned_mint(
r"HDAUDIO\FUNC_01&VEN_10EC&DEV_0897",
&hw(r"ROOT\SteamStreamingSpeakers")
));
assert!(!is_abandoned_mint(r"ROOT\MEDIA\0001", &[]));
}
}
/// A devnode this sweep is allowed to remove: ROOT-enumerated, i.e. software-created.
///
/// Every devnode we mint comes from `SetupDiCreateDeviceInfoW(… DICD_GENERATE_ID)` on the MEDIA
@@ -275,13 +275,22 @@ fn ensure_role(role: Role) -> Result<(String, String, Option<String>)> {
let (hwid, inf) = discover_driver(role.needle(), role.inf_name())?;
let devnode = match find_role_devnode(role)? {
Some(inst) => inst,
None => {
let inst = pe::create_media_devnode(role.desc(), &hwid, |set, did| {
pe::write_devparam_dword(set, did, ROLE_MARKER, role.value())
})?;
tracing::info!(role = role.label(), devnode = %inst, "minted an audio devnode");
inst
}
// Before minting a SECOND devnode, reclaim an abandoned one. Minting is two PnP steps
// (register, then mark), and a host that dies between them — the 0.30.0 teardown abort
// did exactly this, five times on one box — leaves a registered, driver-bound, endpoint-
// serving devnode that carries no marker. Nothing then resolves it: the next pass mints
// a fresh one and the orphan lingers as a duplicate "Punktfunk Speakers"/"Punktfunk
// Microphone" in the Sound zoo, invisible to the marker-matched uninstall sweep.
None => match adopt_orphan_devnode(role, &hwid)? {
Some(inst) => inst,
None => {
let inst = pe::create_media_devnode(role.desc(), &hwid, |set, did| {
pe::write_devparam_dword(set, did, ROLE_MARKER, role.value())
})?;
tracing::info!(role = role.label(), devnode = %inst, "minted an audio devnode");
inst
}
},
};
pe::bind_driver(&hwid, &inf)?;
@@ -531,6 +540,61 @@ fn find_role_devnode(role: Role) -> Result<Option<String>> {
Ok(None)
}
/// Reclaim an ABANDONED punktfunk devnode for `role`, re-marking it so it resolves normally from
/// here on; `None` when there is nothing to adopt (the ordinary first-mint path).
///
/// The shape adopted is `ROOT\MEDIA\NNNN` + the role's Steam hardware id + NO owner marker.
/// That triple can only be ours: `ROOT\MEDIA\NNNN` is what
/// `SetupDiCreateDeviceInfoW(… DICD_GENERATE_ID)` on the MEDIA class yields, and STEAM'S OWN
/// devnodes are enumerated under `ROOT\SteamStreamingSpeakers\*` /
/// `ROOT\SteamStreamingMicrophone\*` — they carry the same hardware id but never that instance
/// prefix, which is precisely what keeps this from adopting (and later sweeping) Steam's devices.
/// A marker of ANY family is left alone: it is a live devnode, ours but spoken for.
///
/// Which family the orphan came from does not matter. Every one is a plain instance of the same
/// Valve driver; roles are ours to assign, and re-marking it here is what makes the assignment
/// stick across restarts.
fn adopt_orphan_devnode(role: Role, hwid: &str) -> Result<Option<String>> {
use windows::Win32::Devices::DeviceAndDriverInstallation::{
SetupDiEnumDeviceInfo, SPDRP_HARDWAREID,
};
let set = pe::media_class_devs()?;
for i in 0.. {
let mut did = pe::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) = pe::instance_id(&set, &did) else {
continue;
};
if !inst.to_ascii_uppercase().starts_with("ROOT\\MEDIA\\") {
continue;
}
if !pe::devnode_multi_sz_prop(&set, &did, SPDRP_HARDWAREID)
.iter()
.any(|h| h.eq_ignore_ascii_case(hwid))
{
continue;
}
if super::devnode_cleanup::OWNER_MARKERS
.iter()
.any(|m| pe::read_devparam_dword(&set, &did, m).is_some())
{
continue;
}
pe::write_devparam_dword(&set, &mut did, ROLE_MARKER, role.value())?;
tracing::warn!(
role = role.label(),
devnode = %inst,
"adopted an abandoned audio devnode — one of ours whose owner marker never landed \
(a host that died mid-mint). Re-marked and reused instead of minting a duplicate"
);
return Ok(Some(inst));
}
Ok(None)
}
/// Find the (exact hardware id, INF path) for one of Steam's streaming drivers: prefer any
/// installed devnode whose hardware-id list contains `needle` (its `oemNN.inf` is the driver
/// Windows already trusts), else fall back to Steam's driver directory. Shared with the
@@ -1192,6 +1192,17 @@ fn grant_system_full_control(subkey_path: &str) -> Result<()> {
result
}
/// The MMDevices hive an endpoint's record lives in, chosen by the direction its id encodes
/// (`{0.0.1.…}` = capture, anything else = render). Render is the safe default: it is what every
/// non-capture id resolves to, and the pad program only ever has render endpoints.
fn mmdev_path_for(endpoint_id: &str) -> &'static str {
if endpoint_id.starts_with(CAPTURE_ENDPOINT_ID_PREFIX) {
MMDEV_CAPTURE_PATH
} else {
MMDEV_RENDER_PATH
}
}
/// 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.
@@ -1199,7 +1210,14 @@ 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");
// The hive follows the endpoint's DIRECTION. This was hardcoded to Render, which is
// invisible for the pad program (its endpoints are render-only) but wrong for the minted
// provider, which stamps the virtual microphone's CAPTURE endpoint through the same
// writer: the fallback then reached for `…\Render\{capture-guid}\Properties`, a key that
// cannot exist, so every registry-route stamp of a capture endpoint failed on a box where
// the property store was denied — silently, since the caller degrades to "keeps the
// driver's default name".
let path = format!(r"{}\{guid}\Properties", mmdev_path_for(endpoint_id));
grant_system_full_control(&path)
.with_context(|| format!("make {path} writable (registry stamp route)"))?;
let key = RegKey::predef(HKEY_LOCAL_MACHINE)
@@ -2109,6 +2127,23 @@ fn pad_capture_thread(
mod tests {
use super::*;
/// The registry stamp route must reach for the hive matching the endpoint's DIRECTION —
/// it was hardcoded to Render, so a capture endpoint's fallback stamp could never land.
#[test]
fn registry_stamp_hive_follows_the_endpoint_direction() {
assert_eq!(
mmdev_path_for("{0.0.1.00000000}.{2753f927-2093-4ab4-aa90-9d880e959128}"),
MMDEV_CAPTURE_PATH,
"the minted microphone's capture endpoint records under Capture"
);
assert_eq!(
mmdev_path_for("{0.0.0.00000000}.{5da9b5c9-8a10-4b54-8cf6-ce02b8354f16}"),
MMDEV_RENDER_PATH,
);
// Anything unrecognised keeps the old behaviour rather than inventing a hive.
assert_eq!(mmdev_path_for("nonsense"), MMDEV_RENDER_PATH);
}
/// 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]