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 <noreply@anthropic.com>
This commit is contained in:
Cyd
2026-07-06 11:30:55 -05:00
co-authored by Claude Fable 5
parent 1f0781f06a
commit e590b89c47
9 changed files with 1315 additions and 6 deletions
+9
View File
@@ -23,6 +23,15 @@ controls:
- Cells shade to the **lamp state the host commands** (`LampRequest`: - Cells shade to the **lamp state the host commands** (`LampRequest`:
off / dim / bright, with slow/med/fast flash), so RIOJoy's press-feedback off / dim / bright, with slow/med/fast flash), so RIOJoy's press-feedback
lights the on-screen panel just like the real buttons. 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 ## Wire behavior
+161 -5
View File
@@ -1,6 +1,7 @@
using System.Diagnostics; using System.Diagnostics;
using System.IO.Ports; using System.IO.Ports;
using VRio.Core.Device; using VRio.Core.Device;
using VRio.Core.Input;
namespace VRio.App; namespace VRio.App;
@@ -16,6 +17,10 @@ internal sealed class MainForm : Form
private readonly VRioDevice _device = new(); private readonly VRioDevice _device = new();
private readonly VRioSerialService _service; private readonly VRioSerialService _service;
private readonly PanelCanvas _canvas = new(); 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() 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 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() private readonly Label _counters = new()
{ {
Location = new Point(12, 290), Location = new Point(12, 396),
AutoSize = true, AutoSize = true,
Font = new Font("Consolas", 8f), Font = new Font("Consolas", 8f),
}; };
private readonly Label _help = new() private readonly Label _help = new()
{ {
Location = new Point(12, 348), Location = new Point(12, 452),
MaximumSize = new Size(306, 0), MaximumSize = new Size(306, 0),
AutoSize = true, AutoSize = true,
ForeColor = Color.Gray, ForeColor = Color.Gray,
Text = "Left-click a cell: momentary press. Right-click: latch it down. " + 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() private readonly TextBox _logBox = new()
{ {
Location = new Point(12, 428), Location = new Point(12, 536),
Multiline = true, Multiline = true,
ReadOnly = true, ReadOnly = true,
ScrollBars = ScrollBars.Vertical, 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 _uiTimer = new() { Interval = 500 };
private readonly System.Windows.Forms.Timer _inputTimer = new() { Interval = 16 };
private readonly Stopwatch _clock = Stopwatch.StartNew(); private readonly Stopwatch _clock = Stopwatch.StartNew();
private double _lastInputTick;
public MainForm() public MainForm()
{ {
@@ -95,8 +115,10 @@ internal sealed class MainForm : Form
ClientSize = new Size(_canvas.Width + 332, Math.Max(_canvas.Height, 640)); ClientSize = new Size(_canvas.Width + 332, Math.Max(_canvas.Height, 640));
MinimumSize = new Size(1000, 620); MinimumSize = new Size(1000, 620);
StartPosition = FormStartPosition.CenterScreen; StartPosition = FormStartPosition.CenterScreen;
KeyPreview = true; // form-level key routing for the input bindings
_service = new VRioSerialService(_device); _service = new VRioSerialService(_device);
_router = new InputRouter(_device);
// Panel canvas, scrolled if the window is smaller than the grid. // 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) }; 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.AddressReleased += _device.ReleaseAddress;
_canvas.AxisMoved += (axis, value) => _device.SetAxis(axis, value); _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 / service events arrive on worker threads; marshal to the UI.
_device.LampChanged += (_, _) => RunOnUi(_canvas.Invalidate); _device.LampChanged += (_, _) => RunOnUi(_canvas.Invalidate);
_device.AxesChanged += () => 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)); _device.Logged += line => RunOnUi(() => PrependLog(line));
_service.Logged += line => RunOnUi(() => PrependLog(line)); _service.Logged += line => RunOnUi(() => PrependLog(line));
_service.ConnectionChanged += open => RunOnUi(() => OnConnectionChanged(open)); _service.ConnectionChanged += open => RunOnUi(() => OnConnectionChanged(open));
@@ -130,6 +157,7 @@ internal sealed class MainForm : Form
{ {
foreach (RioAxis axis in (RioAxis[])Enum.GetValues(typeof(RioAxis))) foreach (RioAxis axis in (RioAxis[])Enum.GetValues(typeof(RioAxis)))
_device.SetAxis(axis, 0); _device.SetAxis(axis, 0);
_router.ResetAxisState();
}; };
_lampsOff.Click += (_, _) => _lampsOff.Click += (_, _) =>
{ {
@@ -146,18 +174,31 @@ internal sealed class MainForm : Form
}; };
_clearLog.Click += (_, _) => _logBox.Clear(); _clearLog.Click += (_, _) => _logBox.Clear();
_kbInput.CheckedChanged += (_, _) =>
{
if (!_kbInput.Checked)
_router.ReleaseAllKeys();
};
_reloadBindings.Click += (_, _) => LoadBindings();
_editBindings.Click += (_, _) => OpenBindingsFile();
_uiTimer.Tick += (_, _) => UpdateStatus(); _uiTimer.Tick += (_, _) => UpdateStatus();
_uiTimer.Start(); _uiTimer.Start();
_inputTimer.Tick += (_, _) => InputTick();
_inputTimer.Start();
FormClosed += (_, _) => FormClosed += (_, _) =>
{ {
_uiTimer.Dispose(); _uiTimer.Dispose();
_inputTimer.Dispose();
_service.Dispose(); _service.Dispose();
}; };
RefreshPorts(); RefreshPorts();
UpdateStatus(); UpdateStatus();
PrependLog("vRIO ready. Open a COM port, then point RIOJoy at the other end of the pair."); PrependLog("vRIO ready. Open a COM port, then point RIOJoy at the other end of the pair.");
LoadBindings();
} }
private Panel BuildControlStrip() private Panel BuildControlStrip()
@@ -189,9 +230,13 @@ internal sealed class MainForm : Form
device.Controls.AddRange(new Control[] { _spring, _centerAxes, _lampsOff, _testEnter, _testExit, _wedgeBug, _wedgeNow }); device.Controls.AddRange(new Control[] { _spring, _centerAxes, _lampsOff, _testEnter, _testExit, _wedgeBug, _wedgeNow });
panel.Controls.Add(device); 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(_counters);
panel.Controls.Add(_help); 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); _logBox.Size = new Size(306, ClientSize.Height - _logBox.Top - 44);
panel.Controls.Add(_logBox); panel.Controls.Add(_logBox);
@@ -252,6 +297,117 @@ internal sealed class MainForm : Form
UpdateStatus(); UpdateStatus();
} }
// ---- Keyboard / gamepad input -------------------------------------------
/// <summary>
/// Keys route to the panel unless the user is in a control that needs
/// them (port list, firmware spinners, log box scrolling).
/// </summary>
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 ------------------------------------------------------ // ---- Status / log ------------------------------------------------------
private void UpdateStatus() private void UpdateStatus()
+13 -1
View File
@@ -43,6 +43,7 @@ internal sealed class PanelCanvas : Control
private bool _wasFlashing; private bool _wasFlashing;
private readonly HashSet<int> _latched = new(); private readonly HashSet<int> _latched = new();
private readonly HashSet<int> _externalHeld = new();
private int? _mouseDownAddress; private int? _mouseDownAddress;
private RioAxis? _dragAxis; // Z / L / R gauge drag 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);
/// <summary>
/// Mark an address held/released by a non-mouse source (keyboard or
/// gamepad via the input router) so it lights up like a click.
/// </summary>
public void SetExternalHeld(int address, bool held)
{
if (held ? _externalHeld.Add(address) : _externalHeld.Remove(address))
Invalidate();
}
private static bool FlashPhaseOn(LampFlash flash, int tick) private static bool FlashPhaseOn(LampFlash flash, int tick)
{ {
+120
View File
@@ -0,0 +1,120 @@
using System.Runtime.InteropServices;
using VRio.Core.Input;
namespace VRio.App;
/// <summary>
/// Polls the first connected XInput (Xbox) controller and converts its state
/// to the Core's normalized <see cref="PadState"/>. 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 03 scan only runs every ~1 s.
/// </summary>
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;
/// <summary>True while a controller is connected and being polled.</summary>
public bool Connected => _user >= 0;
/// <summary>XInput user index of the connected controller (0-based).</summary>
public int UserIndex => _user;
/// <summary>
/// Poll the pad. False (with a rest-state snapshot) while disconnected.
/// </summary>
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;
}
}
+255
View File
@@ -0,0 +1,255 @@
using System.Globalization;
using VRio.Core.Device;
namespace VRio.Core.Input;
/// <summary>
/// The plain-text bindings file: one binding per line, <c>#</c> comments,
/// case-insensitive keywords, whitespace-separated tokens. Grammar:
/// <code>
/// key &lt;name&gt; button &lt;addr&gt; [toggle]
/// key &lt;name&gt; axis &lt;axis&gt; deflect &lt;n&gt; | rate &lt;n&gt;
/// pad &lt;button&gt; button &lt;addr&gt; [toggle]
/// padaxis &lt;src&gt; axis &lt;axis&gt; [invert] [deadzone &lt;d&gt;] [rate &lt;n&gt;]
/// </code>
/// Bad lines are reported (with their line number) and skipped, so one typo
/// doesn't take the whole profile down.
/// </summary>
public static class BindingProfileFormat
{
/// <summary>
/// Parse a bindings file. <paramref name="errors"/> receives one message
/// per rejected line; every well-formed line still becomes a binding.
/// </summary>
public static BindingProfile Parse(string text, out IReadOnlyList<string> errors)
{
var keyButtons = new List<KeyButtonBinding>();
var keyAxes = new List<KeyAxisBinding>();
var padButtons = new List<PadButtonBinding>();
var padAxes = new List<PadAxisBinding>();
var errs = new List<string>();
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<KeyButtonBinding> keyButtons, List<KeyAxisBinding> keyAxes,
List<PadButtonBinding> padButtons, List<PadAxisBinding> padAxes)
{
if (tok.Length < 4)
throw new FormatException("expected '<source> <name> <target> <value> …'");
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 <n>' or 'rate <n>'");
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");
/// <summary>
/// The default profile text, written verbatim (comments and all) as the
/// user's bindings file on first run.
/// </summary>
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 <name> button <addr> [toggle]
# key <name> axis <axis> deflect <n>
# key <name> axis <axis> rate <n-per-second>
# pad <button> button <addr> [toggle]
# padaxis <src> axis <axis> [invert] [deadzone <d>] [rate <n-per-second>]
#
# <addr> RIO input address: lamp buttons 0x00-0x47, internal keypad
# 0x50-0x5F, external keypad 0x60-0x6F (hex or decimal).
# <axis> Throttle | LeftPedal | RightPedal | JoystickY | JoystickX
# <name> .NET Keys name: A-Z, D0-D9 (digit row), NumPad0-NumPad9,
# Up, Down, Left, Right, Space, Enter, OemMinus, Oemplus, ...
# <button> A B X Y DPadUp DPadDown DPadLeft DPadRight Start Back
# LeftShoulder RightShoulder LeftThumb RightThumb
# <src> LeftStickX LeftStickY RightStickX RightStickY
# LeftTrigger RightTrigger
#
# Axis values are normalized: 1 = the axis' full realistic travel
# (throttle 0..-800 raw, pedals 0..+500, stick +/-80 — the wire signs
# are applied for you). 'deflect' holds the axis there while the key
# is down and springs back on release; 'rate' walks the axis by <n>
# per second and the position sticks (use it for the throttle).
# NOTE: raw stick X runs NEGATIVE to the right (that is what RIOJoy
# expects), so 'right' is deflect -1 and pad X is inverted below.
# ---- Joystick: arrows deflect, spring back on release -------------
key Up axis JoystickY deflect 1
key Down axis JoystickY deflect -1
key Right axis JoystickX deflect -1
key Left axis JoystickX deflect 1
# ---- Throttle: W advances, S backs off, position holds ------------
key W axis Throttle rate 0.75
key S axis Throttle rate -0.75
# ---- Pedals: hold to press, spring back ---------------------------
key Q axis LeftPedal deflect 1
key E axis RightPedal deflect 1
# ---- Joystick / throttle column buttons ---------------------------
key Space button 0x40 # Main
key I button 0x42 # Hat Up
key K button 0x41 # Hat Back
key J button 0x44 # Hat Left
key L button 0x43 # Hat Right
key P button 0x45 # Pinky
key M button 0x46 # Middle
key U button 0x47 # Upper
key B button 0x3D # Panic
# ---- Internal keypad on the numpad --------------------------------
key NumPad0 button 0x50
key NumPad1 button 0x51
key NumPad2 button 0x52
key NumPad3 button 0x53
key NumPad4 button 0x54
key NumPad5 button 0x55
key NumPad6 button 0x56
key NumPad7 button 0x57
key NumPad8 button 0x58
key NumPad9 button 0x59
# ---- Xbox controller: axes ----------------------------------------
padaxis LeftStickX axis JoystickX invert deadzone 0.2
padaxis LeftStickY axis JoystickY deadzone 0.2
padaxis RightStickY axis Throttle deadzone 0.2 rate 0.75
padaxis LeftTrigger axis LeftPedal deadzone 0.12
padaxis RightTrigger axis RightPedal deadzone 0.12
# ---- Xbox controller: buttons -------------------------------------
pad A button 0x40 # Main
pad B button 0x3D # Panic
pad X button 0x45 # Pinky
pad Y button 0x47 # Upper
pad DPadUp button 0x42 # Hat Up
pad DPadDown button 0x41 # Hat Back
pad DPadLeft button 0x44 # Hat Left
pad DPadRight button 0x43 # Hat Right
pad LeftShoulder button 0x46 # Middle
pad RightShoulder button 0x3F # Throttle button
";
}
+69
View File
@@ -0,0 +1,69 @@
using VRio.Core.Device;
namespace VRio.Core.Input;
/// <summary>
/// How a key drives an axis: <see cref="Deflect"/> holds the axis at the
/// binding's value while the key is down and springs back when released
/// (stick, pedals); <see cref="Rate"/> walks the axis by value-per-second
/// while held and the position sticks (throttle).
/// </summary>
public enum KeyAxisMode
{
Deflect,
Rate,
}
/// <summary>A keyboard key pressing a RIO input address.</summary>
/// <param name="Key">Key name as the host reports it (System.Windows.Forms
/// Keys.ToString(): "W", "Up", "NumPad1", …); matched case-insensitively.</param>
/// <param name="Address">RIO input address (button 0x000x47 or keypad 0x500x6F).</param>
/// <param name="Toggle">Latch on press instead of momentary press/release.</param>
public sealed record KeyButtonBinding(string Key, int Address, bool Toggle);
/// <summary>
/// A keyboard key driving a RIO axis in normalized units, where 1 is the
/// axis' full realistic travel (<see cref="RioAxisRange.Full"/> carries the
/// wire sign, so bindings never deal with the throttle's negative direction).
/// </summary>
/// <param name="Value">Deflect: the normalized position held while the key is
/// down. Rate: normalized units per second, signed.</param>
public sealed record KeyAxisBinding(string Key, RioAxis Axis, KeyAxisMode Mode, float Value);
/// <summary>A gamepad button pressing a RIO input address.</summary>
public sealed record PadButtonBinding(PadButtons Button, int Address, bool Toggle);
/// <summary>
/// A gamepad analog control driving a RIO axis. With <paramref name="Rate"/>
/// zero the (deadzoned, optionally inverted) pad value maps directly to the
/// normalized axis position; with a positive rate the pad value is a speed —
/// the axis integrates by value × rate per second and holds (throttle-style).
/// </summary>
public sealed record PadAxisBinding(PadAxis Source, RioAxis Axis, bool Invert, float Deadzone, float Rate);
/// <summary>An immutable set of input bindings (one parsed profile file).</summary>
public sealed class BindingProfile
{
public static readonly BindingProfile Empty = new(
Array.Empty<KeyButtonBinding>(), Array.Empty<KeyAxisBinding>(),
Array.Empty<PadButtonBinding>(), Array.Empty<PadAxisBinding>());
public BindingProfile(
IReadOnlyList<KeyButtonBinding> keyButtons,
IReadOnlyList<KeyAxisBinding> keyAxes,
IReadOnlyList<PadButtonBinding> padButtons,
IReadOnlyList<PadAxisBinding> padAxes)
{
KeyButtons = keyButtons;
KeyAxes = keyAxes;
PadButtons = padButtons;
PadAxes = padAxes;
}
public IReadOnlyList<KeyButtonBinding> KeyButtons { get; }
public IReadOnlyList<KeyAxisBinding> KeyAxes { get; }
public IReadOnlyList<PadButtonBinding> PadButtons { get; }
public IReadOnlyList<PadAxisBinding> PadAxes { get; }
public int Count => KeyButtons.Count + KeyAxes.Count + PadButtons.Count + PadAxes.Count;
}
+316
View File
@@ -0,0 +1,316 @@
using VRio.Core.Device;
namespace VRio.Core.Input;
/// <summary>
/// Routes keyboard and gamepad input into a <see cref="VRioDevice"/> through
/// a <see cref="BindingProfile"/>. The host feeds key edges
/// (<see cref="KeyDown"/>/<see cref="KeyUp"/> — auto-repeat is suppressed
/// here), gamepad snapshots (<see cref="SetPadState"/>), and a steady
/// <see cref="Tick"/> for the time-based axis modes.
///
/// <para>Axes are composed in normalized units (1 = full realistic travel,
/// <see cref="RioAxisRange.Full"/> supplies the wire sign): rate integrators
/// (throttle-style, position holds) + deflect keys currently held + direct
/// pad positions, clamped to the axis' travel window. The device is only
/// written when the composed value changes, so mouse drags on the panel keep
/// working while a source is idle.</para>
///
/// <para>Buttons keep a hold count per RIO address, so a key and a pad button
/// bound to the same control overlap cleanly: the press goes on the wire at
/// 0→1 and the release at 1→0. <see cref="AddressHeldChanged"/> mirrors those
/// edges for the UI. Not thread-safe — call from one thread (the UI's).</para>
/// </summary>
public sealed class InputRouter
{
private static readonly RioAxis[] Axes = (RioAxis[])Enum.GetValues(typeof(RioAxis));
private readonly VRioDevice _device;
private BindingProfile _profile = BindingProfile.Empty;
private readonly Dictionary<string, List<KeyButtonBinding>> _keyButtons = new(StringComparer.OrdinalIgnoreCase);
private readonly Dictionary<string, List<KeyAxisBinding>> _keyAxes = new(StringComparer.OrdinalIgnoreCase);
private readonly List<PadAxisBinding>[] _padAxesByAxis;
private readonly List<KeyAxisBinding>[] _deflectByAxis;
private readonly HashSet<string> _heldKeys = new(StringComparer.OrdinalIgnoreCase);
private readonly Dictionary<int, int> _holdCounts = new();
private readonly HashSet<int> _toggled = new();
private readonly float[] _rate = new float[Axes.Length]; // normalized integrators
private readonly short?[] _lastSent = new short?[Axes.Length];
private PadState _pad;
private PadButtons _prevPadButtons;
public InputRouter(VRioDevice device)
{
_device = device ?? throw new ArgumentNullException(nameof(device));
_padAxesByAxis = new List<PadAxisBinding>[Axes.Length];
_deflectByAxis = new List<KeyAxisBinding>[Axes.Length];
for (int i = 0; i < Axes.Length; i++)
{
_padAxesByAxis[i] = new List<PadAxisBinding>();
_deflectByAxis[i] = new List<KeyAxisBinding>();
}
}
/// <summary>A routed address went down (true) or came back up (false) — for panel highlighting.</summary>
public event Action<int, bool>? AddressHeldChanged;
/// <summary>
/// The active bindings. Assigning releases everything currently held
/// (keys, toggles, pad buttons) so a reload never strands a pressed input.
/// </summary>
public BindingProfile Profile
{
get => _profile;
set
{
ReleaseEverything();
_profile = value ?? BindingProfile.Empty;
_keyButtons.Clear();
_keyAxes.Clear();
foreach (KeyButtonBinding b in _profile.KeyButtons)
Bucket(_keyButtons, b.Key).Add(b);
foreach (KeyAxisBinding b in _profile.KeyAxes)
Bucket(_keyAxes, b.Key).Add(b);
for (int i = 0; i < Axes.Length; i++)
{
_padAxesByAxis[i].Clear();
_deflectByAxis[i].Clear();
}
foreach (PadAxisBinding b in _profile.PadAxes)
_padAxesByAxis[(int)b.Axis].Add(b);
foreach (KeyAxisBinding b in _profile.KeyAxes)
if (b.Mode == KeyAxisMode.Deflect)
_deflectByAxis[(int)b.Axis].Add(b);
ResetAxisState();
}
}
private static List<T> Bucket<T>(Dictionary<string, List<T>> map, string key)
{
if (!map.TryGetValue(key, out List<T>? list))
map[key] = list = new List<T>();
return list;
}
/// <summary>True if any binding uses this key (the host swallows only bound keys).</summary>
public bool HasKeyBinding(string key) => _keyButtons.ContainsKey(key) || _keyAxes.ContainsKey(key);
// ---- Keyboard -----------------------------------------------------------
/// <summary>A key went down. Repeats while held are ignored.</summary>
public void KeyDown(string key)
{
if (!_heldKeys.Add(key))
return;
if (!_keyButtons.TryGetValue(key, out List<KeyButtonBinding>? bindings))
return;
foreach (KeyButtonBinding b in bindings)
{
if (b.Toggle)
ToggleAddress(b.Address);
else
IncHold(b.Address);
}
}
/// <summary>A key came back up.</summary>
public void KeyUp(string key)
{
if (!_heldKeys.Remove(key))
return;
if (!_keyButtons.TryGetValue(key, out List<KeyButtonBinding>? bindings))
return;
foreach (KeyButtonBinding b in bindings)
{
if (!b.Toggle)
DecHold(b.Address);
}
}
/// <summary>
/// Release every held key (focus loss, keyboard input turned off).
/// Toggle latches survive, like the panel's right-click latches.
/// </summary>
public void ReleaseAllKeys()
{
foreach (string key in _heldKeys.ToArray())
KeyUp(key);
}
// ---- Gamepad ------------------------------------------------------------
/// <summary>
/// Feed the latest gamepad snapshot; button edges fire immediately, axes
/// are picked up by the next <see cref="Tick"/>. Feed <c>default</c> when
/// the pad disconnects or is disabled so everything it held releases.
/// </summary>
public void SetPadState(PadState state)
{
PadButtons changed = state.Buttons ^ _prevPadButtons;
if (changed != PadButtons.None)
{
foreach (PadButtonBinding b in _profile.PadButtons)
{
if ((changed & b.Button) == 0)
continue;
bool down = (state.Buttons & b.Button) != 0;
if (b.Toggle)
{
if (down)
ToggleAddress(b.Address);
}
else if (down)
{
IncHold(b.Address);
}
else
{
DecHold(b.Address);
}
}
}
_prevPadButtons = state.Buttons;
_pad = state;
}
// ---- Time base ----------------------------------------------------------
/// <summary>
/// Advance rate integrators by <paramref name="dtSeconds"/> and push the
/// composed axis values to the device (only where they changed).
/// </summary>
public void Tick(double dtSeconds)
{
// Pass 1: advance the rate integrators (held rate keys, rate-mode pad axes).
foreach (KeyAxisBinding b in _profile.KeyAxes)
{
if (b.Mode == KeyAxisMode.Rate && _heldKeys.Contains(b.Key))
AddRate(b.Axis, (float)(b.Value * dtSeconds));
}
foreach (PadAxisBinding b in _profile.PadAxes)
{
if (b.Rate > 0f)
AddRate(b.Axis, (float)(Shape(_pad.Axis(b.Source), b) * b.Rate * dtSeconds));
}
// Pass 2: compose each axis and write it if it moved.
for (int i = 0; i < Axes.Length; i++)
{
RioAxis axis = Axes[i];
float total = _rate[i];
foreach (KeyAxisBinding b in _deflectByAxis[i])
{
if (_heldKeys.Contains(b.Key))
total += b.Value;
}
foreach (PadAxisBinding b in _padAxesByAxis[i])
{
if (b.Rate <= 0f)
total += Shape(_pad.Axis(b.Source), b);
}
total = ClampNorm(axis, total);
short raw = (short)Math.Round(total * RioAxisRange.Full(axis));
short prev = _lastSent[i] ?? _device.GetAxis(axis);
if (raw != prev)
_device.SetAxis(axis, raw);
_lastSent[i] = raw;
}
}
/// <summary>
/// Forget integrated/last-sent axis state — call after the axes were
/// re-zeroed behind the router's back (center button, host ResetRequest)
/// so a rate-driven throttle doesn't resume from its old position.
/// </summary>
public void ResetAxisState()
{
Array.Clear(_rate, 0, _rate.Length);
for (int i = 0; i < _lastSent.Length; i++)
_lastSent[i] = null;
}
// ---- Internals ----------------------------------------------------------
private void AddRate(RioAxis axis, float delta) =>
_rate[(int)axis] = ClampNorm(axis, _rate[(int)axis] + delta);
/// <summary>Deadzone (rescaled so travel stays continuous) + inversion.</summary>
private static float Shape(float value, PadAxisBinding b)
{
float v = value;
if (b.Deadzone > 0f)
{
float mag = Math.Abs(v);
v = mag <= b.Deadzone ? 0f : Math.Sign(v) * (mag - b.Deadzone) / (1f - b.Deadzone);
}
return b.Invert ? -v : v;
}
/// <summary>Stick axes are bipolar (±1), throttle/pedals unipolar (0..1 of travel).</summary>
private static float ClampNorm(RioAxis axis, float value)
{
float min = axis is RioAxis.JoystickX or RioAxis.JoystickY ? -1f : 0f;
return Math.Max(min, Math.Min(1f, value));
}
private void ToggleAddress(int address)
{
if (_toggled.Remove(address))
DecHold(address);
else
{
_toggled.Add(address);
IncHold(address);
}
}
private void IncHold(int address)
{
_holdCounts.TryGetValue(address, out int count);
_holdCounts[address] = count + 1;
if (count == 0)
{
_device.PressAddress(address);
AddressHeldChanged?.Invoke(address, true);
}
}
private void DecHold(int address)
{
if (!_holdCounts.TryGetValue(address, out int count))
return;
if (count <= 1)
{
_holdCounts.Remove(address);
_device.ReleaseAddress(address);
AddressHeldChanged?.Invoke(address, false);
}
else
{
_holdCounts[address] = count - 1;
}
}
private void ReleaseEverything()
{
_heldKeys.Clear();
_toggled.Clear();
_prevPadButtons = PadButtons.None;
_pad = default;
foreach (int address in _holdCounts.Keys.ToArray())
{
_holdCounts.Remove(address);
_device.ReleaseAddress(address);
AddressHeldChanged?.Invoke(address, false);
}
}
}
+76
View File
@@ -0,0 +1,76 @@
namespace VRio.Core.Input;
/// <summary>
/// Gamepad button flags, bit-for-bit the XInput <c>wButtons</c> mask so the
/// App-side poller can cast the native value straight through.
/// </summary>
[Flags]
public enum PadButtons : ushort
{
None = 0,
DPadUp = 0x0001,
DPadDown = 0x0002,
DPadLeft = 0x0004,
DPadRight = 0x0008,
Start = 0x0010,
Back = 0x0020,
LeftThumb = 0x0040,
RightThumb = 0x0080,
LeftShoulder = 0x0100,
RightShoulder = 0x0200,
A = 0x1000,
B = 0x2000,
X = 0x4000,
Y = 0x8000,
}
/// <summary>A gamepad analog control usable as a binding source.</summary>
public enum PadAxis
{
LeftStickX,
LeftStickY,
RightStickX,
RightStickY,
LeftTrigger,
RightTrigger,
}
/// <summary>
/// One normalized gamepad snapshot: sticks 1..+1 (up/right positive),
/// triggers 0..1. The default value is "pad at rest / no pad connected".
/// </summary>
public readonly struct PadState
{
public PadState(PadButtons buttons,
float leftStickX, float leftStickY, float rightStickX, float rightStickY,
float leftTrigger, float rightTrigger)
{
Buttons = buttons;
LeftStickX = leftStickX;
LeftStickY = leftStickY;
RightStickX = rightStickX;
RightStickY = rightStickY;
LeftTrigger = leftTrigger;
RightTrigger = rightTrigger;
}
public PadButtons Buttons { get; }
public float LeftStickX { get; }
public float LeftStickY { get; }
public float RightStickX { get; }
public float RightStickY { get; }
public float LeftTrigger { get; }
public float RightTrigger { get; }
/// <summary>Value of one analog control.</summary>
public float Axis(PadAxis axis) => axis switch
{
PadAxis.LeftStickX => LeftStickX,
PadAxis.LeftStickY => LeftStickY,
PadAxis.RightStickX => RightStickX,
PadAxis.RightStickY => RightStickY,
PadAxis.LeftTrigger => LeftTrigger,
PadAxis.RightTrigger => RightTrigger,
_ => 0f,
};
}
+296
View File
@@ -0,0 +1,296 @@
using VRio.Core.Device;
using VRio.Core.Input;
using VRio.Core.Protocol;
using Xunit;
namespace VRio.Core.Tests;
public class BindingProfileFormatTests
{
[Fact]
public void Default_profile_parses_clean()
{
BindingProfile profile = BindingProfileFormat.Parse(BindingProfileFormat.DefaultText, out var errors);
Assert.Empty(errors);
Assert.True(profile.KeyButtons.Count >= 19); // named buttons + numpad digits
Assert.Equal(8, profile.KeyAxes.Count); // arrows + W/S + Q/E
Assert.Equal(10, profile.PadButtons.Count);
Assert.Equal(5, profile.PadAxes.Count);
}
[Fact]
public void Lines_parse_into_typed_bindings()
{
BindingProfile profile = BindingProfileFormat.Parse(
"key SPACE button 0x40 toggle # comment\n" +
"key w axis throttle rate -0.5\n" +
"pad dpadup button 81\n" +
"padaxis lefttrigger axis LeftPedal invert deadzone 0.25 rate 2\n",
out var errors);
Assert.Empty(errors);
KeyButtonBinding kb = Assert.Single(profile.KeyButtons);
Assert.Equal(("SPACE", 0x40, true), (kb.Key, kb.Address, kb.Toggle));
KeyAxisBinding ka = Assert.Single(profile.KeyAxes);
Assert.Equal((RioAxis.Throttle, KeyAxisMode.Rate, -0.5f), (ka.Axis, ka.Mode, ka.Value));
PadButtonBinding pb = Assert.Single(profile.PadButtons);
Assert.Equal((PadButtons.DPadUp, 81), (pb.Button, pb.Address)); // decimal keypad address
PadAxisBinding pa = Assert.Single(profile.PadAxes);
Assert.Equal((PadAxis.LeftTrigger, RioAxis.LeftPedal, true, 0.25f, 2f),
(pa.Source, pa.Axis, pa.Invert, pa.Deadzone, pa.Rate));
}
[Theory]
[InlineData("key W button 0x48")] // hole between buttons and keypads
[InlineData("key W button zz")]
[InlineData("key W axis Throttle deflect")] // missing value
[InlineData("key W axis Throttle wiggle 1")] // unknown mode
[InlineData("pad Guide button 0x00")] // unknown pad button
[InlineData("padaxis LeftStickX axis JoystickX sideways")]
[InlineData("mouse X button 0x00")] // unknown source
public void Bad_lines_are_reported_and_skipped(string line)
{
BindingProfile profile = BindingProfileFormat.Parse("key A button 0x00\n" + line, out var errors);
string error = Assert.Single(errors);
Assert.StartsWith("line 2:", error);
Assert.Equal(1, profile.Count); // the good line survived
}
}
public class InputRouterTests
{
private readonly VRioDevice _device = new();
private readonly InputRouter _router;
private readonly List<byte[]> _tx = new();
public InputRouterTests()
{
_router = new InputRouter(_device);
_device.Transmit += _tx.Add;
}
private void UseProfile(string text)
{
_router.Profile = BindingProfileFormat.Parse(text, out var errors);
Assert.Empty(errors);
_tx.Clear();
}
// ---- Buttons ------------------------------------------------------------
[Fact]
public void Key_press_and_release_hit_the_wire_once_despite_repeats()
{
UseProfile("key Space button 0x40");
_router.KeyDown("Space");
_router.KeyDown("Space"); // auto-repeat
_router.KeyDown("Space");
_router.KeyUp("Space");
Assert.Equal(new[] { PacketBuilder.ButtonPressed(0x40), PacketBuilder.ButtonReleased(0x40) }, _tx);
}
[Fact]
public void Keypad_addresses_go_out_as_key_events()
{
UseProfile("key NumPad7 button 0x67"); // external keypad, key 7
_router.KeyDown("NumPad7");
_router.KeyUp("NumPad7");
Assert.Equal(new[] { PacketBuilder.KeyPressed(1, 7), PacketBuilder.KeyReleased(1, 7) }, _tx);
}
[Fact]
public void Toggle_latches_across_key_up()
{
UseProfile("key T button 0x12 toggle");
_router.KeyDown("T");
_router.KeyUp("T");
Assert.Equal(new[] { PacketBuilder.ButtonPressed(0x12) }, _tx);
_router.KeyDown("T"); // second press releases the latch
_router.KeyUp("T");
Assert.Equal(PacketBuilder.ButtonReleased(0x12), _tx[1]);
}
[Fact]
public void Overlapping_sources_on_one_address_press_once_release_last()
{
UseProfile("key Space button 0x40\npad A button 0x40");
var held = new List<(int Address, bool Held)>();
_router.AddressHeldChanged += (a, h) => held.Add((a, h));
_router.KeyDown("Space");
_router.SetPadState(new PadState(PadButtons.A, 0, 0, 0, 0, 0, 0));
_router.KeyUp("Space");
Assert.Equal(new[] { PacketBuilder.ButtonPressed(0x40) }, _tx); // still held by the pad
_router.SetPadState(default);
Assert.Equal(PacketBuilder.ButtonReleased(0x40), _tx[1]);
Assert.Equal(new[] { (0x40, true), (0x40, false) }, held);
}
[Fact]
public void Pad_button_edges_only_fire_on_change()
{
UseProfile("pad B button 0x3D");
var down = new PadState(PadButtons.B, 0, 0, 0, 0, 0, 0);
_router.SetPadState(down);
_router.SetPadState(down); // unchanged snapshot
_router.SetPadState(default);
Assert.Equal(new[] { PacketBuilder.ButtonPressed(0x3D), PacketBuilder.ButtonReleased(0x3D) }, _tx);
}
[Fact]
public void ReleaseAllKeys_releases_momentaries_but_keeps_toggles()
{
UseProfile("key A button 0x01\nkey B button 0x02 toggle");
_router.KeyDown("A");
_router.KeyDown("B");
_router.ReleaseAllKeys();
Assert.Equal(0, _device.GetLamp(0)); // sanity: device untouched otherwise
Assert.Equal(new[]
{
PacketBuilder.ButtonPressed(0x01),
PacketBuilder.ButtonPressed(0x02),
PacketBuilder.ButtonReleased(0x01), // toggle at 0x02 stays latched
}, _tx);
}
// ---- Axes ---------------------------------------------------------------
[Fact]
public void Deflect_key_holds_full_travel_and_springs_back()
{
UseProfile("key Up axis JoystickY deflect 1\nkey Down axis JoystickY deflect -1");
_router.KeyDown("Up");
_router.Tick(0.016);
Assert.Equal(RioAxisRange.JoystickExtent, _device.GetAxis(RioAxis.JoystickY));
_router.KeyDown("Down"); // opposite deflects cancel
_router.Tick(0.016);
Assert.Equal(0, _device.GetAxis(RioAxis.JoystickY));
_router.KeyUp("Up");
_router.KeyUp("Down");
_router.Tick(0.016);
Assert.Equal(0, _device.GetAxis(RioAxis.JoystickY));
}
[Fact]
public void Rate_key_walks_the_throttle_and_position_sticks()
{
UseProfile("key W axis Throttle rate 0.5");
_router.KeyDown("W");
_router.Tick(1.0); // half travel
Assert.Equal(RioAxisRange.ThrottleFull / 2, _device.GetAxis(RioAxis.Throttle));
_router.KeyUp("W");
_router.Tick(1.0); // released: stays put
Assert.Equal(RioAxisRange.ThrottleFull / 2, _device.GetAxis(RioAxis.Throttle));
_router.KeyDown("W");
_router.Tick(10.0); // clamps at full realistic travel
Assert.Equal(RioAxisRange.ThrottleFull, _device.GetAxis(RioAxis.Throttle));
}
[Fact]
public void Pad_stick_maps_through_deadzone_and_invert()
{
UseProfile("padaxis LeftStickX axis JoystickX invert deadzone 0.2");
// Inside the deadzone: centered.
_router.SetPadState(new PadState(PadButtons.None, 0.1f, 0, 0, 0, 0, 0));
_router.Tick(0.016);
Assert.Equal(0, _device.GetAxis(RioAxis.JoystickX));
// Full right, inverted → raw negative full extent (the wire direction).
_router.SetPadState(new PadState(PadButtons.None, 1f, 0, 0, 0, 0, 0));
_router.Tick(0.016);
Assert.Equal(-RioAxisRange.JoystickExtent, _device.GetAxis(RioAxis.JoystickX));
// Pad gone → springs back.
_router.SetPadState(default);
_router.Tick(0.016);
Assert.Equal(0, _device.GetAxis(RioAxis.JoystickX));
}
[Fact]
public void Pad_trigger_drives_pedal_to_full_press()
{
UseProfile("padaxis RightTrigger axis RightPedal");
_router.SetPadState(new PadState(PadButtons.None, 0, 0, 0, 0, 0, 1f));
_router.Tick(0.016);
Assert.Equal(RioAxisRange.PedalFull, _device.GetAxis(RioAxis.RightPedal));
}
[Fact]
public void Rate_mode_pad_axis_integrates_and_holds()
{
UseProfile("padaxis RightStickY axis Throttle rate 0.5");
_router.SetPadState(new PadState(PadButtons.None, 0, 0, 0, 1f, 0, 0));
_router.Tick(1.0);
Assert.Equal(RioAxisRange.ThrottleFull / 2, _device.GetAxis(RioAxis.Throttle));
_router.SetPadState(default); // stick released: throttle stays
_router.Tick(1.0);
Assert.Equal(RioAxisRange.ThrottleFull / 2, _device.GetAxis(RioAxis.Throttle));
}
[Fact]
public void ResetAxisState_forgets_integrated_position()
{
UseProfile("key W axis Throttle rate 1");
_router.KeyDown("W");
_router.Tick(0.5);
_router.KeyUp("W");
Assert.NotEqual(0, _device.GetAxis(RioAxis.Throttle));
// Emulate the center button / host reset combination.
_device.SetAxis(RioAxis.Throttle, 0);
_router.ResetAxisState();
_router.Tick(0.016);
Assert.Equal(0, _device.GetAxis(RioAxis.Throttle));
}
[Fact]
public void Idle_router_does_not_fight_other_axis_writers()
{
UseProfile("padaxis LeftStickX axis JoystickX");
_router.Tick(0.016);
_device.SetAxis(RioAxis.JoystickX, 42); // e.g. a mouse drag on the panel
_router.Tick(0.016); // router value unchanged → no clobber
Assert.Equal(42, _device.GetAxis(RioAxis.JoystickX));
}
[Fact]
public void Profile_swap_releases_held_inputs()
{
UseProfile("key A button 0x05");
_router.KeyDown("A");
_router.Profile = BindingProfile.Empty;
Assert.Equal(new[] { PacketBuilder.ButtonPressed(0x05), PacketBuilder.ButtonReleased(0x05) }, _tx);
}
}