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 <noreply@anthropic.com>
This commit is contained in:
Cyd
2026-08-01 19:56:55 -05:00
co-authored by Claude Fable 5
parent a048b7c69b
commit 4ccb6a1a04
3 changed files with 544 additions and 9 deletions
+173
View File
@@ -0,0 +1,173 @@
using System.Runtime.InteropServices;
using System.Text;
namespace VRio.App;
/// <summary>
/// Finds the PnP device instance IDs behind every attached gamepad/joystick,
/// for handing to <see cref="HidHideControl"/>. Enumeration rides on Raw
/// Input's HID list (usage page 1, usages 4/5/8 — joystick, gamepad,
/// multi-axis), the same census games take.
///
/// <para>For XInput-class pads the HID collection is only the DirectInput
/// face; XInput itself reads the <em>parent</em> XUSB/GIP devnode. Those pads
/// are recognizable by the <c>IG_</c> 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.</para>
/// </summary>
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 ---------------------------------------------------------
/// <summary>
/// Device instance IDs of every attached gamepad/joystick: the HID
/// instances, plus the parent devnode of each <c>IG_</c> (XInput) pad.
/// Empty when no pad is attached.
/// </summary>
public static List<string> FindGamepadInstances()
{
var result = new List<string>();
uint listSize = (uint)Marshal.SizeOf<RAWINPUTDEVICELIST>();
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<RID_DEVICE_INFO>() };
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);
}
}
/// <summary>
/// <c>\\?\HID#VID_x&amp;PID_y#instance#{guid}</c> → <c>HID\VID_x&amp;PID_y\instance</c>.
/// </summary>
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;
}
}
+186
View File
@@ -0,0 +1,186 @@
using System.ComponentModel;
using System.Runtime.InteropServices;
using System.Text;
using Microsoft.Win32.SafeHandles;
namespace VRio.App;
/// <summary>
/// Minimal client for the HidHide filter driver's control device
/// (<c>\\.\HidHide</c>) — the same wire protocol Nefarius's
/// <c>HidHideControlService</c> 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.
///
/// <para>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
/// <see cref="Win32Exception"/> (driver refused / not reachable) or
/// <see cref="InvalidOperationException"/> (unconvertible path) — callers
/// surface the message and carry on.</para>
/// </summary>
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);
/// <summary>
/// True when the HidHide driver is present (its control device exists),
/// even if a later operation might still be refused by its ACL.
/// </summary>
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);
}
/// <summary>Whether the cloak is currently up (blocked devices hidden).</summary>
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);
}
/// <summary>Device instance IDs currently cloaked (e.g. <c>HID\VID_045E…\…</c>).</summary>
public static List<string> GetBlockedInstances() => GetList(IoctlGetBlacklist);
/// <summary>Replace the cloaked-instance list wholesale.</summary>
public static void SetBlockedInstances(IReadOnlyCollection<string> instanceIds) =>
SetList(IoctlSetBlacklist, instanceIds);
/// <summary>Allowlisted applications, in dos-device form (<c>\Device\HarddiskVolumeN\…</c>).</summary>
public static List<string> GetAllowedApps() => GetList(IoctlGetWhitelist);
/// <summary>Replace the application allowlist wholesale.</summary>
public static void SetAllowedApps(IReadOnlyCollection<string> dosDevicePaths) =>
SetList(IoctlSetWhitelist, dosDevicePaths);
/// <summary>
/// Convert a normal path (<c>C:\dir\app.exe</c>) to the dos-device form
/// the driver stores allowlist entries in
/// (<c>\Device\HarddiskVolume3\dir\app.exe</c>). Drive-letter paths only —
/// the driver compares against the kernel's image path, which never has a
/// letter.
/// </summary>
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<string> 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<string>();
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<string> items)
{
using SafeFileHandle handle = Open();
Ioctl(handle, code, EncodeMultiSz(items), null);
}
/// <summary>UTF-16 multi-sz: each string null-terminated, double null at the end.</summary>
private static byte[] EncodeMultiSz(IEnumerable<string> 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<string> DecodeMultiSz(byte[] buffer, int length)
{
string text = Encoding.Unicode.GetString(buffer, 0, length);
var result = new List<string>();
foreach (string s in text.Split('\0'))
{
if (s.Length > 0)
result.Add(s);
}
return result;
}
}
+185 -9
View File
@@ -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<string>? _hiddenPadInstances;
private bool _hidHideWasActive;
private bool _padExclusiveUpdating;
private int _exclusiveGraceTicks;
private bool _padWasConnected;
/// <summary>A keyboard choice in the lamp-mirror picker (null id = all).</summary>
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<string> 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<string> 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<string> 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<string> blocked = HidHideControl.GetBlockedInstances();
List<string> 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.");
}
/// <summary>
/// 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).
/// </summary>
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<string> pads = GamepadDeviceLocator.FindGamepadInstances();
List<string> 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}");
}
}
}
/// <summary>Move the checkbox without tripping its change handler.</summary>
private void SetPadExclusiveChecked(bool value)
{
_padExclusiveUpdating = true;
_padExclusive.Checked = value;
_padExclusiveUpdating = false;
}
private void LoadBindings()
{
try