diff --git a/tools/sunshine-ds5-sidecar/DefaultAudioEndpointGuard.cs b/tools/sunshine-ds5-sidecar/DefaultAudioEndpointGuard.cs
index 0a60f2c8..6ac3ae9b 100644
--- a/tools/sunshine-ds5-sidecar/DefaultAudioEndpointGuard.cs
+++ b/tools/sunshine-ds5-sidecar/DefaultAudioEndpointGuard.cs
@@ -1,18 +1,22 @@
using System.Runtime.InteropServices;
using System.Text;
+using System.Threading.Channels;
using Microsoft.Win32;
namespace Sunshine.Ds5Sidecar;
///
-/// Fails closed when Windows selects the virtual HIDMaestro DualSense speaker
-/// as a default render endpoint. This is intentionally read-only: changing
-/// Windows audio policy relies on undocumented APIs and would make the helper
-/// responsible for restoring user preferences after crashes or upgrades.
+/// Fails closed when Windows selects a virtual HIDMaestro DualSense audio
+/// endpoint as a default render or capture endpoint. The guard is a read-only
+/// fallback for systems where the documented never-default policy cannot be
+/// applied.
///
internal sealed class DefaultAudioEndpointGuard : IDisposable
{
- internal enum AudioRole : byte
+ private const int DataFlowCount = 2;
+ private const int AudioRoleCount = 3;
+
+ internal enum AudioRole
{
Console = 0,
Multimedia = 1,
@@ -24,6 +28,18 @@ internal enum AudioRole : byte
private static readonly Guid MmDeviceEnumeratorClass =
new("BCDE0395-E52F-467C-8E3D-C4579291692E");
private readonly CancellationTokenSource _stopping = new();
+ // Each flow/role pair owns one atomic latest-value slot. The capacity-one
+ // channel is only a non-blocking wakeup, so notification bursts coalesce
+ // without allowing a backlog to grow.
+ private readonly DefaultEndpointChange?[] _pendingEndpointChanges =
+ new DefaultEndpointChange?[DataFlowCount * AudioRoleCount];
+ private readonly Channel _endpointChangeSignal =
+ Channel.CreateBounded(new BoundedChannelOptions(1)
+ {
+ SingleReader = true,
+ SingleWriter = false,
+ FullMode = BoundedChannelFullMode.DropWrite,
+ });
private readonly Action _onViolation;
private readonly Task _worker;
private int _reported;
@@ -37,24 +53,31 @@ internal DefaultAudioEndpointGuard(Action onViolation)
private async Task MonitorAsync()
{
IMMDeviceEnumerator? enumerator = null;
+ EndpointNotificationClient? notification = null;
+ var registered = false;
try
{
var type = Type.GetTypeFromCLSID(MmDeviceEnumeratorClass, throwOnError: true)!;
enumerator = (IMMDeviceEnumerator)Activator.CreateInstance(type)!;
- using var timer = new PeriodicTimer(TimeSpan.FromMilliseconds(400));
- do
+ notification = new EndpointNotificationClient(OnDefaultDeviceChanged);
+ var registrationResult = enumerator.RegisterEndpointNotificationCallback(notification);
+ registered = registrationResult >= 0;
+
+ // Register before taking the initial snapshot so a default-device
+ // change racing startup is either observed by the callback or by
+ // the snapshot (and harmlessly deduplicated by _reported).
+ CheckCurrentDefaults(enumerator);
+ if (registered)
{
- foreach (var role in Enum.GetValues())
- {
- if (IsDefaultVirtualDualSense(enumerator, role) &&
- Interlocked.Exchange(ref _reported, 1) == 0)
- {
- _onViolation(role);
- return;
- }
- }
+ await ProcessEndpointChangesAsync();
+ }
+ else
+ {
+ Console.Error.WriteLine(
+ $"Unable to register the default audio endpoint monitor (0x{registrationResult:X8}); " +
+ "falling back to low-frequency polling");
+ await PollDefaultsAsync(enumerator);
}
- while (await timer.WaitForNextTickAsync(_stopping.Token));
}
catch (OperationCanceledException) when (_stopping.IsCancellationRequested)
{
@@ -68,23 +91,113 @@ private async Task MonitorAsync()
}
finally
{
+ if (registered && enumerator is not null && notification is not null)
+ {
+ var result = enumerator.UnregisterEndpointNotificationCallback(notification);
+ if (result < 0 && !_stopping.IsCancellationRequested)
+ Console.Error.WriteLine($"Unable to unregister the default audio endpoint monitor: 0x{result:X8}");
+ }
if (enumerator is not null && Marshal.IsComObject(enumerator))
Marshal.FinalReleaseComObject(enumerator);
+ GC.KeepAlive(notification);
+ }
+ }
+
+ private void CheckCurrentDefaults(IMMDeviceEnumerator enumerator)
+ {
+ foreach (var flow in Enum.GetValues())
+ {
+ foreach (var role in Enum.GetValues())
+ {
+ if (TryGetDefaultEndpointId(enumerator, flow, role, out var endpointId))
+ ReportIfVirtualDualSense(role, endpointId);
+ }
+ }
+ }
+
+ private async Task PollDefaultsAsync(IMMDeviceEnumerator enumerator)
+ {
+ using var timer = new PeriodicTimer(TimeSpan.FromSeconds(2));
+ while (await timer.WaitForNextTickAsync(_stopping.Token))
+ CheckCurrentDefaults(enumerator);
+ }
+
+ private async Task ProcessEndpointChangesAsync()
+ {
+ while (await _endpointChangeSignal.Reader.WaitToReadAsync(_stopping.Token))
+ {
+ _endpointChangeSignal.Reader.TryRead(out _);
+ for (var slot = 0; slot < _pendingEndpointChanges.Length; ++slot)
+ {
+ var change = Interlocked.Exchange(ref _pendingEndpointChanges[slot], null);
+ if (change is null)
+ continue;
+ try
+ {
+ ReportIfVirtualDualSense(change.Role, change.EndpointId);
+ }
+ catch (Exception error)
+ {
+ Console.Error.WriteLine(
+ $"Unable to inspect the changed {change.Flow} default audio endpoint: {error.Message}");
+ }
+ if (Volatile.Read(ref _reported) != 0)
+ return;
+ }
+ }
+ }
+
+ private void OnDefaultDeviceChanged(DataFlow flow, AudioRole role, string? endpointId)
+ {
+ if (endpointId is null || _stopping.IsCancellationRequested ||
+ Volatile.Read(ref _reported) != 0 || !TryGetEndpointSlot(flow, role, out var slot))
+ {
+ return;
+ }
+ Interlocked.Exchange(
+ ref _pendingEndpointChanges[slot], new DefaultEndpointChange(flow, role, endpointId));
+ _endpointChangeSignal.Writer.TryWrite(true);
+ }
+
+ private static bool TryGetEndpointSlot(DataFlow flow, AudioRole role, out int slot)
+ {
+ var flowIndex = (int)flow;
+ var roleIndex = (int)role;
+ if ((uint)flowIndex >= DataFlowCount || (uint)roleIndex >= AudioRoleCount)
+ {
+ slot = -1;
+ return false;
+ }
+ slot = flowIndex * AudioRoleCount + roleIndex;
+ return true;
+ }
+
+ private void ReportIfVirtualDualSense(AudioRole role, string endpointId)
+ {
+ if (Volatile.Read(ref _reported) != 0 ||
+ !IsVirtualDualSenseEndpoint(endpointId) ||
+ Interlocked.Exchange(ref _reported, 1) != 0)
+ {
+ return;
}
+ _onViolation(role);
}
- private static bool IsDefaultVirtualDualSense(IMMDeviceEnumerator enumerator, AudioRole role)
+ private static bool TryGetDefaultEndpointId(
+ IMMDeviceEnumerator enumerator, DataFlow flow, AudioRole role, out string endpointId)
{
+ endpointId = string.Empty;
IMMDevice? endpoint = null;
try
{
// AUDCLNT_E_DEVICE_INVALIDATED and E_NOTFOUND are normal while an
// endpoint is being created or removed, so treat any failed lookup
- // as "not currently default" and retry on the next poll.
- if (enumerator.GetDefaultAudioEndpoint(DataFlow.Render, role, out endpoint) < 0 || endpoint is null ||
- endpoint.GetId(out var endpointId) < 0)
+ // as "not currently default". A later notification (or the rare
+ // registration-failure polling fallback) will retry it.
+ if (enumerator.GetDefaultAudioEndpoint(flow, role, out endpoint) < 0 || endpoint is null ||
+ endpoint.GetId(out endpointId) < 0)
return false;
- return IsVirtualDualSenseEndpoint(endpointId);
+ return true;
}
finally
{
@@ -101,6 +214,21 @@ internal static bool IsVirtualDualSenseEndpoint(string endpointId)
if (CM_Locate_DevNodeW(out var node, instanceId, 0) != 0)
return false;
+ return IsVirtualDualSenseDeviceNode(node);
+ }
+
+ internal static bool IsVirtualDualSenseDeviceNode(string instanceId, bool includePhantom)
+ {
+ if (CM_Locate_DevNodeW(out var node, instanceId, 0) != 0 &&
+ (!includePhantom || CM_Locate_DevNodeW(out node, instanceId, 1) != 0))
+ {
+ return false;
+ }
+ return IsVirtualDualSenseDeviceNode(node);
+ }
+
+ private static bool IsVirtualDualSenseDeviceNode(uint node)
+ {
var chain = new List();
for (var depth = 0; depth < 12; ++depth)
{
@@ -172,6 +300,60 @@ public void Dispose()
private enum DataFlow
{
Render = 0,
+ Capture = 1,
+ }
+
+ private sealed record DefaultEndpointChange(
+ DataFlow Flow, AudioRole Role, string EndpointId);
+
+ [ComVisible(true)]
+ [ClassInterface(ClassInterfaceType.None)]
+ private sealed class EndpointNotificationClient : IMMNotificationClient
+ {
+ private readonly Action _onDefaultDeviceChanged;
+
+ internal EndpointNotificationClient(Action onDefaultDeviceChanged)
+ {
+ _onDefaultDeviceChanged = onDefaultDeviceChanged;
+ }
+
+ public int OnDeviceStateChanged(string deviceId, uint newState) => 0;
+ public int OnDeviceAdded(string deviceId) => 0;
+ public int OnDeviceRemoved(string deviceId) => 0;
+
+ public int OnDefaultDeviceChanged(DataFlow flow, AudioRole role, string? defaultDeviceId)
+ {
+ _onDefaultDeviceChanged(flow, role, defaultDeviceId);
+ return 0;
+ }
+
+ public int OnPropertyValueChanged(string deviceId, PropertyKey propertyKey) => 0;
+ }
+
+ [StructLayout(LayoutKind.Sequential)]
+ private readonly struct PropertyKey
+ {
+ private readonly Guid _formatId;
+ private readonly uint _propertyId;
+ }
+
+ [ComImport]
+ [Guid("7991EEC9-7E89-4D85-8390-6C703CEC60C0")]
+ [InterfaceType(ComInterfaceType.InterfaceIsIUnknown)]
+ private interface IMMNotificationClient
+ {
+ [PreserveSig]
+ int OnDeviceStateChanged([MarshalAs(UnmanagedType.LPWStr)] string deviceId, uint newState);
+ [PreserveSig]
+ int OnDeviceAdded([MarshalAs(UnmanagedType.LPWStr)] string deviceId);
+ [PreserveSig]
+ int OnDeviceRemoved([MarshalAs(UnmanagedType.LPWStr)] string deviceId);
+ [PreserveSig]
+ int OnDefaultDeviceChanged(DataFlow flow, AudioRole role,
+ [MarshalAs(UnmanagedType.LPWStr)] string? defaultDeviceId);
+ [PreserveSig]
+ int OnPropertyValueChanged([MarshalAs(UnmanagedType.LPWStr)] string deviceId,
+ PropertyKey propertyKey);
}
[ComImport]
@@ -186,9 +368,9 @@ private interface IMMDeviceEnumerator
[PreserveSig]
int GetDevice([MarshalAs(UnmanagedType.LPWStr)] string id, out IMMDevice endpoint);
[PreserveSig]
- int RegisterEndpointNotificationCallback(IntPtr callback);
+ int RegisterEndpointNotificationCallback(IMMNotificationClient callback);
[PreserveSig]
- int UnregisterEndpointNotificationCallback(IntPtr callback);
+ int UnregisterEndpointNotificationCallback(IMMNotificationClient callback);
}
[ComImport]
diff --git a/tools/sunshine-ds5-sidecar/DefaultAudioEndpointPolicy.cs b/tools/sunshine-ds5-sidecar/DefaultAudioEndpointPolicy.cs
new file mode 100644
index 00000000..036f9ed4
--- /dev/null
+++ b/tools/sunshine-ds5-sidecar/DefaultAudioEndpointPolicy.cs
@@ -0,0 +1,94 @@
+using Microsoft.Win32;
+
+namespace Sunshine.Ds5Sidecar;
+
+///
+/// Applies Windows' documented never-default policy to HIDMaestro-backed
+/// DualSense audio interfaces. The policy is scoped to the concrete virtual
+/// device instance, so a physical DualSense with the same VID/PID is untouched.
+///
+internal static class DefaultAudioEndpointPolicy
+{
+ private const string AudioInterfaceClass =
+ @"SYSTEM\CurrentControlSet\Control\DeviceClasses\{6994ad04-93ef-11d0-a3cc-00a0c9223196}";
+ private const string EndpointAssociation =
+ "{1DA5D803-D492-4EDD-8C23-E0C0FFEE7F0E},2";
+ private const string NeverSetAsDefaultEndpoint =
+ "{F3E80BEF-1723-4FF2-BCC4-7F83DC5E46D4},3";
+ private const string AnyKsNodeType = "{00000000-0000-0000-0000-000000000000}";
+ // FLOW_MASK_RENDER | FLOW_MASK_CAPTURE | every default-device role.
+ private const int AllRolesAndFlows = 0x00000307;
+
+ ///
+ /// Finds the virtual DualSense KS audio interface and applies the endpoint
+ /// policy. A short poll is needed because the USB audio child appears
+ /// asynchronously after HIDMaestro creates the composite device.
+ ///
+ /// True when a matching interface was found.
+ internal static bool EnsureNeverDefault(TimeSpan timeout, bool includePhantom, out bool changed)
+ {
+ var deadline = DateTime.UtcNow + timeout;
+ do
+ {
+ if (EnsureOnce(includePhantom, out changed))
+ return true;
+ if (DateTime.UtcNow >= deadline)
+ break;
+ Thread.Sleep(50);
+ }
+ while (true);
+
+ changed = false;
+ return false;
+ }
+
+ private static bool EnsureOnce(bool includePhantom, out bool changed)
+ {
+ changed = false;
+ var matched = false;
+ using var audioInterfaces = Registry.LocalMachine.OpenSubKey(AudioInterfaceClass, writable: false);
+ if (audioInterfaces is null)
+ return false;
+
+ foreach (var interfaceName in audioInterfaces.GetSubKeyNames())
+ {
+ var interfacePath = AudioInterfaceClass + "\\" + interfaceName;
+ using var interfaceKey = Registry.LocalMachine.OpenSubKey(interfacePath, writable: false);
+ if (interfaceKey?.GetValue("DeviceInstance") is not string deviceInstance ||
+ !DefaultAudioEndpointGuard.IsVirtualDualSenseDeviceNode(deviceInstance, includePhantom))
+ {
+ continue;
+ }
+
+ foreach (var referenceName in interfaceKey.GetSubKeyNames())
+ {
+ var parametersPath = interfacePath + "\\" + referenceName + "\\Device Parameters";
+ using var parameters = Registry.LocalMachine.OpenSubKey(parametersPath, writable: true);
+ if (parameters is null)
+ continue;
+
+ using var endpoint = parameters.CreateSubKey(@"EP\0", writable: true);
+ if (endpoint is null)
+ continue;
+
+ matched = true;
+ if (NeedsUpdate(endpoint.GetValue(EndpointAssociation), endpoint.GetValue(NeverSetAsDefaultEndpoint)))
+ {
+ endpoint.SetValue(EndpointAssociation, AnyKsNodeType, RegistryValueKind.String);
+ endpoint.SetValue(NeverSetAsDefaultEndpoint, AllRolesAndFlows, RegistryValueKind.DWord);
+ changed = true;
+ }
+ }
+ }
+
+ return matched;
+ }
+
+ internal static bool NeedsUpdate(object? association, object? policy)
+ {
+ return association is not string associationText ||
+ !associationText.Equals(AnyKsNodeType, StringComparison.OrdinalIgnoreCase) ||
+ policy is not int policyMask ||
+ (policyMask & AllRolesAndFlows) != AllRolesAndFlows;
+ }
+}
diff --git a/tools/sunshine-ds5-sidecar/ProtocolSelfTest.cs b/tools/sunshine-ds5-sidecar/ProtocolSelfTest.cs
index fda7d853..3e008f30 100644
--- a/tools/sunshine-ds5-sidecar/ProtocolSelfTest.cs
+++ b/tools/sunshine-ds5-sidecar/ProtocolSelfTest.cs
@@ -150,10 +150,13 @@ internal static void RunDeterministicChecks()
VerifyBundledCompositeProfile();
VerifyHapticsChannelIsolation();
VerifyDefaultAudioEndpointClassification();
+ VerifyDefaultAudioEndpointPolicy();
}
private static void VerifyDefaultAudioEndpointClassification()
{
+ Require(Enum.GetUnderlyingType(typeof(DefaultAudioEndpointGuard.AudioRole)) == typeof(int),
+ "default audio role COM width");
var virtualDualSense = new[]
{
new DefaultAudioEndpointGuard.DeviceNodeIdentity(
@@ -176,6 +179,18 @@ private static void VerifyDefaultAudioEndpointClassification()
}), "unrelated HIDMaestro endpoint exclusion");
}
+ private static void VerifyDefaultAudioEndpointPolicy()
+ {
+ Require(DefaultAudioEndpointPolicy.NeedsUpdate(null, null),
+ "missing default audio endpoint policy");
+ Require(DefaultAudioEndpointPolicy.NeedsUpdate(
+ "{00000000-0000-0000-0000-000000000000}", 0x00000101),
+ "partial default audio endpoint policy");
+ Require(!DefaultAudioEndpointPolicy.NeedsUpdate(
+ "{00000000-0000-0000-0000-000000000000}", 0x00000307),
+ "complete default audio endpoint policy");
+ }
+
private static void VerifyBundledCompositeProfile()
{
using var stream = typeof(ProtocolSelfTest).Assembly.GetManifestResourceStream(
diff --git a/tools/sunshine-ds5-sidecar/SidecarServer.cs b/tools/sunshine-ds5-sidecar/SidecarServer.cs
index de00eddd..c0fe1818 100644
--- a/tools/sunshine-ds5-sidecar/SidecarServer.cs
+++ b/tools/sunshine-ds5-sidecar/SidecarServer.cs
@@ -205,7 +205,26 @@ private void Attach(uint requestId, ReadOnlySpan payload)
?? throw new InvalidOperationException($"HIDMaestro profile '{profileId}' is missing");
if (!profile.RequiresUsbipBackend)
_context.InstallDriver();
+
+ if (profile.RequiresUsbipBackend)
+ {
+ try
+ {
+ // Seed a previously seen virtual interface before it becomes
+ // present. This avoids even a transient default-device switch
+ // on every attach after the first one.
+ DefaultAudioEndpointPolicy.EnsureNeverDefault(
+ TimeSpan.Zero, includePhantom: true, out _);
+ }
+ catch (Exception error)
+ {
+ Console.Error.WriteLine($"Unable to preseed the DualSense audio endpoint policy: {error.Message}");
+ }
+ }
+
var controller = _context.CreateController(profile);
+ if (profile.RequiresUsbipBackend)
+ controller = ApplyDefaultAudioEndpointPolicy(controller, profile);
ControllerSession session;
try
{
@@ -238,6 +257,46 @@ private void Attach(uint requestId, ReadOnlySpan payload)
session.StartDefaultAudioEndpointGuard();
}
+ private HMController ApplyDefaultAudioEndpointPolicy(HMController controller, HMProfile profile)
+ {
+ const int recreationLimit = 2;
+ for (var recreation = 0; recreation <= recreationLimit; ++recreation)
+ {
+ bool changed;
+ try
+ {
+ if (!DefaultAudioEndpointPolicy.EnsureNeverDefault(
+ TimeSpan.FromSeconds(3), includePhantom: false, out changed))
+ {
+ Console.Error.WriteLine(
+ "Unable to find the virtual DualSense audio interface; runtime default-device guard remains active");
+ return controller;
+ }
+ }
+ catch (Exception error)
+ {
+ Console.Error.WriteLine(
+ $"Unable to apply the DualSense audio endpoint policy: {error.Message}; " +
+ "runtime default-device guard remains active");
+ return controller;
+ }
+
+ if (!changed)
+ return controller;
+
+ // AudioEndpointBuilder consumes the EP properties when it creates
+ // the MMDevice endpoint. Once disposal begins, creation failures
+ // must propagate rather than returning a disposed controller.
+ controller.Dispose();
+ controller = _context.CreateController(profile);
+ }
+
+ Console.Error.WriteLine(
+ "DualSense audio endpoint identity did not stabilize after policy provisioning; " +
+ "runtime default-device guard remains active");
+ return controller;
+ }
+
private void Detach(uint requestId, ReadOnlySpan payload)
{
if (payload.Length != 1)