From 4ccb6a1a0465422d2d7187112fb9f9e44693f26c Mon Sep 17 00:00:00 2001 From: Cyd Date: Sat, 1 Aug 2026 19:56:55 -0500 Subject: [PATCH] Add exclusive-gamepad mode via the HidHide filter driver XInput has no exclusive acquisition, so a second app (DOSBox-X, Steam, the sim) can always read the cockpit pad too. The new "Exclusive gamepad (needs HidHide)" toggle cloaks the pad one level down instead: vRIO allowlists itself with the HidHide driver, blocks the pad's device instances (both the HID face and, for IG_/XInput pads, the parent XUSB devnode XInput actually reads), and reverts on toggle-off or exit. - HidHideControl: raw IOCTL client for \.\HidHide (net48 can't take the Nefarius NuGet); read-modify-write so other tools' entries survive - GamepadDeviceLocator: Raw Input census of joystick/gamepad HIDs plus IG_ parent devnodes via cfgmgr32 - MainForm: toggle + status suffix, 3 s visibility watchdog that auto-reverts if the allowlist didn't take, hot-plugged pads get cloaked too, cloak dropped in FormClosed; not persisted across runs so a crash can't strand an invisible controller silently Co-Authored-By: Claude Fable 5 --- src/VRio.App/GamepadDeviceLocator.cs | 173 ++++++++++++++++++++++++ src/VRio.App/HidHideControl.cs | 186 +++++++++++++++++++++++++ src/VRio.App/MainForm.cs | 194 +++++++++++++++++++++++++-- 3 files changed, 544 insertions(+), 9 deletions(-) create mode 100644 src/VRio.App/GamepadDeviceLocator.cs create mode 100644 src/VRio.App/HidHideControl.cs diff --git a/src/VRio.App/GamepadDeviceLocator.cs b/src/VRio.App/GamepadDeviceLocator.cs new file mode 100644 index 0000000..38b0570 --- /dev/null +++ b/src/VRio.App/GamepadDeviceLocator.cs @@ -0,0 +1,173 @@ +using System.Runtime.InteropServices; +using System.Text; + +namespace VRio.App; + +/// +/// Finds the PnP device instance IDs behind every attached gamepad/joystick, +/// for handing to . Enumeration rides on Raw +/// Input's HID list (usage page 1, usages 4/5/8 — joystick, gamepad, +/// multi-axis), the same census games take. +/// +/// For XInput-class pads the HID collection is only the DirectInput +/// face; XInput itself reads the parent XUSB/GIP devnode. Those pads +/// are recognizable by the IG_ marker Microsoft puts in their HID +/// hardware IDs, and both the HID instance and its parent are returned — +/// cloaking just one of them leaves the other visible to half the world. +/// Plain HID pads (DualShock and friends) contribute only their HID instance; +/// their parent is often a composite device with audio and such that must not +/// be hidden. +/// +internal static class GamepadDeviceLocator +{ + // --- Win32 interop ------------------------------------------------------- + + [StructLayout(LayoutKind.Sequential)] + private struct RAWINPUTDEVICELIST + { + public IntPtr hDevice; + public uint dwType; + } + + // The RID_DEVICE_INFO union flattened to its HID arm; the keyboard arm is + // the largest (24 bytes), so the tail pad keeps cbSize honest at 32. + [StructLayout(LayoutKind.Explicit)] + private struct RID_DEVICE_INFO + { + [FieldOffset(0)] public uint cbSize; + [FieldOffset(4)] public uint dwType; + [FieldOffset(8)] public uint hidVendorId; + [FieldOffset(12)] public uint hidProductId; + [FieldOffset(16)] public uint hidVersionNumber; + [FieldOffset(20)] public ushort hidUsagePage; + [FieldOffset(22)] public ushort hidUsage; + [FieldOffset(28)] public uint KeyboardArmTail; // pads the union to its true 24-byte size + } + + [DllImport("user32.dll", SetLastError = true)] + private static extern uint GetRawInputDeviceList( + [In, Out] RAWINPUTDEVICELIST[]? pRawInputDeviceList, ref uint puiNumDevices, uint cbSize); + + [DllImport("user32.dll", SetLastError = true, CharSet = CharSet.Unicode)] + private static extern uint GetRawInputDeviceInfoW( + IntPtr hDevice, uint uiCommand, IntPtr pData, ref uint pcbSize); + + [DllImport("user32.dll", SetLastError = true, CharSet = CharSet.Unicode)] + private static extern uint GetRawInputDeviceInfoW( + IntPtr hDevice, uint uiCommand, ref RID_DEVICE_INFO pData, ref uint pcbSize); + + [DllImport("cfgmgr32.dll", CharSet = CharSet.Unicode)] + private static extern int CM_Locate_DevNodeW(out uint pdnDevInst, string pDeviceID, uint ulFlags); + + [DllImport("cfgmgr32.dll")] + private static extern int CM_Get_Parent(out uint pdnDevInst, uint dnDevInst, uint ulFlags); + + [DllImport("cfgmgr32.dll", CharSet = CharSet.Unicode)] + private static extern int CM_Get_Device_IDW(uint dnDevInst, StringBuilder buffer, int bufferLen, uint ulFlags); + + private const uint Err = 0xFFFFFFFF; + private const uint RIM_TYPEHID = 2; + private const uint RIDI_DEVICENAME = 0x20000007; + private const uint RIDI_DEVICEINFO = 0x2000000B; + private const ushort UsagePageGeneric = 0x01; + private const ushort UsageJoystick = 0x04; + private const ushort UsageGamepad = 0x05; + private const ushort UsageMultiAxis = 0x08; + private const int CrSuccess = 0; + private const int MaxDeviceIdLen = 200; + + // --- Enumeration --------------------------------------------------------- + + /// + /// Device instance IDs of every attached gamepad/joystick: the HID + /// instances, plus the parent devnode of each IG_ (XInput) pad. + /// Empty when no pad is attached. + /// + public static List FindGamepadInstances() + { + var result = new List(); + uint listSize = (uint)Marshal.SizeOf(); + uint count = 0; + if (GetRawInputDeviceList(null, ref count, listSize) == Err || count == 0) + return result; + + var devices = new RAWINPUTDEVICELIST[count]; + uint got = GetRawInputDeviceList(devices, ref count, listSize); + if (got == Err) + return result; + + for (int i = 0; i < got; i++) + { + if (devices[i].dwType != RIM_TYPEHID || !IsGamepad(devices[i].hDevice)) + continue; + + string? path = InterfacePath(devices[i].hDevice); + string? instance = path is null ? null : InstanceIdFromInterfacePath(path); + if (instance is null || result.Contains(instance, StringComparer.OrdinalIgnoreCase)) + continue; + + result.Add(instance); + if (instance.IndexOf("IG_", StringComparison.OrdinalIgnoreCase) >= 0 + && ParentInstanceId(instance) is { } parent + && !result.Contains(parent, StringComparer.OrdinalIgnoreCase)) + { + result.Add(parent); + } + } + return result; + } + + private static bool IsGamepad(IntPtr hDevice) + { + var info = new RID_DEVICE_INFO { cbSize = (uint)Marshal.SizeOf() }; + uint size = info.cbSize; + if (GetRawInputDeviceInfoW(hDevice, RIDI_DEVICEINFO, ref info, ref size) == Err) + return false; + return info.dwType == RIM_TYPEHID + && info.hidUsagePage == UsagePageGeneric + && info.hidUsage is UsageJoystick or UsageGamepad or UsageMultiAxis; + } + + private static string? InterfacePath(IntPtr hDevice) + { + uint chars = 0; + if (GetRawInputDeviceInfoW(hDevice, RIDI_DEVICENAME, IntPtr.Zero, ref chars) == Err || chars == 0) + return null; + + IntPtr buffer = Marshal.AllocHGlobal((int)chars * sizeof(char)); + try + { + if (GetRawInputDeviceInfoW(hDevice, RIDI_DEVICENAME, buffer, ref chars) == Err) + return null; + return Marshal.PtrToStringUni(buffer); + } + finally + { + Marshal.FreeHGlobal(buffer); + } + } + + /// + /// \\?\HID#VID_x&PID_y#instance#{guid}HID\VID_x&PID_y\instance. + /// + private static string InstanceIdFromInterfacePath(string path) + { + string s = path; + if (s.StartsWith(@"\\?\", StringComparison.Ordinal) || s.StartsWith(@"\\.\", StringComparison.Ordinal)) + s = s.Substring(4); + int guid = s.LastIndexOf("#{", StringComparison.Ordinal); + if (guid >= 0) + s = s.Substring(0, guid); + return s.Replace('#', '\\'); + } + + private static string? ParentInstanceId(string instanceId) + { + if (CM_Locate_DevNodeW(out uint node, instanceId, 0) != CrSuccess) + return null; + if (CM_Get_Parent(out uint parent, node, 0) != CrSuccess) + return null; + var buffer = new StringBuilder(MaxDeviceIdLen); + return CM_Get_Device_IDW(parent, buffer, buffer.Capacity, 0) == CrSuccess ? buffer.ToString() : null; + } +} diff --git a/src/VRio.App/HidHideControl.cs b/src/VRio.App/HidHideControl.cs new file mode 100644 index 0000000..676edc1 --- /dev/null +++ b/src/VRio.App/HidHideControl.cs @@ -0,0 +1,186 @@ +using System.ComponentModel; +using System.Runtime.InteropServices; +using System.Text; +using Microsoft.Win32.SafeHandles; + +namespace VRio.App; + +/// +/// Minimal client for the HidHide filter driver's control device +/// (\\.\HidHide) — the same wire protocol Nefarius's +/// HidHideControlService speaks, reduced to what the exclusive-gamepad +/// toggle needs. HidHide cloaks blocked device instances from every process +/// except the allowlisted applications, which is the only robust way to keep +/// a second app (DOSBox-X, Steam, the sim itself) from also reacting to the +/// cockpit gamepad: XInput has no exclusive mode of its own. +/// +/// Every operation opens the control device fresh, mirroring the +/// reference implementation; the driver treats each set as a full list +/// replace, so add/remove is read-modify-write here. All methods throw +/// (driver refused / not reachable) or +/// (unconvertible path) — callers +/// surface the message and carry on. +/// +internal static class HidHideControl +{ + private const string ControlDevice = @"\\.\HidHide"; + + // CTL_CODE(0x8001, 2048+n, METHOD_BUFFERED, FILE_READ_DATA) — the + // device type / function numbers HidHide has used since v1.0. + private const uint IoctlGetWhitelist = 0x80016000; + private const uint IoctlSetWhitelist = 0x80016004; + private const uint IoctlGetBlacklist = 0x80016008; + private const uint IoctlSetBlacklist = 0x8001600C; + private const uint IoctlGetActive = 0x80016010; + private const uint IoctlSetActive = 0x80016014; + + private const uint GenericRead = 0x80000000; + private const uint GenericWrite = 0x40000000; + private const uint ShareReadWrite = 0x00000003; + private const uint OpenExisting = 3; + private const int ErrorFileNotFound = 2; + private const int ErrorPathNotFound = 3; + + [DllImport("kernel32.dll", SetLastError = true, CharSet = CharSet.Unicode)] + private static extern SafeFileHandle CreateFileW( + string lpFileName, uint dwDesiredAccess, uint dwShareMode, IntPtr lpSecurityAttributes, + uint dwCreationDisposition, uint dwFlagsAndAttributes, IntPtr hTemplateFile); + + [DllImport("kernel32.dll", SetLastError = true)] + private static extern bool DeviceIoControl( + SafeFileHandle hDevice, uint dwIoControlCode, + byte[]? lpInBuffer, uint nInBufferSize, + byte[]? lpOutBuffer, uint nOutBufferSize, + out uint lpBytesReturned, IntPtr lpOverlapped); + + [DllImport("kernel32.dll", SetLastError = true, CharSet = CharSet.Unicode)] + private static extern uint QueryDosDeviceW(string lpDeviceName, StringBuilder lpTargetPath, int ucchMax); + + /// + /// True when the HidHide driver is present (its control device exists), + /// even if a later operation might still be refused by its ACL. + /// + public static bool IsInstalled() + { + using SafeFileHandle handle = CreateFileW(ControlDevice, GenericRead | GenericWrite, + ShareReadWrite, IntPtr.Zero, OpenExisting, 0, IntPtr.Zero); + if (!handle.IsInvalid) + return true; + return Marshal.GetLastWin32Error() is not (ErrorFileNotFound or ErrorPathNotFound); + } + + /// Whether the cloak is currently up (blocked devices hidden). + public static bool GetActive() + { + using SafeFileHandle handle = Open(); + var buffer = new byte[1]; + Ioctl(handle, IoctlGetActive, null, buffer); + return buffer[0] != 0; + } + + public static void SetActive(bool active) + { + using SafeFileHandle handle = Open(); + Ioctl(handle, IoctlSetActive, new[] { active ? (byte)1 : (byte)0 }, null); + } + + /// Device instance IDs currently cloaked (e.g. HID\VID_045E…\…). + public static List GetBlockedInstances() => GetList(IoctlGetBlacklist); + + /// Replace the cloaked-instance list wholesale. + public static void SetBlockedInstances(IReadOnlyCollection instanceIds) => + SetList(IoctlSetBlacklist, instanceIds); + + /// Allowlisted applications, in dos-device form (\Device\HarddiskVolumeN\…). + public static List GetAllowedApps() => GetList(IoctlGetWhitelist); + + /// Replace the application allowlist wholesale. + public static void SetAllowedApps(IReadOnlyCollection dosDevicePaths) => + SetList(IoctlSetWhitelist, dosDevicePaths); + + /// + /// Convert a normal path (C:\dir\app.exe) to the dos-device form + /// the driver stores allowlist entries in + /// (\Device\HarddiskVolume3\dir\app.exe). Drive-letter paths only — + /// the driver compares against the kernel's image path, which never has a + /// letter. + /// + public static string ToDosDevicePath(string path) + { + string full = Path.GetFullPath(path); + string? root = Path.GetPathRoot(full); + if (root is null || root.Length < 2 || root[1] != ':') + throw new InvalidOperationException($"cannot allowlist \"{full}\" — only local drive paths are supported."); + + string drive = root.Substring(0, 2); // "C:" + var target = new StringBuilder(512); + if (QueryDosDeviceW(drive, target, target.Capacity) == 0) + throw new Win32Exception(); + return target.ToString() + full.Substring(drive.Length); + } + + // --- Wire helpers -------------------------------------------------------- + + private static SafeFileHandle Open() + { + SafeFileHandle handle = CreateFileW(ControlDevice, GenericRead | GenericWrite, + ShareReadWrite, IntPtr.Zero, OpenExisting, 0, IntPtr.Zero); + if (handle.IsInvalid) + throw new Win32Exception(); + return handle; + } + + private static uint Ioctl(SafeFileHandle handle, uint code, byte[]? input, byte[]? output) + { + if (!DeviceIoControl(handle, code, input, (uint)(input?.Length ?? 0), + output, (uint)(output?.Length ?? 0), out uint returned, IntPtr.Zero)) + throw new Win32Exception(); + return returned; + } + + private static List GetList(uint code) + { + using SafeFileHandle handle = Open(); + // Called with no output buffer the driver reports the required size + // (in bytes) instead of failing; a second call fetches the data. + uint needed = Ioctl(handle, code, null, null); + if (needed == 0) + return new List(); + var buffer = new byte[needed]; + uint got = Ioctl(handle, code, null, buffer); + return DecodeMultiSz(buffer, (int)Math.Min(got, (uint)buffer.Length)); + } + + private static void SetList(uint code, IEnumerable items) + { + using SafeFileHandle handle = Open(); + Ioctl(handle, code, EncodeMultiSz(items), null); + } + + /// UTF-16 multi-sz: each string null-terminated, double null at the end. + private static byte[] EncodeMultiSz(IEnumerable items) + { + var sb = new StringBuilder(); + foreach (string item in items) + { + sb.Append(item); + sb.Append('\0'); + } + sb.Append('\0'); + if (sb.Length == 1) + sb.Append('\0'); // an empty multi-sz is two terminators + return Encoding.Unicode.GetBytes(sb.ToString()); + } + + private static List DecodeMultiSz(byte[] buffer, int length) + { + string text = Encoding.Unicode.GetString(buffer, 0, length); + var result = new List(); + foreach (string s in text.Split('\0')) + { + if (s.Length > 0) + result.Add(s); + } + return result; + } +} diff --git a/src/VRio.App/MainForm.cs b/src/VRio.App/MainForm.cs index 9aee109..fd2f739 100644 --- a/src/VRio.App/MainForm.cs +++ b/src/VRio.App/MainForm.cs @@ -129,6 +129,23 @@ internal sealed class MainForm : Form Width = 286, DropDownStyle = ComboBoxStyle.DropDownList, }; + private readonly CheckBox _padExclusive = new() + { + Text = "Exclusive gamepad (needs HidHide)", + Location = new Point(10, 190), + AutoSize = true, + }; + + // Exclusive-gamepad state: the instance IDs we cloaked (null = off), the + // driver's active flag before we touched it, a re-entrancy guard for + // programmatic checkbox updates, a post-enable countdown proving vRIO can + // still see the pad through the cloak, and last tick's connect state for + // hot-plug detection. + private List? _hiddenPadInstances; + private bool _hidHideWasActive; + private bool _padExclusiveUpdating; + private int _exclusiveGraceTicks; + private bool _padWasConnected; /// A keyboard choice in the lamp-mirror picker (null id = all). private sealed record KbChoice(string? Id, string Name) @@ -138,14 +155,14 @@ internal sealed class MainForm : Form private readonly Label _counters = new() { - Location = new Point(12, 372), + Location = new Point(12, 386), AutoSize = true, Font = new Font("Consolas", 8f), }; private readonly Label _help = new() { - Location = new Point(12, 428), + Location = new Point(12, 442), MaximumSize = new Size(306, 0), AutoSize = true, ForeColor = Color.Gray, @@ -156,7 +173,7 @@ internal sealed class MainForm : Form private readonly TextBox _logBox = new() { - Location = new Point(12, 498), + Location = new Point(12, 512), Multiline = true, ReadOnly = true, ScrollBars = ScrollBars.Both, // long wire lines don't wrap — scroll to read @@ -186,8 +203,8 @@ internal sealed class MainForm : Form Icon = Icon.ExtractAssociatedIcon(Application.ExecutablePath); // Fit the window to its content: the cockpit canvas plus the 330px // control strip, with just enough height for the strip's log area. - ClientSize = new Size(_canvas.Width + 332, Math.Max(_canvas.Height, 640)); - MinimumSize = new Size(1000, 620); + ClientSize = new Size(_canvas.Width + 332, Math.Max(_canvas.Height, 654)); + MinimumSize = new Size(1000, 634); StartPosition = FormStartPosition.CenterScreen; KeyPreview = true; // form-level key routing for the input bindings @@ -295,6 +312,11 @@ internal sealed class MainForm : Form _rawKeyboard.KeyUp += name => RunOnUi(() => _router.KeyUp(name)); _rawKeyboard.Logged += line => RunOnUi(() => PrependLog(line)); _rawKeyboard.KeyboardsChanged += list => RunOnUi(() => RebuildInputKeyboardPicker(list)); + _padExclusive.CheckedChanged += (_, _) => + { + if (!_padExclusiveUpdating) + SetPadExclusive(_padExclusive.Checked); + }; _kbInputTarget.SelectedIndexChanged += (_, _) => ApplyInputSource(); _kbInputTarget.DropDown += (_, _) => _rawKeyboard.RefreshKeyboards(); _kbInputTarget.Items.Add(new KbChoice(null, "All keyboards (focus only)")); @@ -310,6 +332,11 @@ internal sealed class MainForm : Form FormClosed += (_, _) => { + // Drop the cloak on the way out — a hidden controller must not + // outlive the app that could still see it. Best effort: if the + // driver refuses, HidHide's own client can clean up. + try { DisablePadExclusive(); } + catch { } _uiTimer.Dispose(); _inputTimer.Dispose(); _lampMirror.Dispose(); @@ -355,13 +382,13 @@ internal sealed class MainForm : Form device.Controls.AddRange(new Control[] { _spring, _centerAxes, _lampsOff }); panel.Controls.Add(device); - var input = new GroupBox { Text = "Input", Location = new Point(12, 162), Size = new Size(306, 202) }; - input.Controls.AddRange(new Control[] { _kbInput, _padInput, _invertY, _padStatus, _reloadBindings, _editBindings, _kbLights, _kbLightsTarget, _kbInputLabel, _kbInputTarget }); + var input = new GroupBox { Text = "Input", Location = new Point(12, 162), Size = new Size(306, 216) }; + input.Controls.AddRange(new Control[] { _kbInput, _padInput, _invertY, _padStatus, _reloadBindings, _editBindings, _kbLights, _kbLightsTarget, _kbInputLabel, _kbInputTarget, _padExclusive }); panel.Controls.Add(input); panel.Controls.Add(_counters); panel.Controls.Add(_help); - panel.Controls.Add(new Label { Text = "Wire log:", Location = new Point(12, 480), AutoSize = true }); + panel.Controls.Add(new Label { Text = "Wire log:", Location = new Point(12, 494), AutoSize = true }); _logBox.Size = new Size(306, ClientSize.Height - _logBox.Top - 44); panel.Controls.Add(_logBox); @@ -620,8 +647,14 @@ internal sealed class MainForm : Form _router.Tick(dt); + if (_hiddenPadInstances is not null && _padInput.Checked) + MaintainPadExclusive(); + _padWasConnected = _gamepad.Connected; + string status = _padInput.Checked - ? _gamepad.Connected ? $"Controller #{_gamepad.UserIndex + 1} connected." : "No controller detected." + ? _gamepad.Connected + ? $"Controller #{_gamepad.UserIndex + 1} connected." + (_hiddenPadInstances is not null ? " Exclusive." : "") + : "No controller detected." : "Gamepad input off."; if (_padStatus.Text != status) { @@ -630,6 +663,149 @@ internal sealed class MainForm : Form } } + // ---- Exclusive gamepad (HidHide) ---------------------------------------- + // XInput has no exclusive acquisition, so "only vRIO gets the pad" is done + // one level down: the HidHide filter driver cloaks the pad's device + // instances from every process except allowlisted ones. vRIO allowlists + // itself, cloaks the pad (both the HID face and, for IG_/XInput pads, the + // parent devnode XInput actually reads), and reverts on toggle-off or + // exit. Not persisted across runs on purpose — an invisible controller + // with nobody holding the reveal switch is a support call. + + private void SetPadExclusive(bool on) + { + try + { + if (on) + EnablePadExclusive(); + else + DisablePadExclusive(); + } + catch (Exception ex) + { + PrependLog($"HidHide: {ex.Message}"); + SetPadExclusiveChecked(_hiddenPadInstances is not null); // reflect what actually happened + MessageBox.Show(this, $"Exclusive gamepad mode failed:\n{ex.Message}", "vRIO", + MessageBoxButtons.OK, MessageBoxIcon.Warning); + } + } + + private void EnablePadExclusive() + { + if (_hiddenPadInstances is not null) + return; + + if (!HidHideControl.IsInstalled()) + throw new InvalidOperationException( + "the HidHide filter driver is not installed. Install it from " + + "github.com/nefarius/HidHide/releases and reconnect the pad, then try again."); + + List pads = GamepadDeviceLocator.FindGamepadInstances(); + if (pads.Count == 0) + throw new InvalidOperationException( + "no gamepad or joystick device found to cloak — connect the controller first."); + + // vRIO must be able to see through the cloak before it goes up. + string self = HidHideControl.ToDosDevicePath(Application.ExecutablePath); + List apps = HidHideControl.GetAllowedApps(); + if (!apps.Contains(self, StringComparer.OrdinalIgnoreCase)) + { + apps.Add(self); + HidHideControl.SetAllowedApps(apps); + } + + // Track only the instances *we* add: toggle-off must not strip + // entries some other tool (DS4Windows, say) is relying on. + bool wasActive = HidHideControl.GetActive(); + List blocked = HidHideControl.GetBlockedInstances(); + var added = pads.Where(p => !blocked.Contains(p, StringComparer.OrdinalIgnoreCase)).ToList(); + if (added.Count > 0) + HidHideControl.SetBlockedInstances(blocked.Concat(added).ToList()); + if (!wasActive) + HidHideControl.SetActive(true); + + _hidHideWasActive = wasActive; + _hiddenPadInstances = added; + // The cloak filters device *opens*, so vRIO's already-open XInput + // handle rides through — but so does everyone else's until the pad is + // replugged. Watch our own visibility for a few seconds regardless: + // if the pad drops out, the allowlist didn't take. + _exclusiveGraceTicks = _gamepad.Connected ? 180 : 0; + + PrependLog($"HidHide: cloaked {pads.Count} gamepad device instance(s); vRIO is allowlisted."); + foreach (string id in pads) + PrependLog($"HidHide: {id}"); + PrependLog("HidHide: apps already holding the pad keep it until it's replugged."); + } + + private void DisablePadExclusive() + { + if (_hiddenPadInstances is null) + return; + + List blocked = HidHideControl.GetBlockedInstances(); + List keep = blocked + .Where(b => !_hiddenPadInstances.Contains(b, StringComparer.OrdinalIgnoreCase)).ToList(); + if (keep.Count != blocked.Count) + HidHideControl.SetBlockedInstances(keep); + if (!_hidHideWasActive) + HidHideControl.SetActive(false); + + _hiddenPadInstances = null; + _exclusiveGraceTicks = 0; + PrependLog("HidHide: gamepad visible to all applications again."); + } + + /// + /// Per-tick upkeep while exclusive mode is on: the post-enable visibility + /// watch, then cloaking any pad that hot-plugs in later (a fresh pad is a + /// fresh instance ID — without this it would bypass the cloak entirely). + /// + private void MaintainPadExclusive() + { + if (_exclusiveGraceTicks > 0) + { + if (_gamepad.Connected) + { + _exclusiveGraceTicks--; + return; + } + PrependLog("HidHide: vRIO lost sight of the pad right after cloaking — " + + "the allowlist didn't take. Reverting."); + _exclusiveGraceTicks = 0; + SetPadExclusiveChecked(false); + SetPadExclusive(false); + return; + } + + if (_gamepad.Connected && !_padWasConnected) + { + try + { + List pads = GamepadDeviceLocator.FindGamepadInstances(); + List blocked = HidHideControl.GetBlockedInstances(); + var fresh = pads.Where(p => !blocked.Contains(p, StringComparer.OrdinalIgnoreCase)).ToList(); + if (fresh.Count == 0) + return; + HidHideControl.SetBlockedInstances(blocked.Concat(fresh).ToList()); + _hiddenPadInstances!.AddRange(fresh); + PrependLog($"HidHide: cloaked {fresh.Count} newly connected gamepad device instance(s)."); + } + catch (Exception ex) + { + PrependLog($"HidHide: could not cloak the new pad — {ex.Message}"); + } + } + } + + /// Move the checkbox without tripping its change handler. + private void SetPadExclusiveChecked(bool value) + { + _padExclusiveUpdating = true; + _padExclusive.Checked = value; + _padExclusiveUpdating = false; + } + private void LoadBindings() { try