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:
@@ -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&PID_y#instance#{guid}</c> → <c>HID\VID_x&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;
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user