commit 3128ccd207ee72c2b7cc512efa29924e276fb350 Author: MangaValk Date: Sun Nov 1 23:10:45 2026 +0100 Mute HyperX QuadCast S at logon and sync Windows mute to it Sets the mic's own hardware mute (USB Audio SET_CUR mute on feature unit 6, via IDeviceTopology/IAudioMute) when it appears, and follows its 05 10 xx HID mute reports to keep the Windows endpoint mute in step. Includes build/install/uninstall scripts and a read-only HID listener. Co-Authored-By: Claude Opus 5.5 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..34a5637 --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +bin/ +*.pcap +*.pcapng diff --git a/README.md b/README.md new file mode 100644 index 0000000..b854f70 --- /dev/null +++ b/README.md @@ -0,0 +1,87 @@ +# quadcast-mute-sync + +Makes a **HyperX QuadCast S** start **muted on the mic itself** (light off) every time you log in to Windows, and keeps the **Windows microphone mute in step** with the mic's tap-to-mute button. + +HyperX NGENUITY has no "start muted" option, and the QuadCast S always powers up live. This small background app fixes both. + +## What it does + +| When | Mic (hardware) | Windows mic mute | +|---|---|---| +| You log in / the mic is plugged in | muted | on | +| You tap the mic to unmute | live | off | +| You tap the mic to mute | muted | on | + +The sync is one-way, from the mic to Windows. Changing mute in Windows doesn't change the mic. + +## Install + +Requirements: Windows 10/11, a QuadCast S. No admin rights, no extra runtimes (it builds with the C# compiler that ships with Windows' .NET Framework 4). + +```powershell +powershell -ExecutionPolicy Bypass -File .\install.ps1 +``` + +This builds `bin\QuadCastMuteSync.exe`, copies it to `%USERPROFILE%\QuadCastMuteSync`, registers a per-user logon task named **QuadCastMuteSync**, and starts it right away. Pass `-InstallDir ` to install somewhere else. + +Uninstall: + +```powershell +powershell -ExecutionPolicy Bypass -File .\uninstall.ps1 +``` + +## Command line + +``` +QuadCastMuteSync.exe run # background mode (what the logon task runs) +QuadCastMuteSync.exe mute # mute mic + Windows now +QuadCastMuteSync.exe unmute # unmute mic + Windows now +QuadCastMuteSync.exe status # show Windows mute and the KS mute nodes +``` + +`run` writes a short log to `log.txt` next to the exe. It waits for the mic if it isn't connected yet, and mutes it again whenever it is reconnected, since the mic powers up live. + +## How it works + +Found by capturing the NGENUITY software with USBPcap + Wireshark while toggling mute. + +The QuadCast S shows up as two USB devices: + +| Device | Role | +|---|---| +| `03F0:0294` | USB Audio device (the microphone) | +| `03F0:028C` | Controller: RGB, plus a vendor HID collection that reports mute | + +**Muting the mic.** NGENUITY sends a standard USB Audio Class request to the audio device: + +``` +bmRequestType 0x21 bRequest 0x01 (SET_CUR) +wValue 0x0100 (MUTE_CONTROL, channel 0) wIndex 0x0600 (feature unit 6, interface 0) +data 01 = mute, 00 = unmute +``` + +The mic's firmware treats this like a tap, so the light changes too. Windows' `usbaudio` driver exposes that feature unit as a KS *Mute* node (`.../00020006`), so the app sets it through Core Audio `IDeviceTopology` → `IAudioMute`. No driver, WinUSB or admin rights needed. The Windows endpoint mute (`IAudioEndpointVolume`) is a separate software mute, which is why Windows mute and the mic button normally don't follow each other. + +**Reading the mic's state.** Every mute change, from a tap or from software, makes the controller send an 8-byte input report on its vendor collection (usage page `0xFFFF`): + +``` +05 10 01 ... mic is live +05 10 00 ... mic is muted +``` + +The app blocks on reads from that collection and sets the Windows endpoint mute to match. + +## Limitations + +- Only tested on the QuadCast S (`03F0:0294` / `03F0:028C`). The QuadCast 2 uses a different protocol. +- The KS node id `/00020006` is what Windows assigned on the test machine. If `status` lists different ids for you, pass the right suffix: `QuadCastMuteSync.exe run /000200xx`. +- If the mic shows up under a name without "QuadCast", the audio endpoint won't be found. +- After sleep/resume the mic usually keeps its state. If it comes back live, it's because the device re-enumerated, and the app mutes it again. + +## Tools + +`tools/hidlisten.ps1` is a read-only listener that prints every input report from HyperX (`VID 03F0`) HID interfaces. It's useful for checking the mute reports on another unit or model. + +```powershell +powershell -ExecutionPolicy Bypass -File .\tools\hidlisten.ps1 -Seconds 30 +``` diff --git a/build.ps1 b/build.ps1 new file mode 100644 index 0000000..14d49bd --- /dev/null +++ b/build.ps1 @@ -0,0 +1,9 @@ +# Builds bin\QuadCastMuteSync.exe with the C# compiler that ships with Windows (.NET Framework 4). +$ErrorActionPreference = 'Stop' +$csc = Join-Path $env:WINDIR 'Microsoft.NET\Framework64\v4.0.30319\csc.exe' +if (-not (Test-Path $csc)) { throw "csc.exe not found at $csc" } +$bin = Join-Path $PSScriptRoot 'bin' +New-Item -ItemType Directory -Force $bin | Out-Null +& $csc /nologo /target:winexe /optimize+ /platform:anycpu "/out:$bin\QuadCastMuteSync.exe" "$PSScriptRoot\src\QuadCastMuteSync.cs" +if ($LASTEXITCODE -ne 0) { throw "build failed" } +Write-Host "Built $bin\QuadCastMuteSync.exe" diff --git a/install.ps1 b/install.ps1 new file mode 100644 index 0000000..d302eaa --- /dev/null +++ b/install.ps1 @@ -0,0 +1,26 @@ +# Builds QuadCastMuteSync, copies it to the install folder and runs it at every logon. +# No admin rights needed: the task runs as the current user. +# The default folder is outside AppData on purpose: when run from an MSIX-packaged app +# (such as some terminals), writes to AppData are redirected and Task Scheduler can't see them. +param([string]$InstallDir = (Join-Path $env:USERPROFILE 'QuadCastMuteSync')) +$ErrorActionPreference = 'Stop' +$TaskName = 'QuadCastMuteSync' +$dest = $InstallDir +$exe = Join-Path $dest 'QuadCastMuteSync.exe' + +& (Join-Path $PSScriptRoot 'build.ps1') + +Get-Process QuadCastMuteSync -ErrorAction SilentlyContinue | Stop-Process -Force +New-Item -ItemType Directory -Force $dest | Out-Null +Copy-Item (Join-Path $PSScriptRoot 'bin\QuadCastMuteSync.exe') $exe -Force + +$action = New-ScheduledTaskAction -Execute $exe -Argument 'run' +$trigger = New-ScheduledTaskTrigger -AtLogOn -User "$env:USERDOMAIN\$env:USERNAME" +$settings = New-ScheduledTaskSettingsSet -AllowStartIfOnBatteries -DontStopIfGoingOnBatteries ` + -ExecutionTimeLimit ([TimeSpan]::Zero) -MultipleInstances IgnoreNew ` + -RestartCount 3 -RestartInterval (New-TimeSpan -Minutes 1) +Register-ScheduledTask -TaskName $TaskName -Action $action -Trigger $trigger -Settings $settings ` + -Description 'Mutes the HyperX QuadCast S at logon and keeps Windows mic mute in sync with it.' -Force | Out-Null + +Start-ScheduledTask -TaskName $TaskName +Write-Host "Installed to $dest and registered logon task '$TaskName' (started now)." diff --git a/src/QuadCastMuteSync.cs b/src/QuadCastMuteSync.cs new file mode 100644 index 0000000..888a7e7 --- /dev/null +++ b/src/QuadCastMuteSync.cs @@ -0,0 +1,387 @@ +// QuadCastMuteSync - mutes the HyperX QuadCast S itself at logon and keeps the +// Windows microphone mute in step with the mic's own tap-to-mute state. +// +// How it works (found by sniffing the HyperX software with USBPcap): +// * Hardware mute is the standard USB Audio Class request SET_CUR / MUTE_CONTROL +// on feature unit 6 of the audio device (03F0:0294). Windows exposes it as a +// KS "Mute" node, reachable through IDeviceTopology -> IAudioMute. +// * Every mute change (tap or software) is reported by the mic's controller +// (03F0:028C, vendor HID collection, page 0xFFFF) as input report 05 10 xx, +// where xx = 01 live, 00 muted. +// +// Built for .NET Framework 4 (csc.exe that ships with Windows), so C# 5 only. + +using System; +using System.Collections.Generic; +using System.IO; +using System.Runtime.InteropServices; +using System.Text; +using System.Threading; +using Microsoft.Win32.SafeHandles; + +namespace QuadCastMuteSync +{ + static class Program + { + // KS node id suffix of the feature-unit mute the HyperX software uses. + const string DefaultNode = "/00020006"; + const string EndpointMatch = "QuadCast"; + + static string logPath; + + [DllImport("kernel32.dll")] static extern bool AttachConsole(int pid); + + [MTAThread] + static int Main(string[] args) + { + string cmd = args.Length > 0 ? args[0].ToLowerInvariant() : "run"; + string node = args.Length > 1 ? args[1] : DefaultNode; + logPath = Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "log.txt"); + + if (cmd != "run") AttachConsole(-1); // winexe: print to the calling console + switch (cmd) + { + case "run": return Run(node); + case "status": Console.WriteLine(); Console.WriteLine(Audio.Status()); return 0; + case "mute": + case "unmute": + bool m = cmd == "mute"; + bool ok = Audio.SetHardwareMute(m, node); + if (ok) Audio.SetWindowsMute(m); + Console.WriteLine(); Console.WriteLine(ok ? cmd + "d (mic and Windows)" : "QuadCast mute control not found"); + return ok ? 0 : 1; + default: + Console.WriteLine(); Console.WriteLine("usage: QuadCastMuteSync.exe [run|status|mute|unmute] [nodeSuffix]"); + return 2; + } + } + + static void Log(string s) + { + try { File.AppendAllText(logPath, DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss ") + s + Environment.NewLine); } catch { } + } + + static int Run(string node) + { + bool created; + var single = new Mutex(true, @"Local\QuadCastMuteSync", out created); + if (!created) return 0; + try { File.WriteAllText(logPath, ""); } catch { } + Log("started"); + + bool waitingLogged = false; + while (true) + { + Hid.Device hid = Hid.FindStateCollection(); + if (hid == null || !Audio.EndpointPresent(EndpointMatch)) + { + if (!waitingLogged) { Log("waiting for QuadCast S"); waitingLogged = true; } + Thread.Sleep(2000); + continue; + } + waitingLogged = false; + Thread.Sleep(1500); // let the audio stack finish setting up the endpoint + + // The mic always powers up live, so mute it whenever it (re)appears. + bool ok = false; + for (int i = 0; i < 5 && !ok; i++) { ok = Audio.SetHardwareMute(true, node); if (!ok) Thread.Sleep(1000); } + Audio.SetWindowsMute(true); + Log("QuadCast S found: hardware mute " + (ok ? "set" : "FAILED") + ", Windows mute on"); + + try + { + using (var stream = hid.OpenRead()) + { + var buf = new byte[hid.InputLength]; + while (true) + { + int n = stream.Read(buf, 0, buf.Length); + if (n == 0) throw new IOException("read returned 0"); + if (n >= 3 && buf[0] == 0x05 && buf[1] == 0x10) + { + bool muted = buf[2] == 0x00; + Audio.SetWindowsMute(muted); + Log("mic " + (muted ? "muted" : "live") + " -> Windows mute " + (muted ? "on" : "off")); + } + } + } + } + catch (Exception e) { Log("lost QuadCast S (" + e.Message + ")"); } + Thread.Sleep(2000); + } + } + } + + // ---------------------------------------------------------------- HID + + static class Hid + { + public class Device + { + public string Path; + public int InputLength; + public FileStream OpenRead() + { + var h = CreateFile(Path, GENERIC_READ, 3, IntPtr.Zero, 3, 0, IntPtr.Zero); + if (h.IsInvalid) throw new IOException("open failed, error " + Marshal.GetLastWin32Error()); + return new FileStream(h, FileAccess.Read, InputLength, false); + } + } + + const uint GENERIC_READ = 0x80000000; + + [StructLayout(LayoutKind.Sequential)] struct SP_DID { public int cbSize; public Guid g; public int flags; public IntPtr r; } + [StructLayout(LayoutKind.Sequential)] + struct HIDP_CAPS + { + public ushort Usage, UsagePage, InputLen, OutputLen, FeatureLen; + [MarshalAs(UnmanagedType.ByValArray, SizeConst = 27)] public ushort[] Rest; + } + + [DllImport("hid.dll")] static extern void HidD_GetHidGuid(out Guid g); + [DllImport("hid.dll")] static extern bool HidD_GetPreparsedData(SafeFileHandle h, out IntPtr p); + [DllImport("hid.dll")] static extern bool HidD_FreePreparsedData(IntPtr p); + [DllImport("hid.dll")] static extern int HidP_GetCaps(IntPtr p, out HIDP_CAPS c); + [DllImport("setupapi.dll", CharSet = CharSet.Unicode)] static extern IntPtr SetupDiGetClassDevs(ref Guid g, string e, IntPtr w, int f); + [DllImport("setupapi.dll")] static extern bool SetupDiDestroyDeviceInfoList(IntPtr s); + [DllImport("setupapi.dll", CharSet = CharSet.Unicode)] static extern bool SetupDiEnumDeviceInterfaces(IntPtr s, IntPtr d, ref Guid g, int i, ref SP_DID data); + [DllImport("setupapi.dll", CharSet = CharSet.Unicode)] static extern bool SetupDiGetDeviceInterfaceDetail(IntPtr s, ref SP_DID data, IntPtr detail, int size, out int req, IntPtr di); + [DllImport("kernel32.dll", CharSet = CharSet.Unicode, SetLastError = true)] static extern SafeFileHandle CreateFile(string n, uint a, uint sh, IntPtr sec, uint disp, uint flags, IntPtr t); + + // The QuadCast S controller (03F0:028C) collection that sends the 05 10 xx mute reports. + public static Device FindStateCollection() + { + Guid g; HidD_GetHidGuid(out g); + IntPtr set = SetupDiGetClassDevs(ref g, null, IntPtr.Zero, 0x12); // PRESENT | DEVICEINTERFACE + try + { + for (int i = 0; ; i++) + { + var d = new SP_DID(); d.cbSize = Marshal.SizeOf(d); + if (!SetupDiEnumDeviceInterfaces(set, IntPtr.Zero, ref g, i, ref d)) return null; + int req; SetupDiGetDeviceInterfaceDetail(set, ref d, IntPtr.Zero, 0, out req, IntPtr.Zero); + IntPtr buf = Marshal.AllocHGlobal(req); + string path = null; + try + { + Marshal.WriteInt32(buf, IntPtr.Size == 8 ? 8 : 6); + if (SetupDiGetDeviceInterfaceDetail(set, ref d, buf, req, out req, IntPtr.Zero)) + path = Marshal.PtrToStringUni(new IntPtr(buf.ToInt64() + 4)); + } + finally { Marshal.FreeHGlobal(buf); } + if (path == null || path.ToLowerInvariant().IndexOf("vid_03f0&pid_028c") < 0) continue; + + using (var h = CreateFile(path, 0, 3, IntPtr.Zero, 3, 0, IntPtr.Zero)) + { + if (h.IsInvalid) continue; + IntPtr pp; HIDP_CAPS c; + if (!HidD_GetPreparsedData(h, out pp)) continue; + HidP_GetCaps(pp, out c); HidD_FreePreparsedData(pp); + if (c.UsagePage == 0xFFFF && c.InputLen == 8) + return new Device { Path = path, InputLength = c.InputLen }; + } + } + } + finally { SetupDiDestroyDeviceInfoList(set); } + } + } + + // ---------------------------------------------------------------- Core Audio + + static class Audio + { + public static bool EndpointPresent(string match) { return FindEndpoint(match) != null; } + + static IMMDevice FindEndpoint(string match) + { + var en = (IMMDeviceEnumerator)new MMDeviceEnumeratorCo(); + IMMDeviceCollection col; + if (en.EnumAudioEndpoints(1, 1, out col) != 0) return null; // eCapture, DEVICE_STATE_ACTIVE + int n; col.GetCount(out n); + for (int i = 0; i < n; i++) + { + IMMDevice d; col.Item(i, out d); + if (Name(d).IndexOf(match, StringComparison.OrdinalIgnoreCase) >= 0) return d; + } + return null; + } + + static string Name(IMMDevice d) + { + IPropertyStore s; + if (d.OpenPropertyStore(0, out s) != 0) return ""; + var k = new PROPERTYKEY { fmtid = new Guid("A45C254E-DF1C-4EFD-8020-67D146A850E0"), pid = 14 }; // PKEY_Device_FriendlyName + PROPVARIANT v; s.GetValue(ref k, out v); + return v.vt == 31 ? Marshal.PtrToStringUni(v.p) : ""; + } + + static IAudioEndpointVolume EndpointVolume() + { + var d = FindEndpoint("QuadCast"); + if (d == null) return null; + Guid iid = typeof(IAudioEndpointVolume).GUID; object o; + return d.Activate(ref iid, 23, IntPtr.Zero, out o) == 0 ? (IAudioEndpointVolume)o : null; + } + + public static void SetWindowsMute(bool mute) + { + var v = EndpointVolume(); + if (v == null) return; + int cur; v.GetMute(out cur); + if ((cur != 0) != mute) v.SetMute(mute ? 1 : 0, IntPtr.Zero); // only on change, avoids feedback loops + } + + // Every KS mute node on the QuadCast capture path, keyed by global id. + static List> MuteNodes() + { + var result = new List>(); + var d = FindEndpoint("QuadCast"); + if (d == null) return result; + Guid iidTopo = typeof(IDeviceTopology).GUID; object o; + if (d.Activate(ref iidTopo, 23, IntPtr.Zero, out o) != 0) return result; + IConnector c, other; + if (((IDeviceTopology)o).GetConnector(0, out c) != 0 || c.GetConnectedTo(out other) != 0) return result; + + var queue = new Queue(); var seen = new HashSet(); + queue.Enqueue((IPart)other); + while (queue.Count > 0) + { + IPart p = queue.Dequeue(); string gid; p.GetGlobalId(out gid); + if (!seen.Add(gid)) continue; + Guid iidMute = typeof(IAudioMute).GUID; object m; + if (p.Activate(23, ref iidMute, out m) == 0 && m != null) + result.Add(new KeyValuePair(gid, (IAudioMute)m)); + IPartsList l; int k; + if (p.EnumPartsIncoming(out l) == 0 && l != null) { l.GetCount(out k); for (int j = 0; j < k; j++) { IPart q; l.GetPart(j, out q); queue.Enqueue(q); } } + if (p.EnumPartsOutgoing(out l) == 0 && l != null) { l.GetCount(out k); for (int j = 0; j < k; j++) { IPart q; l.GetPart(j, out q); queue.Enqueue(q); } } + } + return result; + } + + public static bool SetHardwareMute(bool mute, string nodeSuffix) + { + foreach (var n in MuteNodes()) + if (n.Key.EndsWith(nodeSuffix, StringComparison.OrdinalIgnoreCase)) + return n.Value.SetMute(mute ? 1 : 0, IntPtr.Zero) == 0; + return false; + } + + public static string Status() + { + var sb = new StringBuilder(); + var v = EndpointVolume(); + if (v == null) return "QuadCast capture endpoint not found"; + int wm; v.GetMute(out wm); + sb.AppendLine("Windows mute: " + (wm != 0 ? "on" : "off")); + foreach (var n in MuteNodes()) + { + int m; n.Value.GetMute(out m); + sb.AppendLine("KS mute node " + n.Key.Substring(n.Key.LastIndexOf('/')) + ": " + (m != 0 ? "muted" : "live")); + } + sb.Append("(KS nodes show the last mute set by software; taps on the mic are not reflected there)"); + return sb.ToString(); + } + } + + // ---------------------------------------------------------------- COM interop + + [ComImport, Guid("BCDE0395-E52F-467C-8E3D-C4579291692E")] class MMDeviceEnumeratorCo { } + + [ComImport, Guid("A95664D2-9614-4F35-A746-DE8DB63617E6"), InterfaceType(ComInterfaceType.InterfaceIsIUnknown)] + interface IMMDeviceEnumerator + { + [PreserveSig] int EnumAudioEndpoints(int dataFlow, int stateMask, out IMMDeviceCollection devices); + } + + [ComImport, Guid("0BD7A1BE-7A1A-44DB-8397-CC5392387B5E"), InterfaceType(ComInterfaceType.InterfaceIsIUnknown)] + interface IMMDeviceCollection + { + [PreserveSig] int GetCount(out int count); + [PreserveSig] int Item(int i, out IMMDevice device); + } + + [ComImport, Guid("D666063F-1587-4E43-81F1-B948E807363F"), InterfaceType(ComInterfaceType.InterfaceIsIUnknown)] + interface IMMDevice + { + [PreserveSig] int Activate(ref Guid iid, int ctx, IntPtr p, [MarshalAs(UnmanagedType.IUnknown)] out object o); + [PreserveSig] int OpenPropertyStore(int access, out IPropertyStore store); + } + + [ComImport, Guid("886D8EEB-8CF2-4446-8D02-CDBA1DBDCF99"), InterfaceType(ComInterfaceType.InterfaceIsIUnknown)] + interface IPropertyStore + { + [PreserveSig] int GetCount(out int c); + [PreserveSig] int GetAt(int i, out PROPERTYKEY k); + [PreserveSig] int GetValue(ref PROPERTYKEY k, out PROPVARIANT v); + } + + [StructLayout(LayoutKind.Sequential)] struct PROPERTYKEY { public Guid fmtid; public int pid; } + [StructLayout(LayoutKind.Sequential)] struct PROPVARIANT { public short vt; short r1, r2, r3; public IntPtr p; IntPtr p2; } + + [ComImport, Guid("5CDF2C82-841E-4546-9722-0CF74078229A"), InterfaceType(ComInterfaceType.InterfaceIsIUnknown)] + interface IAudioEndpointVolume + { + // Vtable order matters; only SetMute/GetMute are called. + [PreserveSig] int RegisterControlChangeNotify(IntPtr n); + [PreserveSig] int UnregisterControlChangeNotify(IntPtr n); + [PreserveSig] int GetChannelCount(out int c); + [PreserveSig] int SetMasterVolumeLevel(float db, IntPtr ctx); + [PreserveSig] int SetMasterVolumeLevelScalar(float level, IntPtr ctx); + [PreserveSig] int GetMasterVolumeLevel(out float db); + [PreserveSig] int GetMasterVolumeLevelScalar(out float level); + [PreserveSig] int SetChannelVolumeLevel(int ch, float db, IntPtr ctx); + [PreserveSig] int SetChannelVolumeLevelScalar(int ch, float level, IntPtr ctx); + [PreserveSig] int GetChannelVolumeLevel(int ch, out float db); + [PreserveSig] int GetChannelVolumeLevelScalar(int ch, out float level); + [PreserveSig] int SetMute(int mute, IntPtr ctx); + [PreserveSig] int GetMute(out int mute); + } + + [ComImport, Guid("2A07407E-6497-4A18-9787-32F79BD0D98F"), InterfaceType(ComInterfaceType.InterfaceIsIUnknown)] + interface IDeviceTopology + { + [PreserveSig] int GetConnectorCount(out int c); + [PreserveSig] int GetConnector(int i, out IConnector c); + } + + [ComImport, Guid("9C2C4058-23F5-41DE-877A-DF3AF236A09E"), InterfaceType(ComInterfaceType.InterfaceIsIUnknown)] + interface IConnector + { + [PreserveSig] int GetType(out int t); + [PreserveSig] int GetDataFlow(out int f); + [PreserveSig] int ConnectTo(IConnector c); + [PreserveSig] int Disconnect(); + [PreserveSig] int IsConnected(out int b); + [PreserveSig] int GetConnectedTo(out IConnector c); + } + + [ComImport, Guid("AE2DE0E4-5BCA-4F2D-AA46-5D13F8FDB3A9"), InterfaceType(ComInterfaceType.InterfaceIsIUnknown)] + interface IPart + { + [PreserveSig] int GetName([MarshalAs(UnmanagedType.LPWStr)] out string n); + [PreserveSig] int GetLocalId(out int id); + [PreserveSig] int GetGlobalId([MarshalAs(UnmanagedType.LPWStr)] out string id); + [PreserveSig] int GetPartType(out int t); + [PreserveSig] int GetSubType(out Guid g); + [PreserveSig] int GetControlInterfaceCount(out int c); + [PreserveSig] int GetControlInterface(int i, out IntPtr c); + [PreserveSig] int EnumPartsIncoming(out IPartsList l); + [PreserveSig] int EnumPartsOutgoing(out IPartsList l); + [PreserveSig] int GetTopologyObject(out IntPtr t); + [PreserveSig] int Activate(int ctx, ref Guid iid, [MarshalAs(UnmanagedType.IUnknown)] out object o); + } + + [ComImport, Guid("6DAA848C-5EB0-45CC-AEA5-998A2CDA1FFB"), InterfaceType(ComInterfaceType.InterfaceIsIUnknown)] + interface IPartsList + { + [PreserveSig] int GetCount(out int c); + [PreserveSig] int GetPart(int i, out IPart p); + } + + [ComImport, Guid("DF45AEEA-B74A-4B6B-AFAD-2366B6AA012E"), InterfaceType(ComInterfaceType.InterfaceIsIUnknown)] + interface IAudioMute + { + [PreserveSig] int SetMute(int mute, IntPtr ctx); + [PreserveSig] int GetMute(out int mute); + } +} diff --git a/tools/hidlisten.ps1 b/tools/hidlisten.ps1 new file mode 100644 index 0000000..da593e7 --- /dev/null +++ b/tools/hidlisten.ps1 @@ -0,0 +1,94 @@ +param([int]$Seconds = 10) +# Read-only listener for HyperX HID interfaces: never writes output or feature reports. +Add-Type -TypeDefinition @' +using System; +using System.Collections.Generic; +using System.IO; +using System.Runtime.InteropServices; +using System.Text; +using System.Threading; +using Microsoft.Win32.SafeHandles; + +public static class HidListen { + [DllImport("hid.dll")] static extern void HidD_GetHidGuid(out Guid g); + [DllImport("hid.dll")] static extern bool HidD_GetPreparsedData(SafeFileHandle h, out IntPtr p); + [DllImport("hid.dll")] static extern bool HidD_FreePreparsedData(IntPtr p); + [DllImport("hid.dll")] static extern int HidP_GetCaps(IntPtr p, out HIDP_CAPS c); + [DllImport("hid.dll")] static extern bool HidD_GetInputReport(SafeFileHandle h, byte[] b, int len); + [DllImport("setupapi.dll", CharSet = CharSet.Unicode)] static extern IntPtr SetupDiGetClassDevs(ref Guid g, string e, IntPtr w, int f); + [DllImport("setupapi.dll", CharSet = CharSet.Unicode)] static extern bool SetupDiEnumDeviceInterfaces(IntPtr s, IntPtr d, ref Guid g, int i, ref SP_DID data); + [DllImport("setupapi.dll", CharSet = CharSet.Unicode)] static extern bool SetupDiGetDeviceInterfaceDetail(IntPtr s, ref SP_DID data, IntPtr detail, int size, out int req, IntPtr di); + [DllImport("kernel32.dll", CharSet = CharSet.Unicode, SetLastError = true)] static extern SafeFileHandle CreateFile(string n, uint a, uint sh, IntPtr sec, uint disp, uint flags, IntPtr t); + + [StructLayout(LayoutKind.Sequential)] struct SP_DID { public int cbSize; public Guid g; public int flags; public IntPtr r; } + [StructLayout(LayoutKind.Sequential)] public struct HIDP_CAPS { + public ushort Usage, UsagePage, InputLen, OutputLen, FeatureLen; + [MarshalAs(UnmanagedType.ByValArray, SizeConst = 17)] public ushort[] Reserved; + public ushort a, b, c, d, e, f, g, h, i, j; + } + + static object lk = new object(); + static void Log(string s) { lock (lk) { Console.WriteLine(DateTime.Now.ToString("HH:mm:ss.fff") + " " + s); } } + static string Hex(byte[] b, int n) { var sb = new StringBuilder(); for (int i = 0; i < n; i++) sb.Append(b[i].ToString("X2")).Append(' '); return sb.ToString().TrimEnd(); } + + static List Paths(string filter) { + var res = new List(); Guid g; HidD_GetHidGuid(out g); + IntPtr set = SetupDiGetClassDevs(ref g, null, IntPtr.Zero, 0x12); + for (int i = 0; ; i++) { + var d = new SP_DID(); d.cbSize = Marshal.SizeOf(d); + if (!SetupDiEnumDeviceInterfaces(set, IntPtr.Zero, ref g, i, ref d)) break; + int req; SetupDiGetDeviceInterfaceDetail(set, ref d, IntPtr.Zero, 0, out req, IntPtr.Zero); + IntPtr buf = Marshal.AllocHGlobal(req); + Marshal.WriteInt32(buf, IntPtr.Size == 8 ? 8 : 6); + if (SetupDiGetDeviceInterfaceDetail(set, ref d, buf, req, out req, IntPtr.Zero)) { + string p = Marshal.PtrToStringUni(new IntPtr(buf.ToInt64() + 4)); + if (p.ToLower().Contains(filter)) res.Add(p); + } + Marshal.FreeHGlobal(buf); + } + return res; + } + + public static void Run(int seconds) { + DateTime end = DateTime.Now.AddSeconds(seconds); + var threads = new List(); + foreach (var p in Paths("vid_03f0")) { + // Access 0 (query only) first to read caps, then try read access. + var h = CreateFile(p, 0x80000000, 3, IntPtr.Zero, 3, 0, IntPtr.Zero); + bool canRead = !h.IsInvalid; + if (!canRead) h = CreateFile(p, 0, 3, IntPtr.Zero, 3, 0, IntPtr.Zero); + if (h.IsInvalid) { Log("OPEN FAIL " + p + " err=" + Marshal.GetLastWin32Error()); continue; } + IntPtr pp; HIDP_CAPS c = new HIDP_CAPS(); + if (HidD_GetPreparsedData(h, out pp)) { HidP_GetCaps(pp, out c); HidD_FreePreparsedData(pp); } + string name = p.Substring(p.IndexOf("vid_"), 40); + Log(string.Format("DEV {0} page=0x{1:X4} usage=0x{2:X4} in={3} out={4} feat={5} read={6}", name, c.UsagePage, c.Usage, c.InputLen, c.OutputLen, c.FeatureLen, canRead)); + if (!canRead || c.InputLen == 0) continue; + var hh = h; var cc = c; var nm = name; + // Thread 1: interrupt input reports. + var t = new Thread(() => { + var fs = new FileStream(hh, FileAccess.Read, cc.InputLen, false); + var b = new byte[cc.InputLen]; + while (DateTime.Now < end) { + try { int n = fs.Read(b, 0, b.Length); if (n > 0) Log("IN " + nm + " : " + Hex(b, n)); } + catch (Exception ex) { Log("READ ERR " + nm + " " + ex.Message); break; } + } + }); + t.IsBackground = true; t.Start(); threads.Add(t); + // Thread 2: poll GET_REPORT(Input) on the control pipe, log only changes. + var h2 = CreateFile(p, 0x80000000, 3, IntPtr.Zero, 3, 0, IntPtr.Zero); + var t2 = new Thread(() => { + string last = null; + while (DateTime.Now < end) { + var b = new byte[cc.InputLen]; + if (HidD_GetInputReport(h2, b, b.Length)) { string s = Hex(b, b.Length); if (s != last) { Log("GET " + nm + " : " + s); last = s; } } + Thread.Sleep(100); + } + }); + t2.IsBackground = true; t2.Start(); threads.Add(t2); + } + while (DateTime.Now < end) Thread.Sleep(200); + Log("DONE"); + } +} +'@ +[HidListen]::Run($Seconds) diff --git a/uninstall.ps1 b/uninstall.ps1 new file mode 100644 index 0000000..8f39abf --- /dev/null +++ b/uninstall.ps1 @@ -0,0 +1,8 @@ +# Stops QuadCastMuteSync, removes its logon task and its install folder. +param([string]$InstallDir = (Join-Path $env:USERPROFILE 'QuadCastMuteSync')) +$TaskName = 'QuadCastMuteSync' +Unregister-ScheduledTask -TaskName $TaskName -Confirm:$false -ErrorAction SilentlyContinue +Get-Process QuadCastMuteSync -ErrorAction SilentlyContinue | Stop-Process -Force +Start-Sleep -Milliseconds 500 +Remove-Item $InstallDir -Recurse -Force -ErrorAction SilentlyContinue +Write-Host "Removed task '$TaskName' and $InstallDir."