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