Compare commits
6
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1e79711181 | ||
|
|
dc9a294101 | ||
|
|
1eded793af | ||
|
|
44dc8e48e7 | ||
|
|
d26000f906 | ||
|
|
e590b89c47 |
@@ -23,6 +23,20 @@ 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. The default profile makes the controller
|
||||||
|
mandatory: all five axes live on the pad (left stick / triggers / right
|
||||||
|
stick = stick / pedals / throttle) and the keyboard covers the button
|
||||||
|
field — number row + QWERTY row = the upper MFD bank, home + bottom
|
||||||
|
rows = the lower MFD bank (4-key blocks split by an unbound gap key),
|
||||||
|
F1–F6 / F7–F12 = the Secondary / Screen columns, numpad = internal
|
||||||
|
keypad (hex keys on the operators), arrows + Space = hat + main, with
|
||||||
|
ABXY / dpad / shoulders on the pad's named buttons.
|
||||||
|
|
||||||
## Wire behavior
|
## Wire behavior
|
||||||
|
|
||||||
@@ -32,9 +46,17 @@ device behavior grounded in the **real v4.2 firmware dump**
|
|||||||
(`riojoy/rio-firmware/RIOv4_2-ANALYSIS.md`):
|
(`riojoy/rio-firmware/RIOv4_2-ANALYSIS.md`):
|
||||||
|
|
||||||
- ACKs every well-formed packet; NAKs bad-checksum packets.
|
- ACKs every well-formed packet; NAKs bad-checksum packets.
|
||||||
- `CheckRequest` → one `BoardOk` CheckReply per board (the 11 boards from the
|
- **TX is paced at the wire rate** — one byte per 10-bit frame (~1.04 ms at
|
||||||
legacy firmware's table). `VersionRequest` → configurable version,
|
9600 8N1), never closer. A virtual null-modem has no UART, so unpaced
|
||||||
default **4.2**.
|
writes would land at the host in microsecond bursts no real board could
|
||||||
|
produce; vRIO's writer thread schedules each byte against a monotonic
|
||||||
|
slot deadline instead, so e.g. the 51-byte CheckRequest response takes
|
||||||
|
the same ~53 ms it takes real hardware.
|
||||||
|
- `CheckRequest` → the real board's init handshake: `TestModeChange` **enter**,
|
||||||
|
one `BoardOk` CheckReply per board (the 11 boards from the legacy firmware's
|
||||||
|
table), then `TestModeChange` **exit**. Hosts wait (≤5 s per step) on both
|
||||||
|
test-mode packets and send nothing while test mode is active, so the exit is
|
||||||
|
mandatory. `VersionRequest` → configurable version, default **4.2**.
|
||||||
- `ResetRequest` re-zeroes the targeted axis (or all).
|
- `ResetRequest` re-zeroes the targeted axis (or all).
|
||||||
- A NAK re-sends the last event up to **4 times**, then gives up with a
|
- A NAK re-sends the last event up to **4 times**, then gives up with a
|
||||||
RESTART byte — the real board's retry budget.
|
RESTART byte — the real board's retry budget.
|
||||||
|
|||||||
+160
-17
@@ -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()
|
||||||
{
|
{
|
||||||
@@ -40,34 +45,40 @@ internal sealed class MainForm : Form
|
|||||||
private readonly Button _lampsOff = new() { Text = "All lamps off", Location = new Point(156, 82), Width = 140, Height = 26 };
|
private readonly Button _lampsOff = new() { Text = "All lamps off", Location = new Point(156, 82), Width = 140, Height = 26 };
|
||||||
private readonly Button _testEnter = new() { Text = "Enter test mode", Location = new Point(10, 114), Width = 140, Height = 26 };
|
private readonly Button _testEnter = new() { Text = "Enter test mode", Location = new Point(10, 114), Width = 140, Height = 26 };
|
||||||
private readonly Button _testExit = new() { Text = "Exit test mode", Location = new Point(156, 114), Width = 140, Height = 26 };
|
private readonly Button _testExit = new() { Text = "Exit test mode", Location = new Point(156, 114), Width = 140, Height = 26 };
|
||||||
private readonly CheckBox _wedgeBug = new()
|
|
||||||
|
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 = "Emulate the v4.2 reply-wedge bug",
|
Text = "No controller detected.",
|
||||||
Location = new Point(10, 148),
|
Location = new Point(10, 46),
|
||||||
AutoSize = true,
|
AutoSize = true,
|
||||||
|
ForeColor = Color.Gray,
|
||||||
};
|
};
|
||||||
private readonly Button _wedgeNow = new() { Text = "Wedge analog now", Location = new Point(10, 172), Width = 140, Height = 26 };
|
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, 336),
|
||||||
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, 392),
|
||||||
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, 476),
|
||||||
Multiline = true,
|
Multiline = true,
|
||||||
ReadOnly = true,
|
ReadOnly = true,
|
||||||
ScrollBars = ScrollBars.Vertical,
|
ScrollBars = ScrollBars.Vertical,
|
||||||
@@ -85,7 +96,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 +108,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 +127,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 +150,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 += (_, _) =>
|
||||||
{
|
{
|
||||||
@@ -138,26 +159,33 @@ internal sealed class MainForm : Form
|
|||||||
};
|
};
|
||||||
_testEnter.Click += (_, _) => _device.SendTestMode(1);
|
_testEnter.Click += (_, _) => _device.SendTestMode(1);
|
||||||
_testExit.Click += (_, _) => _device.SendTestMode(0);
|
_testExit.Click += (_, _) => _device.SendTestMode(0);
|
||||||
_wedgeBug.CheckedChanged += (_, _) => _device.EmulateReplyWedge = _wedgeBug.Checked;
|
|
||||||
_wedgeNow.Click += (_, _) =>
|
|
||||||
{
|
|
||||||
_device.WedgeAnalogNow();
|
|
||||||
UpdateStatus();
|
|
||||||
};
|
|
||||||
_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()
|
||||||
@@ -181,17 +209,21 @@ internal sealed class MainForm : Form
|
|||||||
panel.Controls.Add(_openClose);
|
panel.Controls.Add(_openClose);
|
||||||
panel.Controls.Add(_linkStatus);
|
panel.Controls.Add(_linkStatus);
|
||||||
|
|
||||||
var device = new GroupBox { Text = "Device", Location = new Point(12, 68), Size = new Size(306, 210) };
|
var device = new GroupBox { Text = "Device", Location = new Point(12, 68), Size = new Size(306, 150) };
|
||||||
device.Controls.Add(new Label { Text = "Firmware:", Location = new Point(10, 27), AutoSize = true });
|
device.Controls.Add(new Label { Text = "Firmware:", Location = new Point(10, 27), AutoSize = true });
|
||||||
device.Controls.Add(_verMajor);
|
device.Controls.Add(_verMajor);
|
||||||
device.Controls.Add(new Label { Text = ".", Location = new Point(127, 27), AutoSize = true });
|
device.Controls.Add(new Label { Text = ".", Location = new Point(127, 27), AutoSize = true });
|
||||||
device.Controls.Add(_verMinor);
|
device.Controls.Add(_verMinor);
|
||||||
device.Controls.AddRange(new Control[] { _spring, _centerAxes, _lampsOff, _testEnter, _testExit, _wedgeBug, _wedgeNow });
|
device.Controls.AddRange(new Control[] { _spring, _centerAxes, _lampsOff, _testEnter, _testExit });
|
||||||
panel.Controls.Add(device);
|
panel.Controls.Add(device);
|
||||||
|
|
||||||
|
var input = new GroupBox { Text = "Input", Location = new Point(12, 224), 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, 458), 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 +284,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()
|
||||||
|
|||||||
@@ -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)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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 0–3 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -13,7 +13,9 @@ namespace VRio.Core.Device;
|
|||||||
/// <para>Wire behavior (mirroring what RIOJoy expects from the real board):</para>
|
/// <para>Wire behavior (mirroring what RIOJoy expects from the real board):</para>
|
||||||
/// <list type="bullet">
|
/// <list type="bullet">
|
||||||
/// <item>ACKs every well-formed inbound packet, NAKs one with a bad checksum.</item>
|
/// <item>ACKs every well-formed inbound packet, NAKs one with a bad checksum.</item>
|
||||||
/// <item>CheckRequest → a BoardOk CheckReply per known board.</item>
|
/// <item>CheckRequest → TestModeChange ENTER, a BoardOk CheckReply per known
|
||||||
|
/// board (the self-test stream), then TestModeChange EXIT — the init
|
||||||
|
/// handshake the real board performs and the game waits (≤5s per step) on.</item>
|
||||||
/// <item>VersionRequest → VersionReply with the configured firmware version
|
/// <item>VersionRequest → VersionReply with the configured firmware version
|
||||||
/// (default 4.2, matching the real board's dumped EPROM).</item>
|
/// (default 4.2, matching the real board's dumped EPROM).</item>
|
||||||
/// <item>AnalogRequest → AnalogReply with the current five axis values.</item>
|
/// <item>AnalogRequest → AnalogReply with the current five axis values.</item>
|
||||||
@@ -239,9 +241,14 @@ public sealed class VRioDevice
|
|||||||
switch (packet.Command)
|
switch (packet.Command)
|
||||||
{
|
{
|
||||||
case RioCommand.CheckRequest:
|
case RioCommand.CheckRequest:
|
||||||
Logged?.Invoke("RX CheckRequest → all boards OK");
|
// Init handshake (verified against a real v4.2 board tap): the
|
||||||
|
// host waits ≤5s for TestModeChange ENTER before anything else,
|
||||||
|
// and sends no requests at all until the matching EXIT arrives.
|
||||||
|
Logged?.Invoke("RX CheckRequest → test mode enter, all boards OK, exit");
|
||||||
|
Send(PacketBuilder.TestModeChange(1));
|
||||||
foreach ((byte number, string _) in RioAddressSpace.Boards)
|
foreach ((byte number, string _) in RioAddressSpace.Boards)
|
||||||
Send(PacketBuilder.CheckReply(RioStatusType.BoardOk, number));
|
Send(PacketBuilder.CheckReply(RioStatusType.BoardOk, number));
|
||||||
|
Send(PacketBuilder.TestModeChange(0));
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case RioCommand.VersionRequest:
|
case RioCommand.VersionRequest:
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
|
using System.Diagnostics;
|
||||||
using System.IO.Ports;
|
using System.IO.Ports;
|
||||||
|
using System.Runtime.InteropServices;
|
||||||
using VRio.Core.Protocol;
|
using VRio.Core.Protocol;
|
||||||
|
|
||||||
namespace VRio.Core.Device;
|
namespace VRio.Core.Device;
|
||||||
@@ -8,6 +10,20 @@ namespace VRio.Core.Device;
|
|||||||
/// 9600 8N1 settings. On a single PC, pair it with RIOJoy through a virtual
|
/// 9600 8N1 settings. On a single PC, pair it with RIOJoy through a virtual
|
||||||
/// null-modem (e.g. com0com): vRIO opens one end, RIOJoy the other.
|
/// null-modem (e.g. com0com): vRIO opens one end, RIOJoy the other.
|
||||||
///
|
///
|
||||||
|
/// <para>Outbound bytes are paced at the wire rate: one byte per 10-bit frame
|
||||||
|
/// time (~1.04 ms at 9600 8N1). A virtual null-modem has no UART, so an
|
||||||
|
/// unpaced multi-byte write lands at the host back-to-back in microseconds —
|
||||||
|
/// a burst no real board could produce, and a timing tell that has tripped up
|
||||||
|
/// hosts tuned to hardware. A writer thread schedules each byte against a
|
||||||
|
/// monotonic slot deadline (<c>slot = max(prevSlot + period, now)</c>), so
|
||||||
|
/// the stream averages the true baud rate without bursting after idle.</para>
|
||||||
|
///
|
||||||
|
/// <para>A write fault never kills the writer — a real UART streams into an
|
||||||
|
/// unterminated line rather than blocking. If the virtual wire's far side
|
||||||
|
/// stops draining (peer end closed, write timeout), the stalled byte and the
|
||||||
|
/// queued backlog are dropped and transmission resumes with the next fresh
|
||||||
|
/// packet once the host reads again.</para>
|
||||||
|
///
|
||||||
/// <para>RIOJoy pulses DTR for 50 ms when it opens its end (the board-reset
|
/// <para>RIOJoy pulses DTR for 50 ms when it opens its end (the board-reset
|
||||||
/// handshake); through a null modem that arrives here as a DSR blip, which is
|
/// handshake); through a null modem that arrives here as a DSR blip, which is
|
||||||
/// surfaced via <see cref="HostHandshake"/> so the UI can show that a host
|
/// surfaced via <see cref="HostHandshake"/> so the UI can show that a host
|
||||||
@@ -18,12 +34,22 @@ public sealed class VRioSerialService : IDisposable
|
|||||||
/// <summary>RIO link bit rate (must match RIOJoy's transport).</summary>
|
/// <summary>RIO link bit rate (must match RIOJoy's transport).</summary>
|
||||||
public const int BaudRate = 9600;
|
public const int BaudRate = 9600;
|
||||||
|
|
||||||
|
// One byte on the wire is 10 bits (start + 8 data + stop) at 9600 baud.
|
||||||
|
private static readonly long BytePeriodTicks = Stopwatch.Frequency * 10 / BaudRate;
|
||||||
|
|
||||||
|
// Below this remaining wait (~1.8 ms) Thread.Sleep(1) would overshoot the
|
||||||
|
// slot even at 1 ms timer resolution, so the pacer spins the remainder.
|
||||||
|
private static readonly long SpinThresholdTicks = Stopwatch.Frequency * 18 / 10_000;
|
||||||
|
|
||||||
private readonly VRioDevice _device;
|
private readonly VRioDevice _device;
|
||||||
private readonly object _writeGate = new();
|
private readonly object _txGate = new();
|
||||||
|
private readonly Queue<byte> _txQueue = new();
|
||||||
|
|
||||||
private SerialPort? _port;
|
private SerialPort? _port;
|
||||||
private Thread? _reader;
|
private Thread? _reader;
|
||||||
|
private Thread? _writer;
|
||||||
private volatile bool _running;
|
private volatile bool _running;
|
||||||
|
private bool _timerResolutionRaised;
|
||||||
|
|
||||||
public VRioSerialService(VRioDevice device)
|
public VRioSerialService(VRioDevice device)
|
||||||
{
|
{
|
||||||
@@ -74,10 +100,18 @@ public sealed class VRioSerialService : IDisposable
|
|||||||
|
|
||||||
_port = port;
|
_port = port;
|
||||||
_running = true;
|
_running = true;
|
||||||
|
lock (_txGate) _txQueue.Clear();
|
||||||
|
|
||||||
|
// 1 ms system timer resolution while the port is open, so the pacer's
|
||||||
|
// Thread.Sleep(1) actually sleeps ~1 ms instead of the 15.6 ms default.
|
||||||
|
_timerResolutionRaised = timeBeginPeriod(1) == 0;
|
||||||
|
|
||||||
_reader = new Thread(ReadLoop) { IsBackground = true, Name = "vRIO serial reader" };
|
_reader = new Thread(ReadLoop) { IsBackground = true, Name = "vRIO serial reader" };
|
||||||
_reader.Start();
|
_reader.Start();
|
||||||
|
_writer = new Thread(WriteLoop) { IsBackground = true, Name = "vRIO serial writer" };
|
||||||
|
_writer.Start();
|
||||||
|
|
||||||
Logged?.Invoke($"Opened {portName} @ {BaudRate} 8N1 — waiting for the host");
|
Logged?.Invoke($"Opened {portName} @ {BaudRate} 8N1 (TX paced at the wire rate) — waiting for the host");
|
||||||
ConnectionChanged?.Invoke(true);
|
ConnectionChanged?.Invoke(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -89,6 +123,7 @@ public sealed class VRioSerialService : IDisposable
|
|||||||
return;
|
return;
|
||||||
|
|
||||||
_running = false;
|
_running = false;
|
||||||
|
lock (_txGate) Monitor.PulseAll(_txGate); // wake the writer so it can exit
|
||||||
_port = null;
|
_port = null;
|
||||||
port.PinChanged -= OnPinChanged;
|
port.PinChanged -= OnPinChanged;
|
||||||
try { port.Close(); }
|
try { port.Close(); }
|
||||||
@@ -97,6 +132,15 @@ public sealed class VRioSerialService : IDisposable
|
|||||||
|
|
||||||
_reader?.Join(1000);
|
_reader?.Join(1000);
|
||||||
_reader = null;
|
_reader = null;
|
||||||
|
_writer?.Join(1000);
|
||||||
|
_writer = null;
|
||||||
|
lock (_txGate) _txQueue.Clear();
|
||||||
|
|
||||||
|
if (_timerResolutionRaised)
|
||||||
|
{
|
||||||
|
timeEndPeriod(1);
|
||||||
|
_timerResolutionRaised = false;
|
||||||
|
}
|
||||||
|
|
||||||
Logged?.Invoke("Port closed");
|
Logged?.Invoke("Port closed");
|
||||||
ConnectionChanged?.Invoke(false);
|
ConnectionChanged?.Invoke(false);
|
||||||
@@ -144,23 +188,109 @@ public sealed class VRioSerialService : IDisposable
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The device's Transmit handler: queue the frame for the paced writer so
|
||||||
|
// the caller (UI click, reader thread mid-reply) never blocks on the port.
|
||||||
private void Write(byte[] data)
|
private void Write(byte[] data)
|
||||||
{
|
{
|
||||||
SerialPort? port = _port;
|
if (!_running)
|
||||||
if (port is null || !port.IsOpen)
|
|
||||||
return; // device poked while offline — drop silently
|
return; // device poked while offline — drop silently
|
||||||
|
|
||||||
try
|
lock (_txGate)
|
||||||
{
|
{
|
||||||
lock (_writeGate)
|
foreach (byte b in data)
|
||||||
port.Write(data, 0, data.Length);
|
_txQueue.Enqueue(b);
|
||||||
}
|
Monitor.Pulse(_txGate);
|
||||||
catch (Exception ex) when (ex is IOException or InvalidOperationException or TimeoutException)
|
|
||||||
{
|
|
||||||
Logged?.Invoke($"Write failed: {ex.Message}");
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void WriteLoop()
|
||||||
|
{
|
||||||
|
var one = new byte[1];
|
||||||
|
long slot = Stopwatch.GetTimestamp();
|
||||||
|
bool txHealthy = true; // log stall/recovery transitions, not every byte
|
||||||
|
|
||||||
|
while (_running)
|
||||||
|
{
|
||||||
|
lock (_txGate)
|
||||||
|
{
|
||||||
|
while (_txQueue.Count == 0)
|
||||||
|
{
|
||||||
|
if (!_running)
|
||||||
|
return;
|
||||||
|
Monitor.Wait(_txGate, 200); // timed, so a missed pulse can't wedge shutdown
|
||||||
|
}
|
||||||
|
one[0] = _txQueue.Dequeue();
|
||||||
|
}
|
||||||
|
|
||||||
|
// This byte's wire slot: one frame after the previous byte, or now
|
||||||
|
// if the line has been idle (no burst "catch-up" debt).
|
||||||
|
slot = Math.Max(slot + BytePeriodTicks, Stopwatch.GetTimestamp());
|
||||||
|
PaceUntil(slot);
|
||||||
|
|
||||||
|
SerialPort? port = _port;
|
||||||
|
if (port is null)
|
||||||
|
return;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
port.Write(one, 0, 1);
|
||||||
|
if (!txHealthy)
|
||||||
|
{
|
||||||
|
txHealthy = true;
|
||||||
|
Logged?.Invoke("TX recovered — host is draining the wire again");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex) when (ex is IOException or InvalidOperationException or TimeoutException)
|
||||||
|
{
|
||||||
|
if (!_running)
|
||||||
|
return;
|
||||||
|
// A real UART cannot wedge: it shifts bits onto the line whether
|
||||||
|
// or not anyone is listening. A failed write means the virtual
|
||||||
|
// wire's far side stopped draining (peer end closed), so the
|
||||||
|
// queued backlog is already stale — drop it and keep serving
|
||||||
|
// fresh traffic; writes land again once the host reads its end.
|
||||||
|
int dropped;
|
||||||
|
lock (_txGate)
|
||||||
|
{
|
||||||
|
dropped = _txQueue.Count;
|
||||||
|
_txQueue.Clear();
|
||||||
|
}
|
||||||
|
if (txHealthy)
|
||||||
|
{
|
||||||
|
txHealthy = false;
|
||||||
|
Logged?.Invoke($"TX stalled ({ex.Message.TrimEnd('.')}) — dropped {dropped + 1} stale byte(s), writer stays alive");
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// If the wait overshot its slot, pace the next byte from the
|
||||||
|
// actual emission instead: a UART can never put two frames closer
|
||||||
|
// than the frame time, so a stall must not cause a catch-up burst.
|
||||||
|
long now = Stopwatch.GetTimestamp();
|
||||||
|
if (now > slot)
|
||||||
|
slot = now;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void PaceUntil(long slotTicks)
|
||||||
|
{
|
||||||
|
while (true)
|
||||||
|
{
|
||||||
|
long remaining = slotTicks - Stopwatch.GetTimestamp();
|
||||||
|
if (remaining <= 0)
|
||||||
|
return;
|
||||||
|
if (remaining > SpinThresholdTicks)
|
||||||
|
Thread.Sleep(1);
|
||||||
|
else
|
||||||
|
Thread.SpinWait(64);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[DllImport("winmm.dll")]
|
||||||
|
private static extern uint timeBeginPeriod(uint uMilliseconds);
|
||||||
|
|
||||||
|
[DllImport("winmm.dll")]
|
||||||
|
private static extern uint timeEndPeriod(uint uMilliseconds);
|
||||||
|
|
||||||
public void Dispose()
|
public void Dispose()
|
||||||
{
|
{
|
||||||
_device.Transmit -= Write;
|
_device.Transmit -= Write;
|
||||||
|
|||||||
@@ -0,0 +1,314 @@
|
|||||||
|
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 <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>]
|
||||||
|
/// </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), F1-F12,
|
||||||
|
# NumPad0-NumPad9, Up, Down, Left, Right, Space, Enter,
|
||||||
|
# OemMinus, Oemplus, Oemcomma, OemPeriod, ...
|
||||||
|
# <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 pad LeftStickX is inverted below.
|
||||||
|
|
||||||
|
# ---- Axes: controller only ----------------------------------------
|
||||||
|
# This profile drives all five axes from the Xbox controller - a pad
|
||||||
|
# is required. Keyboard axis bindings ('key <name> axis ...' with
|
||||||
|
# deflect or rate, per the grammar above) are still supported if you
|
||||||
|
# want them back.
|
||||||
|
|
||||||
|
# ---- Upper MFD bank: the number row is the top MFD row and the
|
||||||
|
# ---- QWERTY row is the row under it, left to right across the
|
||||||
|
# ---- Left / Middle / Right MFDs.
|
||||||
|
key D1 button 0x2F
|
||||||
|
key D2 button 0x2E
|
||||||
|
key D3 button 0x2D
|
||||||
|
key D4 button 0x2C
|
||||||
|
key D5 button 0x27
|
||||||
|
key D6 button 0x26
|
||||||
|
key D7 button 0x25
|
||||||
|
key D8 button 0x24
|
||||||
|
key D9 button 0x37
|
||||||
|
key D0 button 0x36
|
||||||
|
key OemMinus button 0x35
|
||||||
|
key Oemplus button 0x34
|
||||||
|
key Q button 0x2B
|
||||||
|
key W button 0x2A
|
||||||
|
key E button 0x29
|
||||||
|
key R button 0x28
|
||||||
|
key T button 0x23
|
||||||
|
key Y button 0x22
|
||||||
|
key U button 0x21
|
||||||
|
key I button 0x20
|
||||||
|
key O button 0x33
|
||||||
|
key P button 0x32
|
||||||
|
key OemOpenBrackets button 0x31
|
||||||
|
key OemCloseBrackets button 0x30
|
||||||
|
|
||||||
|
# ---- Lower MFD bank: home row and the row below it, two 4-key
|
||||||
|
# ---- blocks split by an unbound gap key (G / B), mirroring the
|
||||||
|
# ---- keypad gap between the Lower Left and Lower Right MFDs.
|
||||||
|
key A button 0x0F
|
||||||
|
key S button 0x0E
|
||||||
|
key D button 0x0D
|
||||||
|
key F button 0x0C
|
||||||
|
key H button 0x07
|
||||||
|
key J button 0x06
|
||||||
|
key K button 0x05
|
||||||
|
key L button 0x04
|
||||||
|
key Z button 0x0B
|
||||||
|
key X button 0x0A
|
||||||
|
key C button 0x09
|
||||||
|
key V button 0x08
|
||||||
|
key N button 0x03
|
||||||
|
key M button 0x02
|
||||||
|
key Oemcomma button 0x01
|
||||||
|
key OemPeriod button 0x00
|
||||||
|
|
||||||
|
# ---- Secondary / Screen columns on the function keys, top to
|
||||||
|
# ---- bottom (0x16/0x17 and 0x1E/0x1F intentionally unmapped).
|
||||||
|
key F1 button 0x10
|
||||||
|
key F2 button 0x11
|
||||||
|
key F3 button 0x12
|
||||||
|
key F4 button 0x13
|
||||||
|
key F5 button 0x14
|
||||||
|
key F6 button 0x15
|
||||||
|
key F7 button 0x18
|
||||||
|
key F8 button 0x19
|
||||||
|
key F9 button 0x1A
|
||||||
|
key F10 button 0x1B
|
||||||
|
key F11 button 0x1C
|
||||||
|
key F12 button 0x1D
|
||||||
|
|
||||||
|
# ---- Joystick column: hat on the arrows, Main on Space. Pinky /
|
||||||
|
# ---- Middle / Upper / Panic are pad-only (X / B / Y / LeftShoulder).
|
||||||
|
key Space button 0x40 # Main
|
||||||
|
key Up button 0x42 # Hat Up
|
||||||
|
key Down button 0x41 # Hat Back
|
||||||
|
key Left button 0x44 # Hat Left
|
||||||
|
key Right button 0x43 # Hat Right
|
||||||
|
|
||||||
|
# ---- Internal keypad on the numpad (hex keys on the operators) ----
|
||||||
|
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
|
||||||
|
key Divide button 0x5A # keypad A (/)
|
||||||
|
key Multiply button 0x5B # keypad B (*)
|
||||||
|
key Subtract button 0x5C # keypad C (-)
|
||||||
|
key Add button 0x5D # keypad D (+)
|
||||||
|
key Decimal button 0x5E # keypad E (.)
|
||||||
|
key Return button 0x5F # keypad F (Enter - the main Enter key too)
|
||||||
|
|
||||||
|
# ---- 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 0x46 # Middle
|
||||||
|
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 0x3D # Panic
|
||||||
|
pad RightShoulder button 0x3F # Throttle button
|
||||||
|
";
|
||||||
|
}
|
||||||
@@ -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 0x00–0x47 or keypad 0x50–0x6F).</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;
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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.Equal(73, profile.KeyButtons.Count); // 40 MFD + 12 columns + 16 keypad + Space + 4 hat
|
||||||
|
Assert.Empty(profile.KeyAxes); // axes are pad-only in the default profile
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -79,22 +79,35 @@ public class VRioDeviceTests
|
|||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void CheckRequest_reports_every_board_ok()
|
public void CheckRequest_enters_test_mode_reports_boards_then_exits()
|
||||||
{
|
{
|
||||||
var device = new VRioDevice();
|
var device = new VRioDevice();
|
||||||
var wire = new Wire(device);
|
var wire = new Wire(device);
|
||||||
|
|
||||||
Send(device, PacketBuilder.Build(RioCommand.CheckRequest));
|
Send(device, PacketBuilder.Build(RioCommand.CheckRequest));
|
||||||
|
|
||||||
Assert.Equal(RioAddressSpace.Boards.Count, wire.Packets.Count);
|
// The init handshake: TestModeChange ENTER, one CheckReply per board,
|
||||||
Assert.All(wire.Packets, p =>
|
// TestModeChange EXIT. The game waits on both test-mode packets and
|
||||||
|
// stays mute forever if the EXIT never arrives.
|
||||||
|
Assert.Equal(RioAddressSpace.Boards.Count + 2, wire.Packets.Count);
|
||||||
|
|
||||||
|
RioPacket enter = wire.Packets[0];
|
||||||
|
Assert.Equal(RioCommand.TestModeChange, enter.Command);
|
||||||
|
Assert.Equal(new byte[] { 1 }, enter.Payload);
|
||||||
|
|
||||||
|
RioPacket exit = wire.Packets[wire.Packets.Count - 1];
|
||||||
|
Assert.Equal(RioCommand.TestModeChange, exit.Command);
|
||||||
|
Assert.Equal(new byte[] { 0 }, exit.Payload);
|
||||||
|
|
||||||
|
var checks = wire.Packets.GetRange(1, RioAddressSpace.Boards.Count);
|
||||||
|
Assert.All(checks, p =>
|
||||||
{
|
{
|
||||||
Assert.Equal(RioCommand.CheckReply, p.Command);
|
Assert.Equal(RioCommand.CheckReply, p.Command);
|
||||||
Assert.Equal((byte)RioStatusType.BoardOk, p.Payload[0]);
|
Assert.Equal((byte)RioStatusType.BoardOk, p.Payload[0]);
|
||||||
});
|
});
|
||||||
Assert.Equal(
|
Assert.Equal(
|
||||||
RioAddressSpace.Boards.Select(b => b.Number),
|
RioAddressSpace.Boards.Select(b => b.Number),
|
||||||
wire.Packets.Select(p => p.Payload[1]));
|
checks.Select(p => p.Payload[1]));
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
|
|||||||
Reference in New Issue
Block a user