diff --git a/README.md b/README.md
index 16b176e..b3aaf7c 100644
--- a/README.md
+++ b/README.md
@@ -48,6 +48,17 @@ controls:
F1–F6 / F7–F12 = the Secondary / Screen columns, numpad = internal
keypad (hex keys on the operators), arrows + Space = hat + main, with
ABXY / dpad / shoulders on the pad's named buttons.
+- **Capture one keyboard in the background** (*Capture keyboard* picker in the
+ Input panel): by default keyboard input follows the focused window, so vRIO
+ goes deaf the moment the sim takes focus. Selecting a specific keyboard
+ instead taps it through the Windows **Raw Input** API (`RIDEV_INPUTSINK`), so
+ its keys drive the panel *while the game has focus* — the input-side twin of
+ the lamp mirror writing that keyboard's LEDs under the same condition. Raw
+ Input observes without intercepting: the keystroke still reaches the focused
+ app, so this is meant for a keyboard *dedicated* to the panel, not the one you
+ also type on. Leaving the picker on *All keyboards* keeps the plain
+ focus-only behavior. (No package identity needed — unlike the lamp side, this
+ is a plain Win32 tap.)
## Wire behavior
diff --git a/src/VRio.App/MainForm.cs b/src/VRio.App/MainForm.cs
index a3c52a8..a4b34fd 100644
--- a/src/VRio.App/MainForm.cs
+++ b/src/VRio.App/MainForm.cs
@@ -20,6 +20,8 @@ internal sealed class MainForm : Form
private readonly InputRouter _router;
private readonly XInputGamepad _gamepad = new();
private readonly KeyboardLampMirror _lampMirror;
+ private readonly RawKeyboardSource _rawKeyboard = new();
+ private string? _activeInputId; // interface path of the keyboard being captured; null = WinForms focus path
private readonly string _bindingsPath = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), "vRIO", "bindings.txt");
@@ -72,6 +74,18 @@ internal sealed class MainForm : Form
DropDownStyle = ComboBoxStyle.DropDownList,
Enabled = false, // populated while the mirror is on
};
+ private readonly Label _kbInputLabel = new()
+ {
+ Text = "Capture keyboard (no window focus needed):",
+ Location = new Point(10, 168),
+ AutoSize = true,
+ };
+ private readonly ComboBox _kbInputTarget = new()
+ {
+ Location = new Point(10, 186),
+ Width = 286,
+ DropDownStyle = ComboBoxStyle.DropDownList,
+ };
/// A keyboard choice in the lamp-mirror picker (null id = all).
private sealed record KbChoice(string? Id, string Name)
@@ -81,14 +95,14 @@ internal sealed class MainForm : Form
private readonly Label _counters = new()
{
- Location = new Point(12, 414),
+ Location = new Point(12, 458),
AutoSize = true,
Font = new Font("Consolas", 8f),
};
private readonly Label _help = new()
{
- Location = new Point(12, 470),
+ Location = new Point(12, 514),
MaximumSize = new Size(306, 0),
AutoSize = true,
ForeColor = Color.Gray,
@@ -99,7 +113,7 @@ internal sealed class MainForm : Form
private readonly TextBox _logBox = new()
{
- Location = new Point(12, 554),
+ Location = new Point(12, 598),
Multiline = true,
ReadOnly = true,
ScrollBars = ScrollBars.Both, // long wire lines don't wrap — scroll to read
@@ -209,6 +223,18 @@ internal sealed class MainForm : Form
_lampMirror.KeyboardsChanged += list => RunOnUi(() => RebuildKeyboardPicker(list));
_kbLightsTarget.Items.Add(new KbChoice(null, "All keyboards"));
_kbLightsTarget.SelectedIndex = 0;
+
+ // Raw-input capture: a chosen keyboard drives the panel even while the
+ // sim has focus. Down-edges only fire when keyboard input is enabled;
+ // up-edges always land so a release is never stranded by a mid-hold flip.
+ _rawKeyboard.KeyDown += name => RunOnUi(() => { if (_kbInput.Checked) _router.KeyDown(name); });
+ _rawKeyboard.KeyUp += name => RunOnUi(() => _router.KeyUp(name));
+ _rawKeyboard.Logged += line => RunOnUi(() => PrependLog(line));
+ _rawKeyboard.KeyboardsChanged += list => RunOnUi(() => RebuildInputKeyboardPicker(list));
+ _kbInputTarget.SelectedIndexChanged += (_, _) => ApplyInputSource();
+ _kbInputTarget.DropDown += (_, _) => _rawKeyboard.RefreshKeyboards();
+ _kbInputTarget.Items.Add(new KbChoice(null, "All keyboards (focus only)"));
+ _kbInputTarget.SelectedIndex = 0;
_reloadBindings.Click += (_, _) => LoadBindings();
_editBindings.Click += (_, _) => OpenBindingsFile();
@@ -223,6 +249,7 @@ internal sealed class MainForm : Form
_uiTimer.Dispose();
_inputTimer.Dispose();
_lampMirror.Dispose();
+ _rawKeyboard.Dispose();
_service.Dispose();
};
@@ -261,13 +288,13 @@ internal sealed class MainForm : Form
device.Controls.AddRange(new Control[] { _spring, _centerAxes, _lampsOff, _testEnter, _testExit });
panel.Controls.Add(device);
- var input = new GroupBox { Text = "Input", Location = new Point(12, 224), Size = new Size(306, 182) };
- input.Controls.AddRange(new Control[] { _kbInput, _padInput, _invertY, _padStatus, _reloadBindings, _editBindings, _kbLights, _kbLightsTarget });
+ var input = new GroupBox { Text = "Input", Location = new Point(12, 224), Size = new Size(306, 226) };
+ input.Controls.AddRange(new Control[] { _kbInput, _padInput, _invertY, _padStatus, _reloadBindings, _editBindings, _kbLights, _kbLightsTarget, _kbInputLabel, _kbInputTarget });
panel.Controls.Add(input);
panel.Controls.Add(_counters);
panel.Controls.Add(_help);
- panel.Controls.Add(new Label { Text = "Wire log:", Location = new Point(12, 536), AutoSize = true });
+ panel.Controls.Add(new Label { Text = "Wire log:", Location = new Point(12, 580), AutoSize = true });
_logBox.Size = new Size(306, ClientSize.Height - _logBox.Top - 44);
panel.Controls.Add(_logBox);
@@ -338,6 +365,30 @@ internal sealed class MainForm : Form
_kbInput.Checked &&
!_portBox.ContainsFocus && !_verMajor.ContainsFocus && !_verMinor.ContainsFocus && !_logBox.ContainsFocus;
+ protected override void OnHandleCreated(EventArgs e)
+ {
+ base.OnHandleCreated(e);
+ _rawKeyboard.Attach(Handle);
+ _rawKeyboard.RefreshKeyboards(); // seed the capture picker
+ }
+
+ protected override void WndProc(ref Message m)
+ {
+ // Raw Input arrives outside the normal key-message path; feed it to the
+ // capture source, then let DefWindowProc run its WM_INPUT cleanup.
+ const int WM_INPUT = 0x00FF, WM_INPUT_DEVICE_CHANGE = 0x00FE;
+ switch (m.Msg)
+ {
+ case WM_INPUT:
+ _rawKeyboard.ProcessInput(m.LParam);
+ break;
+ case WM_INPUT_DEVICE_CHANGE:
+ _rawKeyboard.HandleDeviceChange();
+ break;
+ }
+ base.WndProc(ref m);
+ }
+
protected override bool ProcessCmdKey(ref Message msg, Keys keyData)
{
// Intercept WM_KEYDOWN before dialog-key processing, or arrows/space
@@ -350,7 +401,10 @@ internal sealed class MainForm : Form
string name = (keyData & Keys.KeyCode).ToString();
if (_router.HasKeyBinding(name))
{
- _router.KeyDown(name);
+ // While a specific keyboard is captured, that source owns
+ // routing; still swallow the key so it can't click a button.
+ if (!_rawKeyboard.IsCapturing)
+ _router.KeyDown(name);
return true;
}
}
@@ -359,18 +413,57 @@ internal sealed class MainForm : Form
protected override void OnKeyUp(KeyEventArgs e)
{
- // Unconditional: the router ignores keys it never saw go down, and a
- // release must land even if the checkbox flipped mid-hold.
- _router.KeyUp(e.KeyCode.ToString());
+ // Unconditional in focus mode: the router ignores keys it never saw go
+ // down, and a release must land even if the checkbox flipped mid-hold.
+ // In capture mode the raw source owns edges, so don't double-release.
+ if (!_rawKeyboard.IsCapturing)
+ _router.KeyUp(e.KeyCode.ToString());
base.OnKeyUp(e);
}
protected override void OnDeactivate(EventArgs e)
{
- _router.ReleaseAllKeys(); // key-up events are lost once unfocused
+ // Focus mode loses key-up events once unfocused, so release held keys.
+ // Capture mode keeps receiving them in the background — leave holds be.
+ if (!_rawKeyboard.IsCapturing)
+ _router.ReleaseAllKeys();
base.OnDeactivate(e);
}
+ /// Apply the capture-picker selection: swap the keyboard input source.
+ private void ApplyInputSource()
+ {
+ string? id = (_kbInputTarget.SelectedItem as KbChoice)?.Id;
+ if (id == _activeInputId)
+ return;
+ _activeInputId = id;
+ _router.ReleaseAllKeys(); // don't strand holds across a source swap
+ _rawKeyboard.SetTarget(id);
+ PrependLog(id is null
+ ? "Keyboard input: all keyboards, only while vRIO has focus"
+ : $"Keyboard input: capturing \"{(_kbInputTarget.SelectedItem as KbChoice)?.Name}\" in the background");
+ }
+
+ /// Refresh the capture picker, keeping the pick if the device survived.
+ private void RebuildInputKeyboardPicker(IReadOnlyList<(string Id, string Name)> keyboards)
+ {
+ string? selected = (_kbInputTarget.SelectedItem as KbChoice)?.Id;
+ _kbInputTarget.BeginUpdate();
+ _kbInputTarget.Items.Clear();
+ _kbInputTarget.Items.Add(new KbChoice(null, "All keyboards (focus only)"));
+ int select = 0;
+ foreach ((string id, string name) in keyboards)
+ {
+ int idx = _kbInputTarget.Items.Add(new KbChoice(id, name));
+ if (id == selected)
+ select = idx;
+ }
+ // A vanished capture device falls back to "All keyboards"; the
+ // selection-changed handler (ApplyInputSource) unregisters accordingly.
+ _kbInputTarget.SelectedIndex = select;
+ _kbInputTarget.EndUpdate();
+ }
+
private void InputTick()
{
double now = _clock.Elapsed.TotalSeconds;
diff --git a/src/VRio.App/RawKeyboardSource.cs b/src/VRio.App/RawKeyboardSource.cs
new file mode 100644
index 0000000..dfc7dfe
--- /dev/null
+++ b/src/VRio.App/RawKeyboardSource.cs
@@ -0,0 +1,376 @@
+using System.Runtime.InteropServices;
+using System.Text.RegularExpressions;
+using System.Windows.Forms;
+using Microsoft.Win32;
+
+namespace VRio.App;
+
+///
+/// A background keyboard input source built on the Win32 Raw Input API.
+/// Where the WinForms focus path (ProcessCmdKey/OnKeyUp) only sees
+/// keys while vRIO is the foreground window and can't tell one keyboard from
+/// another, Raw Input carries a per-device handle with every keystroke and —
+/// with the RIDEV_INPUTSINK flag — keeps delivering WM_INPUT even
+/// when vRIO is in the background. That lets the user nominate one physical
+/// keyboard as a dedicated cockpit panel: its keys drive the RIO controls while
+/// the sim (or RIOJoy) holds focus, and other keyboards are ignored.
+///
+/// This is the input-side twin of : that
+/// class writes a chosen keyboard's LEDs while the game has focus, this
+/// one reads a chosen keyboard's keys under the same condition. Raw
+/// Input observes without intercepting — the keystroke still reaches whatever
+/// app has focus — so the selected keyboard should be one dedicated to the
+/// panel, not the one you also type on.
+///
+/// Registration is scoped to when a device is actually selected
+/// (): picking "all keyboards" unregisters and hands
+/// input back to the focus path, so vRIO isn't pumping WM_INPUT for every
+/// keystroke system-wide when the feature is idle. Not thread-safe;
+/// and the events fire on the UI thread that owns the
+/// window handle, which is exactly where wants
+/// to be called.
+///
+public sealed class RawKeyboardSource : IDisposable
+{
+ // --- Win32 Raw Input interop ---------------------------------------------
+
+ [StructLayout(LayoutKind.Sequential)]
+ private struct RAWINPUTDEVICE
+ {
+ public ushort usUsagePage;
+ public ushort usUsage;
+ public uint dwFlags;
+ public IntPtr hwndTarget;
+ }
+
+ [StructLayout(LayoutKind.Sequential)]
+ private struct RAWINPUTDEVICELIST
+ {
+ public IntPtr hDevice;
+ public uint dwType;
+ }
+
+ [StructLayout(LayoutKind.Sequential)]
+ private struct RAWINPUTHEADER
+ {
+ public uint dwType;
+ public uint dwSize;
+ public IntPtr hDevice;
+ public IntPtr wParam;
+ }
+
+ [StructLayout(LayoutKind.Sequential)]
+ private struct RAWKEYBOARD
+ {
+ public ushort MakeCode;
+ public ushort Flags;
+ public ushort Reserved;
+ public ushort VKey;
+ public uint Message;
+ public uint ExtraInformation;
+ }
+
+ [DllImport("user32.dll", SetLastError = true)]
+ private static extern bool RegisterRawInputDevices(
+ [In] RAWINPUTDEVICE[] pRawInputDevices, uint uiNumDevices, uint cbSize);
+
+ [DllImport("user32.dll", SetLastError = true)]
+ private static extern uint GetRawInputData(
+ IntPtr hRawInput, uint uiCommand, IntPtr pData, ref uint pcbSize, uint cbSizeHeader);
+
+ [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);
+
+ private const uint Err = 0xFFFFFFFF; // sentinel: all three APIs return (UINT)-1 on failure
+ private const uint RIDEV_REMOVE = 0x00000001;
+ private const uint RIDEV_INPUTSINK = 0x00000100;
+ private const uint RIDEV_DEVNOTIFY = 0x00002000; // WM_INPUT_DEVICE_CHANGE on hot-plug
+ private const uint RID_INPUT = 0x10000003;
+ private const uint RIDI_DEVICENAME = 0x20000007;
+ private const uint RIM_TYPEKEYBOARD = 1;
+ private const ushort RI_KEY_BREAK = 0x01; // set = key up (make/break)
+ private const ushort HidUsagePageGeneric = 0x01;
+ private const ushort HidUsageKeyboard = 0x06;
+
+ private static readonly int HeaderSize = Marshal.SizeOf();
+
+ // --- State ---------------------------------------------------------------
+
+ // hDevice → interface path, so the hot per-keystroke path skips the
+ // two-call GetRawInputDeviceInfo dance once a device is known.
+ private readonly Dictionary _pathByHandle = new();
+
+ private IntPtr _hwnd;
+ private string? _target; // interface path of the keyboard to capture; null = disabled
+ private bool _registered;
+ private bool _disposed;
+
+ /// A key on the captured keyboard went down (.NET name).
+ public event Action? KeyDown;
+
+ /// A key on the captured keyboard came up (.NET name).
+ public event Action? KeyUp;
+
+ /// The set of attached keyboards changed (interface path, display name) — for a picker.
+ public event Action>? KeyboardsChanged;
+
+ /// Status/diagnostic lines.
+ public event Action? Logged;
+
+ /// True while a specific keyboard is being captured (focus path should stand down).
+ public bool IsCapturing => _target is not null;
+
+ /// Bind to the window whose message loop delivers WM_INPUT.
+ public void Attach(IntPtr hwnd) => _hwnd = hwnd;
+
+ ///
+ /// Capture one keyboard by interface path, or none (
+ /// null) to release input back to the focus path. Registering/unregistering
+ /// Raw Input is scoped here so vRIO only taps the global key stream while the
+ /// feature is actually in use.
+ ///
+ public void SetTarget(string? deviceId)
+ {
+ if (_target == deviceId)
+ return;
+ _target = deviceId;
+ if (deviceId is null)
+ Unregister();
+ else
+ Register();
+ }
+
+ /// Re-enumerate keyboards and raise .
+ public void RefreshKeyboards()
+ {
+ _pathByHandle.Clear(); // handles are only stable per connection
+ KeyboardsChanged?.Invoke(EnumerateKeyboards());
+ }
+
+ /// Handle a WM_INPUT_DEVICE_CHANGE: a keyboard arrived or left.
+ public void HandleDeviceChange() => RefreshKeyboards();
+
+ ///
+ /// Handle a WM_INPUT message (pass its LParam). Silently ignores
+ /// input from any keyboard other than the captured one.
+ ///
+ public void ProcessInput(IntPtr hRawInput)
+ {
+ string? target = _target;
+ if (target is null)
+ return;
+
+ uint size = 0;
+ if (GetRawInputData(hRawInput, RID_INPUT, IntPtr.Zero, ref size, (uint)HeaderSize) == Err || size == 0)
+ return;
+
+ IntPtr buffer = Marshal.AllocHGlobal((int)size);
+ try
+ {
+ if (GetRawInputData(hRawInput, RID_INPUT, buffer, ref size, (uint)HeaderSize) != size)
+ return;
+
+ var header = Marshal.PtrToStructure(buffer);
+ if (header.dwType != RIM_TYPEKEYBOARD)
+ return;
+ string? path = PathFor(header.hDevice);
+ if (!string.Equals(path, target, StringComparison.OrdinalIgnoreCase))
+ return;
+
+ var kb = Marshal.PtrToStructure(IntPtr.Add(buffer, HeaderSize));
+ ushort vkey = kb.VKey;
+ // 0 = no VK, 0xFF = fake key emitted as part of an escaped sequence.
+ if (vkey is 0 or 0xFF)
+ return;
+
+ string key = ((Keys)vkey).ToString();
+ if ((kb.Flags & RI_KEY_BREAK) != 0)
+ KeyUp?.Invoke(key);
+ else
+ KeyDown?.Invoke(key); // repeats while held are dropped by the router
+ }
+ finally
+ {
+ Marshal.FreeHGlobal(buffer);
+ }
+ }
+
+ public void Dispose()
+ {
+ if (_disposed)
+ return;
+ _disposed = true;
+ _target = null;
+ Unregister();
+ KeyDown = null;
+ KeyUp = null;
+ KeyboardsChanged = null;
+ Logged = null;
+ }
+
+ // --- Registration --------------------------------------------------------
+
+ private void Register()
+ {
+ if (_registered || _hwnd == IntPtr.Zero)
+ return;
+ var rid = new[]
+ {
+ new RAWINPUTDEVICE
+ {
+ usUsagePage = HidUsagePageGeneric,
+ usUsage = HidUsageKeyboard,
+ dwFlags = RIDEV_INPUTSINK | RIDEV_DEVNOTIFY, // background delivery + hot-plug notices
+ hwndTarget = _hwnd,
+ },
+ };
+ if (RegisterRawInputDevices(rid, 1, (uint)Marshal.SizeOf()))
+ _registered = true;
+ else
+ Logged?.Invoke($"Raw keyboard capture unavailable (error {Marshal.GetLastWin32Error()})");
+ }
+
+ private void Unregister()
+ {
+ if (!_registered)
+ return;
+ // RIDEV_REMOVE must pass a null target window, or the call fails.
+ var rid = new[]
+ {
+ new RAWINPUTDEVICE
+ {
+ usUsagePage = HidUsagePageGeneric,
+ usUsage = HidUsageKeyboard,
+ dwFlags = RIDEV_REMOVE,
+ hwndTarget = IntPtr.Zero,
+ },
+ };
+ RegisterRawInputDevices(rid, 1, (uint)Marshal.SizeOf());
+ _registered = false;
+ }
+
+ // --- Enumeration / naming ------------------------------------------------
+
+ private List<(string Id, string Name)> EnumerateKeyboards()
+ {
+ var result = new List<(string, string)>();
+ 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;
+
+ var labelCounts = new Dictionary();
+ for (int i = 0; i < got; i++)
+ {
+ if (devices[i].dwType != RIM_TYPEKEYBOARD)
+ continue;
+ string? path = PathFor(devices[i].hDevice);
+ if (string.IsNullOrEmpty(path))
+ continue;
+
+ string label = FriendlyName(path!);
+ // Physical keyboards can surface as more than one collection; a bare
+ // count suffix keeps otherwise-identical labels distinguishable.
+ if (labelCounts.TryGetValue(label, out int n))
+ {
+ labelCounts[label] = n + 1;
+ label = $"{label} ({n + 1})";
+ }
+ else
+ {
+ labelCounts[label] = 1;
+ }
+ result.Add((path!, label));
+ }
+ return result;
+ }
+
+ private string? PathFor(IntPtr hDevice)
+ {
+ if (_pathByHandle.TryGetValue(hDevice, out string? cached))
+ return cached;
+ string? path = DeviceName(hDevice);
+ if (path is not null)
+ _pathByHandle[hDevice] = path;
+ return path;
+ }
+
+ private static string? DeviceName(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);
+ }
+ }
+
+ ///
+ /// Turn a raw device interface path into something a human recognizes: the
+ /// device's registry description if we can resolve it, else its VID:PID.
+ ///
+ private static string FriendlyName(string interfacePath)
+ {
+ string? desc = RegistryName(interfacePath);
+ if (!string.IsNullOrWhiteSpace(desc))
+ return desc!;
+
+ Match m = Regex.Match(interfacePath, @"VID_([0-9A-Fa-f]{4}).*?PID_([0-9A-Fa-f]{4})");
+ return m.Success
+ ? $"Keyboard {m.Groups[1].Value.ToUpperInvariant()}:{m.Groups[2].Value.ToUpperInvariant()}"
+ : "Keyboard";
+ }
+
+ private static string? RegistryName(string interfacePath)
+ {
+ try
+ {
+ // \\?\HID#VID_x&PID_y#instance#{class-guid} → Enum key HID\VID_x&PID_y\instance
+ string s = interfacePath;
+ 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);
+ string instance = s.Replace('#', '\\');
+
+ using RegistryKey? key = Registry.LocalMachine.OpenSubKey(
+ @"SYSTEM\CurrentControlSet\Enum\" + instance);
+ if (key is null)
+ return null;
+
+ string? name = key.GetValue("FriendlyName") as string;
+ if (string.IsNullOrWhiteSpace(name))
+ name = key.GetValue("DeviceDesc") as string;
+ if (string.IsNullOrWhiteSpace(name))
+ return null;
+
+ // DeviceDesc is "@driver.inf,%token%;Actual Name" — keep the tail.
+ int semi = name!.LastIndexOf(';');
+ return semi >= 0 ? name.Substring(semi + 1) : name;
+ }
+ catch (Exception ex) when (ex is System.Security.SecurityException or UnauthorizedAccessException or IOException)
+ {
+ return null;
+ }
+ }
+}