From e590b89c479f436a7286197736da669ddbf5a00f Mon Sep 17 00:00:00 2001 From: Cyd Date: Mon, 6 Jul 2026 11:30:55 -0500 Subject: [PATCH] Keyboard and Xbox-gamepad input drive the panel New input pipeline: sources feed an InputRouter (VRio.Core.Input) that calls the same VRioDevice press/release/axis entry points the mouse canvas uses, so routed input is indistinguishable on the wire. - Bindings live in %APPDATA%\vRIO\bindings.txt (plain text, commented defaults written on first run; Reload/Edit buttons in the new Input group). Keys and pad buttons press any RIO address, momentary or toggle; axes bind in normalized units of each axis realistic travel window with deflect (spring-back), rate (position holds), deadzone, and invert options - RioAxisRange supplies the wire signs. - Router suppresses key auto-repeat, edge-detects pad buttons, and hold-counts per address so overlapping sources press once and release last; axes only write when the composed value changes, so mouse drags keep working while sources idle. - Xbox pad via a zero-dependency XInput P/Invoke poller (xinput1_4, fallback xinput9_1_0), throttled rescan while disconnected. - MainForm intercepts bound keys in ProcessCmdKey so arrows/space reach the panel instead of moving focus; keys release on focus loss; center-axes and host ResetRequest reset the rate integrators. - Canvas highlights router-held addresses like clicks. - Defaults: arrows=stick, W/S=throttle, Q/E=pedals, numpad=internal keypad, IJKL/Space=hat+main; pad left stick/triggers/right stick = stick/pedals/throttle-rate, ABXY/dpad/shoulders = named buttons. Co-Authored-By: Claude Fable 5 --- README.md | 9 + src/VRio.App/MainForm.cs | 166 +++++++++- src/VRio.App/PanelCanvas.cs | 14 +- src/VRio.App/XInputGamepad.cs | 120 ++++++++ src/VRio.Core/Input/BindingProfileFormat.cs | 255 ++++++++++++++++ src/VRio.Core/Input/InputBindings.cs | 69 +++++ src/VRio.Core/Input/InputRouter.cs | 316 ++++++++++++++++++++ src/VRio.Core/Input/PadState.cs | 76 +++++ tests/VRio.Core.Tests/InputRouterTests.cs | 296 ++++++++++++++++++ 9 files changed, 1315 insertions(+), 6 deletions(-) create mode 100644 src/VRio.App/XInputGamepad.cs create mode 100644 src/VRio.Core/Input/BindingProfileFormat.cs create mode 100644 src/VRio.Core/Input/InputBindings.cs create mode 100644 src/VRio.Core/Input/InputRouter.cs create mode 100644 src/VRio.Core/Input/PadState.cs create mode 100644 tests/VRio.Core.Tests/InputRouterTests.cs diff --git a/README.md b/README.md index 5295538..32d96b7 100644 --- a/README.md +++ b/README.md @@ -23,6 +23,15 @@ controls: - Cells shade to the **lamp state the host commands** (`LampRequest`: off / dim / bright, with slow/med/fast flash), so RIOJoy's press-feedback lights the on-screen panel just like the real buttons. +- **Keyboard and Xbox (XInput) controller input** drive the same controls + through a bindings file (`%APPDATA%\vRIO\bindings.txt`, created with + commented defaults on first run — *Edit bindings…* opens it, *Reload + bindings* applies edits live). Keys and pad buttons press any RIO address; + pad sticks/triggers and keys drive the axes in each axis' realistic travel + window, with deflect (spring-back), rate (throttle-style, position holds), + deadzone, and invert options. Defaults: arrows = stick, W/S = throttle, + Q/E = pedals, numpad = internal keypad, IJKL/Space = hat + main, left + stick / triggers / right stick = stick / pedals / throttle on the pad. ## Wire behavior diff --git a/src/VRio.App/MainForm.cs b/src/VRio.App/MainForm.cs index 014f036..464a533 100644 --- a/src/VRio.App/MainForm.cs +++ b/src/VRio.App/MainForm.cs @@ -1,6 +1,7 @@ using System.Diagnostics; using System.IO.Ports; using VRio.Core.Device; +using VRio.Core.Input; namespace VRio.App; @@ -16,6 +17,10 @@ internal sealed class MainForm : Form private readonly VRioDevice _device = new(); private readonly VRioSerialService _service; private readonly PanelCanvas _canvas = new(); + private readonly InputRouter _router; + private readonly XInputGamepad _gamepad = new(); + private readonly string _bindingsPath = Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), "vRIO", "bindings.txt"); private readonly ComboBox _portBox = new() { @@ -48,26 +53,39 @@ internal sealed class MainForm : Form }; private readonly Button _wedgeNow = new() { Text = "Wedge analog now", Location = new Point(10, 172), Width = 140, Height = 26 }; + private readonly CheckBox _kbInput = new() { Text = "Keyboard", Location = new Point(10, 22), AutoSize = true, Checked = true }; + private readonly CheckBox _padInput = new() { Text = "Xbox gamepad", Location = new Point(120, 22), AutoSize = true, Checked = true }; + private readonly Label _padStatus = new() + { + Text = "No controller detected.", + Location = new Point(10, 46), + AutoSize = true, + ForeColor = Color.Gray, + }; + private readonly Button _reloadBindings = new() { Text = "Reload bindings", Location = new Point(10, 68), Width = 140, Height = 26 }; + private readonly Button _editBindings = new() { Text = "Edit bindings…", Location = new Point(156, 68), Width = 140, Height = 26 }; + private readonly Label _counters = new() { - Location = new Point(12, 290), + Location = new Point(12, 396), AutoSize = true, Font = new Font("Consolas", 8f), }; private readonly Label _help = new() { - Location = new Point(12, 348), + Location = new Point(12, 452), MaximumSize = new Size(306, 0), AutoSize = true, ForeColor = Color.Gray, Text = "Left-click a cell: momentary press. Right-click: latch it down. " + - "Drag the X/Y box and the Z / L / R gauges to move the axes.", + "Drag the X/Y box and the Z / L / R gauges to move the axes. " + + "Keyboard and Xbox-pad input follow the bindings file (Edit bindings…).", }; private readonly TextBox _logBox = new() { - Location = new Point(12, 428), + Location = new Point(12, 536), Multiline = true, ReadOnly = true, ScrollBars = ScrollBars.Vertical, @@ -85,7 +103,9 @@ internal sealed class MainForm : Form }; private readonly System.Windows.Forms.Timer _uiTimer = new() { Interval = 500 }; + private readonly System.Windows.Forms.Timer _inputTimer = new() { Interval = 16 }; private readonly Stopwatch _clock = Stopwatch.StartNew(); + private double _lastInputTick; public MainForm() { @@ -95,8 +115,10 @@ internal sealed class MainForm : Form ClientSize = new Size(_canvas.Width + 332, Math.Max(_canvas.Height, 640)); MinimumSize = new Size(1000, 620); StartPosition = FormStartPosition.CenterScreen; + KeyPreview = true; // form-level key routing for the input bindings _service = new VRioSerialService(_device); + _router = new InputRouter(_device); // Panel canvas, scrolled if the window is smaller than the grid. var scroller = new Panel { Dock = DockStyle.Fill, AutoScroll = true, BackColor = Color.FromArgb(28, 28, 28) }; @@ -112,9 +134,14 @@ internal sealed class MainForm : Form _canvas.AddressReleased += _device.ReleaseAddress; _canvas.AxisMoved += (axis, value) => _device.SetAxis(axis, value); + // Router-driven presses light up like clicks (router runs on the UI thread). + _router.AddressHeldChanged += _canvas.SetExternalHeld; + // Device / service events arrive on worker threads; marshal to the UI. _device.LampChanged += (_, _) => RunOnUi(_canvas.Invalidate); _device.AxesChanged += () => RunOnUi(_canvas.Invalidate); + // A host reset re-zeroes the axes behind the router's back. + _device.ResetReceived += _ => RunOnUi(_router.ResetAxisState); _device.Logged += line => RunOnUi(() => PrependLog(line)); _service.Logged += line => RunOnUi(() => PrependLog(line)); _service.ConnectionChanged += open => RunOnUi(() => OnConnectionChanged(open)); @@ -130,6 +157,7 @@ internal sealed class MainForm : Form { foreach (RioAxis axis in (RioAxis[])Enum.GetValues(typeof(RioAxis))) _device.SetAxis(axis, 0); + _router.ResetAxisState(); }; _lampsOff.Click += (_, _) => { @@ -146,18 +174,31 @@ internal sealed class MainForm : Form }; _clearLog.Click += (_, _) => _logBox.Clear(); + _kbInput.CheckedChanged += (_, _) => + { + if (!_kbInput.Checked) + _router.ReleaseAllKeys(); + }; + _reloadBindings.Click += (_, _) => LoadBindings(); + _editBindings.Click += (_, _) => OpenBindingsFile(); + _uiTimer.Tick += (_, _) => UpdateStatus(); _uiTimer.Start(); + _inputTimer.Tick += (_, _) => InputTick(); + _inputTimer.Start(); + FormClosed += (_, _) => { _uiTimer.Dispose(); + _inputTimer.Dispose(); _service.Dispose(); }; RefreshPorts(); UpdateStatus(); PrependLog("vRIO ready. Open a COM port, then point RIOJoy at the other end of the pair."); + LoadBindings(); } private Panel BuildControlStrip() @@ -189,9 +230,13 @@ internal sealed class MainForm : Form device.Controls.AddRange(new Control[] { _spring, _centerAxes, _lampsOff, _testEnter, _testExit, _wedgeBug, _wedgeNow }); panel.Controls.Add(device); + var input = new GroupBox { Text = "Input", Location = new Point(12, 284), Size = new Size(306, 104) }; + input.Controls.AddRange(new Control[] { _kbInput, _padInput, _padStatus, _reloadBindings, _editBindings }); + panel.Controls.Add(input); + panel.Controls.Add(_counters); panel.Controls.Add(_help); - panel.Controls.Add(new Label { Text = "Wire log:", Location = new Point(12, 410), AutoSize = true }); + panel.Controls.Add(new Label { Text = "Wire log:", Location = new Point(12, 518), AutoSize = true }); _logBox.Size = new Size(306, ClientSize.Height - _logBox.Top - 44); panel.Controls.Add(_logBox); @@ -252,6 +297,117 @@ internal sealed class MainForm : Form UpdateStatus(); } + // ---- Keyboard / gamepad input ------------------------------------------- + + /// + /// Keys route to the panel unless the user is in a control that needs + /// them (port list, firmware spinners, log box scrolling). + /// + private bool KeyboardRoutingActive => + _kbInput.Checked && + !_portBox.ContainsFocus && !_verMajor.ContainsFocus && !_verMinor.ContainsFocus && !_logBox.ContainsFocus; + + protected override bool ProcessCmdKey(ref Message msg, Keys keyData) + { + // Intercept WM_KEYDOWN before dialog-key processing, or arrows/space + // would move focus and click buttons instead of reaching the panel. + const int WM_KEYDOWN = 0x0100, WM_SYSKEYDOWN = 0x0104; + if ((msg.Msg is WM_KEYDOWN or WM_SYSKEYDOWN) + && (keyData & (Keys.Control | Keys.Alt)) == 0 + && KeyboardRoutingActive) + { + string name = (keyData & Keys.KeyCode).ToString(); + if (_router.HasKeyBinding(name)) + { + _router.KeyDown(name); + return true; + } + } + return base.ProcessCmdKey(ref msg, keyData); + } + + 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()); + base.OnKeyUp(e); + } + + protected override void OnDeactivate(EventArgs e) + { + _router.ReleaseAllKeys(); // key-up events are lost once unfocused + base.OnDeactivate(e); + } + + private void InputTick() + { + double now = _clock.Elapsed.TotalSeconds; + double dt = Math.Min(0.25, now - _lastInputTick); // cap catch-up after a stall + _lastInputTick = now; + + if (_padInput.Checked && _gamepad.TryRead(out var pad)) + _router.SetPadState(pad); + else + _router.SetPadState(default); // releases whatever the pad held + + _router.Tick(dt); + + string status = _padInput.Checked + ? _gamepad.Connected ? $"Controller #{_gamepad.UserIndex + 1} connected." : "No controller detected." + : "Gamepad input off."; + if (_padStatus.Text != status) + { + _padStatus.Text = status; + _padStatus.ForeColor = _gamepad.Connected && _padInput.Checked ? Color.ForestGreen : Color.Gray; + } + } + + private void LoadBindings() + { + try + { + string text; + if (File.Exists(_bindingsPath)) + { + text = File.ReadAllText(_bindingsPath); + } + else + { + // First run: materialize the commented default file so + // "Edit bindings…" has something self-documenting to open. + Directory.CreateDirectory(Path.GetDirectoryName(_bindingsPath)!); + File.WriteAllText(_bindingsPath, BindingProfileFormat.DefaultText); + text = BindingProfileFormat.DefaultText; + } + + var profile = BindingProfileFormat.Parse(text, out var errors); + _router.Profile = profile; + foreach (string error in errors) + PrependLog($"Bindings: {error}"); + PrependLog($"Bindings loaded: {profile.Count} ({_bindingsPath})"); + } + catch (Exception ex) + { + PrependLog($"Bindings load failed: {ex.Message}"); + } + } + + private void OpenBindingsFile() + { + try + { + if (!File.Exists(_bindingsPath)) + LoadBindings(); // writes the default file + Process.Start(new ProcessStartInfo(_bindingsPath) { UseShellExecute = true }); + } + catch (Exception ex) + { + MessageBox.Show(this, $"Could not open {_bindingsPath}:\n{ex.Message}", "vRIO", + MessageBoxButtons.OK, MessageBoxIcon.Warning); + } + } + // ---- Status / log ------------------------------------------------------ private void UpdateStatus() diff --git a/src/VRio.App/PanelCanvas.cs b/src/VRio.App/PanelCanvas.cs index aabae64..5c4fdbf 100644 --- a/src/VRio.App/PanelCanvas.cs +++ b/src/VRio.App/PanelCanvas.cs @@ -43,6 +43,7 @@ internal sealed class PanelCanvas : Control private bool _wasFlashing; private readonly HashSet _latched = new(); + private readonly HashSet _externalHeld = new(); private int? _mouseDownAddress; private RioAxis? _dragAxis; // Z / L / R gauge drag @@ -215,7 +216,18 @@ internal sealed class PanelCanvas : Control } } - private bool IsHeld(int address) => _mouseDownAddress == address || _latched.Contains(address); + private bool IsHeld(int address) => + _mouseDownAddress == address || _latched.Contains(address) || _externalHeld.Contains(address); + + /// + /// Mark an address held/released by a non-mouse source (keyboard or + /// gamepad via the input router) so it lights up like a click. + /// + public void SetExternalHeld(int address, bool held) + { + if (held ? _externalHeld.Add(address) : _externalHeld.Remove(address)) + Invalidate(); + } private static bool FlashPhaseOn(LampFlash flash, int tick) { diff --git a/src/VRio.App/XInputGamepad.cs b/src/VRio.App/XInputGamepad.cs new file mode 100644 index 0000000..bec614c --- /dev/null +++ b/src/VRio.App/XInputGamepad.cs @@ -0,0 +1,120 @@ +using System.Runtime.InteropServices; +using VRio.Core.Input; + +namespace VRio.App; + +/// +/// Polls the first connected XInput (Xbox) controller and converts its state +/// to the Core's normalized . Uses the in-box +/// xinput1_4.dll (Windows 8+), falling back to xinput9_1_0.dll, so no +/// redistributable is needed. Polling a disconnected user index is expensive, +/// so while no pad is present the 0–3 scan only runs every ~1 s. +/// +internal sealed class XInputGamepad +{ + private const int MaxUsers = 4; + private const uint ErrorSuccess = 0; + private const int ScanEveryNPolls = 60; // ≈1 s at the 16 ms input tick + + private static bool _tryXInput14 = true; + + private int _user = -1; + private int _scanCountdown; + + /// True while a controller is connected and being polled. + public bool Connected => _user >= 0; + + /// XInput user index of the connected controller (0-based). + public int UserIndex => _user; + + /// + /// Poll the pad. False (with a rest-state snapshot) while disconnected. + /// + public bool TryRead(out PadState state) + { + if (_user >= 0) + { + if (GetState(_user, out XINPUT_STATE s) == ErrorSuccess) + { + state = Convert(s.Gamepad); + return true; + } + _user = -1; // unplugged; fall through to (throttled) rescan + _scanCountdown = 0; + } + + if (--_scanCountdown <= 0) + { + _scanCountdown = ScanEveryNPolls; + for (int i = 0; i < MaxUsers; i++) + { + if (GetState(i, out XINPUT_STATE s) == ErrorSuccess) + { + _user = i; + state = Convert(s.Gamepad); + return true; + } + } + } + + state = default; + return false; + } + + private static PadState Convert(in XINPUT_GAMEPAD g) => new( + (PadButtons)g.wButtons, + Thumb(g.sThumbLX), Thumb(g.sThumbLY), + Thumb(g.sThumbRX), Thumb(g.sThumbRY), + g.bLeftTrigger / 255f, g.bRightTrigger / 255f); + + private static float Thumb(short v) => Math.Max(-1f, v / 32767f); + + private static uint GetState(int user, out XINPUT_STATE state) + { + if (_tryXInput14) + { + try + { + return XInputGetState14(user, out state); + } + catch (DllNotFoundException) + { + _tryXInput14 = false; + } + } + try + { + return XInputGetState910(user, out state); + } + catch (DllNotFoundException) + { + state = default; + return uint.MaxValue; // no XInput on this system — behaves as "no pad" + } + } + + [DllImport("xinput1_4.dll", EntryPoint = "XInputGetState")] + private static extern uint XInputGetState14(int dwUserIndex, out XINPUT_STATE state); + + [DllImport("xinput9_1_0.dll", EntryPoint = "XInputGetState")] + private static extern uint XInputGetState910(int dwUserIndex, out XINPUT_STATE state); + + [StructLayout(LayoutKind.Sequential)] + private struct XINPUT_STATE + { + public uint dwPacketNumber; + public XINPUT_GAMEPAD Gamepad; + } + + [StructLayout(LayoutKind.Sequential)] + private struct XINPUT_GAMEPAD + { + public ushort wButtons; + public byte bLeftTrigger; + public byte bRightTrigger; + public short sThumbLX; + public short sThumbLY; + public short sThumbRX; + public short sThumbRY; + } +} diff --git a/src/VRio.Core/Input/BindingProfileFormat.cs b/src/VRio.Core/Input/BindingProfileFormat.cs new file mode 100644 index 0000000..466b325 --- /dev/null +++ b/src/VRio.Core/Input/BindingProfileFormat.cs @@ -0,0 +1,255 @@ +using System.Globalization; +using VRio.Core.Device; + +namespace VRio.Core.Input; + +/// +/// The plain-text bindings file: one binding per line, # comments, +/// case-insensitive keywords, whitespace-separated tokens. Grammar: +/// +/// key <name> button <addr> [toggle] +/// key <name> axis <axis> deflect <n> | rate <n> +/// pad <button> button <addr> [toggle] +/// padaxis <src> axis <axis> [invert] [deadzone <d>] [rate <n>] +/// +/// Bad lines are reported (with their line number) and skipped, so one typo +/// doesn't take the whole profile down. +/// +public static class BindingProfileFormat +{ + /// + /// Parse a bindings file. receives one message + /// per rejected line; every well-formed line still becomes a binding. + /// + public static BindingProfile Parse(string text, out IReadOnlyList errors) + { + var keyButtons = new List(); + var keyAxes = new List(); + var padButtons = new List(); + var padAxes = new List(); + var errs = new List(); + errors = errs; + + string[] lines = text.Split('\n'); + for (int i = 0; i < lines.Length; i++) + { + string line = lines[i]; + int hash = line.IndexOf('#'); + if (hash >= 0) line = line.Substring(0, hash); + string[] tok = line.Split((char[]?)null, StringSplitOptions.RemoveEmptyEntries); + if (tok.Length == 0) + continue; + + try + { + ParseLine(tok, keyButtons, keyAxes, padButtons, padAxes); + } + catch (FormatException ex) + { + errs.Add($"line {i + 1}: {ex.Message}"); + } + } + + return new BindingProfile(keyButtons, keyAxes, padButtons, padAxes); + } + + private static void ParseLine(string[] tok, + List keyButtons, List keyAxes, + List padButtons, List padAxes) + { + if (tok.Length < 4) + throw new FormatException("expected ' …'"); + + string source = tok[0].ToLowerInvariant(); + string target = tok[2].ToLowerInvariant(); + switch (source, target) + { + case ("key", "button"): + keyButtons.Add(new KeyButtonBinding(tok[1], ParseAddress(tok[3]), ParseToggle(tok, 4))); + break; + + case ("key", "axis"): + { + if (tok.Length < 6) + throw new FormatException("key axis bindings need 'deflect ' or 'rate '"); + KeyAxisMode mode = tok[4].ToLowerInvariant() switch + { + "deflect" => KeyAxisMode.Deflect, + "rate" => KeyAxisMode.Rate, + var other => throw new FormatException($"unknown key-axis mode '{other}' (deflect/rate)"), + }; + keyAxes.Add(new KeyAxisBinding(tok[1], ParseRioAxis(tok[3]), mode, ParseFloat(tok[5]))); + break; + } + + case ("pad", "button"): + padButtons.Add(new PadButtonBinding(ParsePadButton(tok[1]), ParseAddress(tok[3]), ParseToggle(tok, 4))); + break; + + case ("padaxis", "axis"): + { + bool invert = false; + float deadzone = 0f, rate = 0f; + for (int i = 4; i < tok.Length; i++) + { + switch (tok[i].ToLowerInvariant()) + { + case "invert": + invert = true; + break; + case "deadzone": + deadzone = ParseFloat(TakeValue(tok, ref i, "deadzone")); + break; + case "rate": + rate = ParseFloat(TakeValue(tok, ref i, "rate")); + break; + default: + throw new FormatException($"unknown padaxis option '{tok[i]}'"); + } + } + padAxes.Add(new PadAxisBinding(ParsePadAxis(tok[1]), ParseRioAxis(tok[3]), invert, deadzone, rate)); + break; + } + + default: + throw new FormatException($"unknown binding form '{tok[0]} … {tok[2]}'"); + } + } + + private static string TakeValue(string[] tok, ref int i, string option) + { + if (i + 1 >= tok.Length) + throw new FormatException($"'{option}' needs a value"); + return tok[++i]; + } + + private static bool ParseToggle(string[] tok, int index) + { + if (tok.Length <= index) + return false; + if (tok.Length == index + 1 && tok[index].Equals("toggle", StringComparison.OrdinalIgnoreCase)) + return true; + throw new FormatException($"unexpected '{tok[index]}' (only 'toggle' may follow the address)"); + } + + private static int ParseAddress(string s) + { + int addr; + bool ok = s.StartsWith("0x", StringComparison.OrdinalIgnoreCase) + ? int.TryParse(s.Substring(2), NumberStyles.HexNumber, CultureInfo.InvariantCulture, out addr) + : int.TryParse(s, NumberStyles.Integer, CultureInfo.InvariantCulture, out addr); + if (!ok || !(RioAddressSpace.IsButton(addr) || RioAddressSpace.IsKeypad(addr))) + throw new FormatException($"'{s}' is not a RIO input address (0x00-0x47, 0x50-0x6F)"); + return addr; + } + + private static RioAxis ParseRioAxis(string s) => + Enum.TryParse(s, ignoreCase: true, out RioAxis axis) && Enum.IsDefined(typeof(RioAxis), axis) + ? axis + : throw new FormatException($"unknown RIO axis '{s}' (Throttle/LeftPedal/RightPedal/JoystickY/JoystickX)"); + + private static PadButtons ParsePadButton(string s) => + Enum.TryParse(s, ignoreCase: true, out PadButtons b) && b != PadButtons.None && Enum.IsDefined(typeof(PadButtons), b) + ? b + : throw new FormatException($"unknown pad button '{s}'"); + + private static PadAxis ParsePadAxis(string s) => + Enum.TryParse(s, ignoreCase: true, out PadAxis a) && Enum.IsDefined(typeof(PadAxis), a) + ? a + : throw new FormatException($"unknown pad axis '{s}'"); + + private static float ParseFloat(string s) => + float.TryParse(s, NumberStyles.Float, CultureInfo.InvariantCulture, out float f) + ? f + : throw new FormatException($"'{s}' is not a number"); + + /// + /// The default profile text, written verbatim (comments and all) as the + /// user's bindings file on first run. + /// + public const string DefaultText = @"# vRIO input bindings — keyboard and Xbox (XInput) controller. +# +# One binding per line, '#' starts a comment, keywords are case- +# insensitive. Edit freely, then press 'Reload bindings' in vRIO. +# +# key button [toggle] +# key axis deflect +# key axis rate +# pad