Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
767473d1cf | ||
|
|
007d15e668 | ||
|
|
391c53f294 | ||
|
|
bec3bb1e4a | ||
|
|
6923c9f252 | ||
|
|
23204ba9c6 | ||
|
|
97e78b0eea | ||
|
|
ce5ed1117a | ||
|
|
41f6ef364d | ||
|
|
f77cd55b11 | ||
|
|
31c3a910f0 | ||
|
|
1e79711181 | ||
|
|
dc9a294101 | ||
|
|
1eded793af | ||
|
|
44dc8e48e7 | ||
|
|
d26000f906 | ||
|
|
e590b89c47 |
@@ -0,0 +1,50 @@
|
||||
---
|
||||
name: verify
|
||||
description: Build, launch, and drive the vRIO WinForms app to verify changes at the GUI surface — screenshots via PrintWindow, input via PostMessage, no focus stealing.
|
||||
---
|
||||
|
||||
# Verifying vRIO changes
|
||||
|
||||
Build + tests (tests are CI's job; run the app for verification):
|
||||
|
||||
```powershell
|
||||
dotnet build vrio.sln -v q -nologo # Debug
|
||||
# exe: src\VRio.App\bin\Debug\net48\VRio.App.exe
|
||||
```
|
||||
|
||||
## Driving the GUI without touching the user's desktop
|
||||
|
||||
This is the user's live desktop — **never** use SetForegroundWindow +
|
||||
SendKeys/mouse_event: focus-stealing prevention leaves the app behind
|
||||
VS Code and your clicks land in the user's windows. Instead:
|
||||
|
||||
- **Screenshots**: `PrintWindow(hwnd, dc, 2)` (PW_RENDERFULLCONTENT) —
|
||||
captures the window even when occluded. CopyFromScreen captures
|
||||
whatever is on top; don't use it.
|
||||
- **Canvas input** (stick drags, cell clicks): PostMessage
|
||||
`WM_LBUTTONDOWN/WM_MOUSEMOVE/WM_LBUTTONUP` straight to the canvas
|
||||
child HWND. Find it by descending `RealChildWindowFromPoint` from the
|
||||
form. Canvas geometry is compile-time constant (PanelCanvas.cs):
|
||||
CellW=66, BoxXY = x 416..496, y 6..74 (X/Y stick box).
|
||||
The **first** posted drag after launch can half-land (only the
|
||||
button-down registers); do a throwaway drag first, then the measured
|
||||
one, screenshot **while held** (spring-back recenters on up).
|
||||
- **Checkboxes/buttons**: WinForms controls expose no UIA TogglePattern
|
||||
(they surface as bare Panes) and don't answer BM_GETCHECK — but
|
||||
`SendMessage(hwnd, BM_CLICK, 0, 0)` works. Get the HWND from the UIA
|
||||
element's NativeWindowHandle (find by Name).
|
||||
- Pattern that works: Add-Type user32 P/Invokes, Start-Process the exe,
|
||||
drive via messages, PrintWindow screenshot to the scratchpad, kill
|
||||
the process in `finally`.
|
||||
|
||||
## Environment gotchas
|
||||
|
||||
- Bindings load from `%APPDATA%\vRIO\bindings.txt` (the user's file),
|
||||
not the built-in defaults — arrow keys are bound to Hat *buttons*,
|
||||
not the joystick axis. Drive axes via the X/Y box.
|
||||
- A real XInput gamepad is connected on this machine ("Controller #1
|
||||
connected"); the router only writes an axis when its composed value
|
||||
changes, so a centered pad won't fight a canvas drag.
|
||||
- The wire readout (top center, green) shows `GetWireAxis` values —
|
||||
joystick Y is natively negated on the wire (stick up = −80) unless
|
||||
"Invert Y" is checked.
|
||||
@@ -23,6 +23,42 @@ controls:
|
||||
- Cells shade to the **lamp state the host commands** (`LampRequest`:
|
||||
off / dim / bright, with slow/med/fast flash), so RIOJoy's press-feedback
|
||||
lights the on-screen panel just like the real buttons.
|
||||
- **Lamps can mirror onto an RGB keyboard** (*Mirror lamps on RGB keyboard*,
|
||||
via Windows 11 Dynamic Lighting): keys bound to lamp-capable buttons glow
|
||||
with the panel's palette and blink in step with the on-screen flash modes;
|
||||
per-key keyboards get the full button field, zone-lit keyboards show the
|
||||
strongest current lamp board-wide, and a picker narrows the mirror to one
|
||||
keyboard when several are attached. For the LEDs to stay lit while the
|
||||
*game* has focus, vRIO needs package identity: run `pkg\Register-vRIO.ps1`
|
||||
once (Developer Mode required; pass the exe folder for a deployed copy),
|
||||
launch vRIO from its **Start menu entry** (a direct exe launch runs without
|
||||
identity), then drag vRIO to the top of *Settings → Personalization →
|
||||
Dynamic Lighting → Background light control*.
|
||||
- **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.
|
||||
- **Capture one keyboard in the background** (*Capture keyboard* picker in the
|
||||
Input panel): by default keyboard input follows the focused window, so vRIO
|
||||
goes deaf the moment the sim takes focus. Selecting a specific keyboard
|
||||
instead taps it through the Windows **Raw Input** API (`RIDEV_INPUTSINK`), so
|
||||
its keys drive the panel *while the game has focus* — the input-side twin of
|
||||
the lamp mirror writing that keyboard's LEDs under the same condition. Raw
|
||||
Input observes without intercepting: the keystroke still reaches the focused
|
||||
app, so this is meant for a keyboard *dedicated* to the panel, not the one you
|
||||
also type on. Leaving the picker on *All keyboards* keeps the plain
|
||||
focus-only behavior. (No package identity needed — unlike the lamp side, this
|
||||
is a plain Win32 tap.)
|
||||
|
||||
## Wire behavior
|
||||
|
||||
@@ -32,17 +68,24 @@ device behavior grounded in the **real v4.2 firmware dump**
|
||||
(`riojoy/rio-firmware/RIOv4_2-ANALYSIS.md`):
|
||||
|
||||
- ACKs every well-formed packet; NAKs bad-checksum packets.
|
||||
- `CheckRequest` → one `BoardOk` CheckReply per board (the 11 boards from the
|
||||
legacy firmware's table). `VersionRequest` → configurable version,
|
||||
default **4.2**.
|
||||
- **TX is paced at the wire rate** — one byte per 10-bit frame (~1.04 ms at
|
||||
9600 8N1), never closer. A virtual null-modem has no UART, so unpaced
|
||||
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).
|
||||
- A NAK re-sends the last event up to **4 times**, then gives up with a
|
||||
RESTART byte — the real board's retry budget.
|
||||
- Optional **v4.2 reply-wedge emulation**: after retry exhaustion (or the
|
||||
"Wedge analog now" button), analog requests are silently dropped — still
|
||||
ACK'd, RX path alive — until a host `ResetRequest`, reproducing the
|
||||
latch-leak fault the firmware analysis documents. Use it to exercise
|
||||
RIOJoy's 5-second no-analog recovery watchdog.
|
||||
- Optional **v4.2 reply-wedge emulation** (in `VRio.Core`; the UI toggles
|
||||
were removed): after retry exhaustion, analog requests are silently
|
||||
dropped — still ACK'd, RX path alive — until a host `ResetRequest`,
|
||||
reproducing the latch-leak fault the firmware analysis documents.
|
||||
|
||||
## Using it with RIOJoy on one PC
|
||||
|
||||
@@ -64,6 +107,7 @@ a null-modem cable work the same way.
|
||||
|------|----------|
|
||||
| `src/VRio.Core` | Protocol framing/builder/parser, the `VRioDevice` state machine, serial pump, panel layout data (class library) |
|
||||
| `src/VRio.App` | WinForms panel UI |
|
||||
| `pkg` | Sparse-package manifest + registration script: grants VRio.App.exe the package identity Dynamic Lighting's background-control list requires |
|
||||
| `tests/VRio.Core.Tests` | xUnit tests for the protocol + device engine |
|
||||
|
||||
## Building
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!--
|
||||
Sparse package ("package with external location"): grants the plain win32
|
||||
VRio.App.exe a package identity without changing how it is built or run.
|
||||
vRIO needs identity so Windows Dynamic Lighting can list it under
|
||||
Settings → Personalization → Dynamic Lighting → Background light control —
|
||||
without it the keyboard lamp mirror only works while vRIO has focus.
|
||||
|
||||
Register (Developer Mode, unsigned) with the exe's folder as the external
|
||||
location: see Register-vRIO.ps1 next to this file.
|
||||
-->
|
||||
<Package xmlns="http://schemas.microsoft.com/appx/manifest/foundation/windows10"
|
||||
xmlns:uap="http://schemas.microsoft.com/appx/manifest/uap/windows10"
|
||||
xmlns:uap3="http://schemas.microsoft.com/appx/manifest/uap/windows10/3"
|
||||
xmlns:uap10="http://schemas.microsoft.com/appx/manifest/uap/windows10/10"
|
||||
xmlns:rescap="http://schemas.microsoft.com/appx/manifest/foundation/windows10/restrictedcapabilities"
|
||||
IgnorableNamespaces="uap uap3 uap10 rescap">
|
||||
|
||||
<!-- Bump Version on any Assets/resources.pri change: the shell's MrtCache
|
||||
is keyed by package full name and serves stale (even failed) icon
|
||||
lookups for a re-registered same-version package. -->
|
||||
<Identity Name="VWE.vRIO"
|
||||
ProcessorArchitecture="neutral"
|
||||
Publisher="CN=VWE"
|
||||
Version="1.0.0.1" />
|
||||
|
||||
<Properties>
|
||||
<DisplayName>vRIO</DisplayName>
|
||||
<PublisherDisplayName>VWE</PublisherDisplayName>
|
||||
<Logo>Assets\logo150.png</Logo>
|
||||
<uap10:AllowExternalContent>true</uap10:AllowExternalContent>
|
||||
</Properties>
|
||||
|
||||
<Dependencies>
|
||||
<TargetDeviceFamily Name="Windows.Desktop" MinVersion="10.0.19041.0" MaxVersionTested="10.0.26200.0" />
|
||||
</Dependencies>
|
||||
|
||||
<Resources>
|
||||
<Resource Language="en-us" />
|
||||
</Resources>
|
||||
|
||||
<Capabilities>
|
||||
<rescap:Capability Name="runFullTrust" />
|
||||
<rescap:Capability Name="unvirtualizedResources" />
|
||||
</Capabilities>
|
||||
|
||||
<Applications>
|
||||
<Application Id="vRIO"
|
||||
Executable="VRio.App.exe"
|
||||
uap10:TrustLevel="mediumIL"
|
||||
uap10:RuntimeBehavior="win32App">
|
||||
<uap:VisualElements DisplayName="vRIO"
|
||||
Description="Virtual RIO cockpit device emulator"
|
||||
Square150x150Logo="Assets\logo150.png"
|
||||
Square44x44Logo="Assets\logo44.png"
|
||||
BackgroundColor="transparent" />
|
||||
<Extensions>
|
||||
<!-- Advertise as a lighting-controller app so Dynamic Lighting offers
|
||||
vRIO in Settings' "Background light control" picker. -->
|
||||
<uap3:Extension Category="windows.appExtension">
|
||||
<uap3:AppExtension Name="com.microsoft.windows.lighting"
|
||||
Id="vrio"
|
||||
DisplayName="vRIO"
|
||||
Description="vRIO cockpit lamp mirror"
|
||||
PublicFolder="Public" />
|
||||
</uap3:Extension>
|
||||
</Extensions>
|
||||
</Application>
|
||||
</Applications>
|
||||
</Package>
|
||||
|
After Width: | Height: | Size: 8.8 KiB |
|
After Width: | Height: | Size: 2.5 KiB |
|
After Width: | Height: | Size: 696 B |
|
After Width: | Height: | Size: 696 B |
|
After Width: | Height: | Size: 1.1 KiB |
|
After Width: | Height: | Size: 1.1 KiB |
|
After Width: | Height: | Size: 744 B |
|
After Width: | Height: | Size: 744 B |
|
After Width: | Height: | Size: 2.8 KiB |
|
After Width: | Height: | Size: 2.8 KiB |
@@ -0,0 +1,62 @@
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Regenerates pkg\Assets\*.png from src\VRio.App\vwe.ico and rebuilds
|
||||
resources.pri — run after changing the icon artwork.
|
||||
|
||||
The shell resolves the manifest's Square44x44Logo through the package
|
||||
resource index; without resources.pri every icon lookup fails and the
|
||||
Start menu / taskbar show a generic icon. After regenerating, bump the
|
||||
Version in AppxManifest.xml (the shell's MrtCache is keyed by package
|
||||
full name and serves stale lookups otherwise) and re-register with
|
||||
Register-vRIO.ps1.
|
||||
|
||||
.PARAMETER MakePri
|
||||
Path to makepri.exe. Auto-detected from the Windows SDK if installed;
|
||||
otherwise extract it from the Microsoft.Windows.SDK.BuildTools NuGet
|
||||
package (it is a zip: bin\<ver>\x64\makepri.exe).
|
||||
#>
|
||||
param([string]$MakePri)
|
||||
|
||||
$ErrorActionPreference = 'Stop'
|
||||
Add-Type -AssemblyName System.Drawing
|
||||
|
||||
if (-not $MakePri) {
|
||||
$MakePri = Get-ChildItem 'C:\Program Files (x86)\Windows Kits\10\bin' -Recurse -Filter makepri.exe -ErrorAction SilentlyContinue |
|
||||
Where-Object FullName -like '*\x64\*' | Select-Object -First 1 -ExpandProperty FullName
|
||||
if (-not $MakePri) {
|
||||
throw 'makepri.exe not found — install the Windows SDK or pass -MakePri (see help).'
|
||||
}
|
||||
}
|
||||
|
||||
$assets = Join-Path $PSScriptRoot 'Assets'
|
||||
$ico = New-Object System.Drawing.Icon((Join-Path $PSScriptRoot '..\src\VRio.App\vwe.ico'), 32, 32)
|
||||
$src = $ico.ToBitmap()
|
||||
|
||||
function Save-Png([int]$canvas, [int]$content, [string]$name) {
|
||||
$bmp = New-Object System.Drawing.Bitmap($canvas, $canvas)
|
||||
$g = [System.Drawing.Graphics]::FromImage($bmp)
|
||||
$g.InterpolationMode = [System.Drawing.Drawing2D.InterpolationMode]::HighQualityBicubic
|
||||
$g.PixelOffsetMode = [System.Drawing.Drawing2D.PixelOffsetMode]::HighQuality
|
||||
$off = [int](($canvas - $content) / 2)
|
||||
$g.DrawImage($src, $off, $off, $content, $content)
|
||||
$g.Dispose()
|
||||
$bmp.Save((Join-Path $assets $name), [System.Drawing.Imaging.ImageFormat]::Png)
|
||||
$bmp.Dispose()
|
||||
Write-Host " $name"
|
||||
}
|
||||
|
||||
Save-Png 44 44 'logo44.png'
|
||||
Save-Png 150 96 'logo150.png' # medium tile: 3x upscale centered, crisper than full-bleed
|
||||
foreach ($ts in 16, 24, 32, 48) {
|
||||
# targetsize / altform-unplated variants are found by naming convention;
|
||||
# unplated is what the Win11 taskbar prefers.
|
||||
Save-Png $ts $ts "logo44.targetsize-$ts.png"
|
||||
Save-Png $ts $ts "logo44.targetsize-${ts}_altform-unplated.png"
|
||||
}
|
||||
$src.Dispose(); $ico.Dispose()
|
||||
|
||||
$cf = Join-Path $env:TEMP 'vrio-priconfig.xml'
|
||||
& $MakePri createconfig /cf $cf /dq en-US /o | Out-Null
|
||||
& $MakePri new /pr $PSScriptRoot /cf $cf /mn (Join-Path $PSScriptRoot 'AppxManifest.xml') /of (Join-Path $PSScriptRoot 'resources.pri') /o | Out-Null
|
||||
Remove-Item $cf
|
||||
Write-Host 'resources.pri rebuilt. Now bump Version in AppxManifest.xml and re-register.'
|
||||
@@ -0,0 +1 @@
|
||||
Public folder for the com.microsoft.windows.lighting app extension (required by the manifest schema).
|
||||
@@ -0,0 +1,60 @@
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Grants VRio.App.exe package identity by registering the sparse package
|
||||
(pkg\AppxManifest.xml) with the exe's folder as the external location.
|
||||
|
||||
Identity is what lets Windows Dynamic Lighting offer vRIO under
|
||||
Settings > Personalization > Dynamic Lighting > "Background light control",
|
||||
so the keyboard lamp mirror keeps working while the game has focus.
|
||||
|
||||
Unsigned registration requires Developer Mode
|
||||
(Settings > System > For developers).
|
||||
|
||||
.PARAMETER ExePath
|
||||
Folder containing VRio.App.exe. Defaults to the repo's Release output;
|
||||
for a deployed zip, pass the extracted VRio folder.
|
||||
|
||||
.PARAMETER Unregister
|
||||
Remove the registration instead.
|
||||
#>
|
||||
param(
|
||||
[string]$ExePath = (Join-Path $PSScriptRoot '..\src\VRio.App\bin\Release\net48'),
|
||||
[switch]$Unregister
|
||||
)
|
||||
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$packageName = 'VWE.vRIO'
|
||||
|
||||
$existing = Get-AppxPackage -Name $packageName -ErrorAction SilentlyContinue
|
||||
if ($existing) {
|
||||
Write-Host "Removing existing registration $($existing.PackageFullName)..."
|
||||
Remove-AppxPackage -Package $existing.PackageFullName
|
||||
}
|
||||
if ($Unregister) {
|
||||
Write-Host 'Unregistered.'
|
||||
return
|
||||
}
|
||||
|
||||
$exe = Join-Path $ExePath 'VRio.App.exe'
|
||||
if (-not (Test-Path $exe)) { throw "VRio.App.exe not found in '$ExePath'" }
|
||||
$manifest = Join-Path $PSScriptRoot 'AppxManifest.xml'
|
||||
|
||||
# With AllowExternalContent the shell resolves the manifest's logo assets
|
||||
# against the EXTERNAL location, not the manifest's folder — the Start/taskbar
|
||||
# icon only shows if Assets\ and resources.pri sit next to the exe.
|
||||
$assetDir = New-Item -ItemType Directory -Force (Join-Path $ExePath 'Assets')
|
||||
Copy-Item (Join-Path $PSScriptRoot 'Assets\*') $assetDir -Force
|
||||
Copy-Item (Join-Path $PSScriptRoot 'resources.pri') $ExePath -Force
|
||||
|
||||
Add-AppxPackage -Register $manifest -ExternalLocation (Resolve-Path $ExePath).Path
|
||||
$family = (Get-AppxPackage -Name $packageName).PackageFamilyName
|
||||
Write-Host "Registered $packageName with external location '$ExePath'."
|
||||
Write-Host ''
|
||||
Write-Host 'IMPORTANT: identity is granted through shell activation only. Launch vRIO'
|
||||
Write-Host 'from the Start menu entry ("vRIO") or via:'
|
||||
Write-Host " explorer shell:AppsFolder\$family!vRIO"
|
||||
Write-Host 'Double-clicking VRio.App.exe runs it WITHOUT identity (lamp mirror then'
|
||||
Write-Host 'works only while vRIO has focus).'
|
||||
Write-Host ''
|
||||
Write-Host 'Then enable the lamp mirror and pick vRIO under Settings > Personalization >'
|
||||
Write-Host 'Dynamic Lighting > Background light control.'
|
||||
@@ -0,0 +1,431 @@
|
||||
using System.Diagnostics;
|
||||
using System.Runtime.InteropServices;
|
||||
using System.Windows.Forms;
|
||||
using VRio.Core.Device;
|
||||
using VRio.Core.Input;
|
||||
using VRio.Core.Panel;
|
||||
using VRio.Core.Protocol;
|
||||
using Windows.Devices.Enumeration;
|
||||
using Windows.Devices.Lights;
|
||||
using Windows.System;
|
||||
using Windows.UI;
|
||||
using Color = Windows.UI.Color;
|
||||
|
||||
namespace VRio.App;
|
||||
|
||||
/// <summary>
|
||||
/// Mirrors the host-commanded lamp states onto per-key RGB keyboards through
|
||||
/// Windows Dynamic Lighting (<see cref="LampArray"/>). Keys bound to
|
||||
/// lamp-capable button addresses in the active profile glow with the panel's
|
||||
/// palette — the red family for most groups, yellow for Secondary/Screen —
|
||||
/// and the flash bits blink at the panel's rates; every other key is blacked
|
||||
/// out so the keyboard reads as the button field. Zone-lit keyboards (no
|
||||
/// per-key addressing, common on laptops) fall back to a whole-board mirror
|
||||
/// of the strongest current lamp state.
|
||||
///
|
||||
/// <para>Opt-in: enabling claims keyboard-kind lamp arrays (hot-plug aware),
|
||||
/// disabling releases them so Windows hands the LEDs back to the ambient
|
||||
/// scene — no explicit restore needed. With several keyboards attached,
|
||||
/// <see cref="SetTarget"/> narrows the mirror to one of them
|
||||
/// (<see cref="KeyboardsChanged"/> feeds the picker). Dynamic Lighting gives
|
||||
/// LED control to the <em>foreground</em> app by default; for the keys to
|
||||
/// stay lit while the game has focus, vRIO needs package identity and a pick
|
||||
/// under Settings → Personalization → Dynamic Lighting → "Background light
|
||||
/// control".</para>
|
||||
/// </summary>
|
||||
public sealed class KeyboardLampMirror : IDisposable
|
||||
{
|
||||
// Refresh cadence and flash phases match PanelCanvas, so the keyboard and
|
||||
// the on-screen panel blink in step.
|
||||
private const int RefreshMs = 100;
|
||||
|
||||
private static readonly Stopwatch Clock = Stopwatch.StartNew();
|
||||
|
||||
// Address → panel colouring, from the cockpit layout (yellow = the
|
||||
// Secondary/Screen columns, same test as PanelCanvas).
|
||||
private static readonly Dictionary<int, bool> LampAddressIsYellow = BuildAddressInfo();
|
||||
|
||||
private readonly VRioDevice _device;
|
||||
private readonly object _gate = new();
|
||||
private readonly Dictionary<string, (LampArray Array, bool PerKey)> _claimed = new();
|
||||
private readonly Dictionary<string, string> _known = new(); // every keyboard array seen, id → name
|
||||
|
||||
// One entry per lamp-capable bound key: which lamp drives it, how to paint it.
|
||||
private (int Address, VirtualKey Key, bool Yellow)[] _map = Array.Empty<(int, VirtualKey, bool)>();
|
||||
|
||||
private DeviceWatcher? _watcher;
|
||||
private System.Threading.Timer? _timer;
|
||||
private Color[]? _lastPushed; // parallel to _map; null forces a full repaint
|
||||
private Color _lastAggregate; // last whole-board color for zone keyboards
|
||||
private string? _target; // device id to mirror to; null = all keyboards
|
||||
private bool _enabled;
|
||||
private bool _unsupported; // WinRT/Dynamic Lighting missing — don't retry
|
||||
private bool _anyArraySeen; // a lamp-array device event arrived this session
|
||||
|
||||
public KeyboardLampMirror(VRioDevice device) =>
|
||||
_device = device ?? throw new ArgumentNullException(nameof(device));
|
||||
|
||||
/// <summary>Status/log lines (attach, detach, availability hints).</summary>
|
||||
public event Action<string>? Logged;
|
||||
|
||||
/// <summary>The set of detected keyboards changed (id, name) — for a picker.</summary>
|
||||
public event Action<IReadOnlyList<(string Id, string Name)>>? KeyboardsChanged;
|
||||
|
||||
/// <summary>Mirror on/off. Off releases the LEDs back to Windows.</summary>
|
||||
public bool Enabled
|
||||
{
|
||||
get { lock (_gate) return _enabled; }
|
||||
set
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
if (_enabled == value || _unsupported)
|
||||
return;
|
||||
_enabled = value;
|
||||
}
|
||||
if (value) Start();
|
||||
else Stop();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Mirror to one keyboard by device id, or to all (null).</summary>
|
||||
public void SetTarget(string? deviceId)
|
||||
{
|
||||
LampArray[] release;
|
||||
List<(string Id, string Name)> reclaim = new();
|
||||
lock (_gate)
|
||||
{
|
||||
if (_target == deviceId)
|
||||
return;
|
||||
_target = deviceId;
|
||||
release = _claimed.Where(kv => !Matches(kv.Key))
|
||||
.Select(kv => kv.Value.Array).ToArray();
|
||||
foreach (string id in _claimed.Keys.Where(id => !Matches(id)).ToArray())
|
||||
_claimed.Remove(id);
|
||||
foreach (KeyValuePair<string, string> kv in _known)
|
||||
if (Matches(kv.Key) && !_claimed.ContainsKey(kv.Key))
|
||||
reclaim.Add((kv.Key, kv.Value));
|
||||
_lastPushed = null;
|
||||
}
|
||||
foreach (LampArray array in release)
|
||||
Release(array);
|
||||
foreach ((string id, string name) in reclaim)
|
||||
ReclaimAsync(id, name);
|
||||
}
|
||||
|
||||
/// <summary>Rebuild the key map from a loaded profile (lamp-capable buttons only).</summary>
|
||||
public void SetProfile(BindingProfile profile)
|
||||
{
|
||||
var map = new List<(int, VirtualKey, bool)>();
|
||||
var seen = new HashSet<VirtualKey>();
|
||||
foreach (KeyButtonBinding b in profile.KeyButtons)
|
||||
{
|
||||
if (!LampAddressIsYellow.TryGetValue(b.Address, out bool yellow))
|
||||
continue; // keypads and unknown addresses have no lamp
|
||||
if (!Enum.TryParse(b.Key, ignoreCase: true, out Keys key))
|
||||
continue;
|
||||
// WinForms Keys and WinRT VirtualKey share the Win32 VK number space.
|
||||
var vk = (VirtualKey)(int)(key & Keys.KeyCode);
|
||||
if (seen.Add(vk)) // first binding wins, like the router's key lookup
|
||||
map.Add((b.Address, vk, yellow));
|
||||
}
|
||||
lock (_gate)
|
||||
{
|
||||
_map = map.ToArray();
|
||||
_lastPushed = null; // repaint under the new map
|
||||
}
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
Enabled = false;
|
||||
Logged = null;
|
||||
KeyboardsChanged = null;
|
||||
}
|
||||
|
||||
// ---- Device lifecycle ---------------------------------------------------
|
||||
|
||||
private void Start()
|
||||
{
|
||||
try
|
||||
{
|
||||
var watcher = DeviceInformation.CreateWatcher(LampArray.GetDeviceSelector());
|
||||
watcher.Added += OnArrayAdded;
|
||||
watcher.Removed += OnArrayRemoved;
|
||||
watcher.Updated += (_, _) => { }; // required for the watcher to progress
|
||||
watcher.EnumerationCompleted += (_, _) =>
|
||||
{
|
||||
// Attaches log themselves (and may still be in flight); only the
|
||||
// empty case needs a line here.
|
||||
bool any;
|
||||
lock (_gate) any = _anyArraySeen;
|
||||
if (!any)
|
||||
Logged?.Invoke("Keyboard lighting: no Dynamic Lighting keyboard found (check Settings → Personalization → Dynamic Lighting)");
|
||||
};
|
||||
lock (_gate) _watcher = watcher;
|
||||
watcher.Start();
|
||||
// A leaked exception in a Timer callback kills the process — never
|
||||
// let a lighting hiccup take vRIO down mid-flight.
|
||||
_timer = new System.Threading.Timer(_ =>
|
||||
{
|
||||
try { Tick(); }
|
||||
catch (Exception ex) { Logged?.Invoke($"Keyboard lighting: paint failed: {ex.Message}"); }
|
||||
}, null, RefreshMs, RefreshMs);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
_unsupported = true;
|
||||
_enabled = false;
|
||||
}
|
||||
Logged?.Invoke($"Keyboard lighting unavailable (Dynamic Lighting needs Windows 11): {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
private void Stop()
|
||||
{
|
||||
DeviceWatcher? watcher;
|
||||
LampArray[] arrays;
|
||||
lock (_gate)
|
||||
{
|
||||
watcher = _watcher;
|
||||
_watcher = null;
|
||||
arrays = _claimed.Values.Select(a => a.Array).ToArray();
|
||||
_claimed.Clear();
|
||||
_known.Clear();
|
||||
_lastPushed = null;
|
||||
}
|
||||
_timer?.Dispose();
|
||||
_timer = null;
|
||||
try { watcher?.Stop(); }
|
||||
catch (Exception ex) when (ex is InvalidOperationException or COMException) { }
|
||||
// Releasing the arrays hands the LEDs back to the system ambient scene.
|
||||
foreach (LampArray array in arrays)
|
||||
Release(array);
|
||||
RaiseKeyboardsChanged();
|
||||
}
|
||||
|
||||
private async void OnArrayAdded(DeviceWatcher sender, DeviceInformation info)
|
||||
{
|
||||
lock (_gate) _anyArraySeen = true;
|
||||
try
|
||||
{
|
||||
LampArray array = await LampArray.FromIdAsync(info.Id);
|
||||
if (array is null || array.LampArrayKind != LampArrayKind.Keyboard)
|
||||
return; // don't paint mice/strips/cases
|
||||
|
||||
lock (_gate) _known[info.Id] = info.Name;
|
||||
if (!TryClaim(info.Id, info.Name, array))
|
||||
Release(array);
|
||||
RaiseKeyboardsChanged();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logged?.Invoke($"Keyboard lighting: could not open {info.Name}: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
private void OnArrayRemoved(DeviceWatcher sender, DeviceInformationUpdate update)
|
||||
{
|
||||
string? name;
|
||||
LampArray? gone = null;
|
||||
lock (_gate)
|
||||
{
|
||||
if (!_known.TryGetValue(update.Id, out name))
|
||||
return;
|
||||
_known.Remove(update.Id);
|
||||
if (_claimed.TryGetValue(update.Id, out (LampArray Array, bool PerKey) entry))
|
||||
{
|
||||
gone = entry.Array;
|
||||
_claimed.Remove(update.Id);
|
||||
}
|
||||
}
|
||||
if (gone is not null)
|
||||
Release(gone);
|
||||
Logged?.Invoke($"Keyboard lighting: {name} disconnected");
|
||||
RaiseKeyboardsChanged();
|
||||
}
|
||||
|
||||
private async void ReclaimAsync(string id, string name)
|
||||
{
|
||||
try
|
||||
{
|
||||
LampArray array = await LampArray.FromIdAsync(id);
|
||||
if (array is null)
|
||||
return;
|
||||
if (!TryClaim(id, name, array))
|
||||
Release(array);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logged?.Invoke($"Keyboard lighting: could not reopen {name}: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Store the array if it is wanted right now; false = caller releases.</summary>
|
||||
private bool TryClaim(string id, string name, LampArray array)
|
||||
{
|
||||
bool perKey = array.SupportsVirtualKeys;
|
||||
lock (_gate)
|
||||
{
|
||||
if (!_enabled || !Matches(id) || _claimed.ContainsKey(id))
|
||||
return false;
|
||||
_claimed[id] = (array, perKey);
|
||||
_lastPushed = null; // full repaint including the base coat
|
||||
}
|
||||
Logged?.Invoke(perKey
|
||||
? $"Keyboard lighting: + {name} ({array.LampCount} LEDs, per-key)"
|
||||
: $"Keyboard lighting: + {name} ({array.LampCount} zones — no per-key map, mirroring the strongest lamp board-wide)");
|
||||
HookAvailability(array, name);
|
||||
return true;
|
||||
}
|
||||
|
||||
private bool Matches(string id) => _target is null || _target == id;
|
||||
|
||||
private static void Release(LampArray array)
|
||||
{
|
||||
// No IDisposable projection on .NET Framework — drop the COM wrapper.
|
||||
try { Marshal.ReleaseComObject(array); } catch (ArgumentException) { }
|
||||
}
|
||||
|
||||
private void RaiseKeyboardsChanged()
|
||||
{
|
||||
(string, string)[] known;
|
||||
lock (_gate) known = _known.Select(kv => (kv.Key, kv.Value)).ToArray();
|
||||
KeyboardsChanged?.Invoke(known);
|
||||
}
|
||||
|
||||
private void HookAvailability(LampArray array, string name)
|
||||
{
|
||||
// IsAvailable/AvailabilityChanged need Windows 11 22H2+; older builds
|
||||
// just never get the background-control hint.
|
||||
try
|
||||
{
|
||||
bool lastAvailable = array.IsAvailable;
|
||||
if (!lastAvailable)
|
||||
Logged?.Invoke($"Keyboard lighting: Windows is withholding {name} — enable vRIO under " +
|
||||
"Settings → Personalization → Dynamic Lighting → Background light control");
|
||||
array.AvailabilityChanged += (a, _) =>
|
||||
{
|
||||
bool available = a.IsAvailable;
|
||||
if (available == lastAvailable)
|
||||
return; // the event also fires without a state change
|
||||
lastAvailable = available;
|
||||
Logged?.Invoke(available
|
||||
? $"Keyboard lighting: {name} available"
|
||||
: $"Keyboard lighting: {name} withheld (foreground app owns it — see Dynamic Lighting settings)");
|
||||
lock (_gate) _lastPushed = null; // repaint when control returns
|
||||
};
|
||||
}
|
||||
catch (Exception ex) when (ex is COMException or InvalidCastException or MissingMethodException) { }
|
||||
}
|
||||
|
||||
// ---- Rendering ----------------------------------------------------------
|
||||
|
||||
private void Tick()
|
||||
{
|
||||
(int Address, VirtualKey Key, bool Yellow)[] map;
|
||||
(LampArray Array, bool PerKey)[] arrays;
|
||||
Color[]? last;
|
||||
Color lastAggregate;
|
||||
lock (_gate)
|
||||
{
|
||||
if (!_enabled || _claimed.Count == 0)
|
||||
return;
|
||||
map = _map;
|
||||
arrays = _claimed.Values.ToArray();
|
||||
last = _lastPushed;
|
||||
lastAggregate = _lastAggregate;
|
||||
}
|
||||
|
||||
long tick = Clock.ElapsedMilliseconds;
|
||||
var colors = new Color[map.Length];
|
||||
var keys = new VirtualKey[map.Length];
|
||||
var bestShade = LampBrightness.Off;
|
||||
bool bestYellow = false;
|
||||
bool changed = last is null || last.Length != map.Length;
|
||||
for (int i = 0; i < map.Length; i++)
|
||||
{
|
||||
byte state = _device.GetLamp(map[i].Address);
|
||||
LampBrightness shade = RioLampState.Brightness(state);
|
||||
if (shade != LampBrightness.Off && !FlashPhaseOn(RioLampState.Flash(state), tick))
|
||||
shade = LampBrightness.Off;
|
||||
if (shade > bestShade)
|
||||
(bestShade, bestYellow) = (shade, map[i].Yellow);
|
||||
|
||||
colors[i] = Shade(shade, map[i].Yellow);
|
||||
keys[i] = map[i].Key;
|
||||
if (last is not null)
|
||||
changed |= !colors[i].Equals(last[i]);
|
||||
}
|
||||
Color aggregate = Shade(bestShade, bestYellow);
|
||||
changed |= !aggregate.Equals(lastAggregate);
|
||||
if (!changed)
|
||||
return;
|
||||
|
||||
foreach ((LampArray array, bool perKey) in arrays)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (perKey)
|
||||
{
|
||||
if (last is null) // new claim/map: black out the whole board first
|
||||
array.SetColor(ColorHelper.FromArgb(255, 0, 0, 0));
|
||||
if (map.Length > 0)
|
||||
array.SetColorsForKeys(colors, keys);
|
||||
}
|
||||
else if (map.Length > 0)
|
||||
{
|
||||
// Zone keyboard: the whole board is one big lamp showing
|
||||
// the strongest current state (idle dim, alerts bright/flash).
|
||||
array.SetColor(aggregate);
|
||||
}
|
||||
}
|
||||
catch (COMException) { } // device wobble; the watcher handles removal
|
||||
}
|
||||
lock (_gate)
|
||||
{
|
||||
_lastPushed = colors;
|
||||
_lastAggregate = aggregate;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>The panel's lamp palette (PanelCanvas fills), as LED colors.</summary>
|
||||
private static Color Shade(LampBrightness shade, bool yellow) =>
|
||||
yellow
|
||||
? shade switch
|
||||
{
|
||||
LampBrightness.Bright => ColorHelper.FromArgb(255, 245, 210, 60),
|
||||
LampBrightness.Dim => ColorHelper.FromArgb(255, 140, 118, 38),
|
||||
_ => ColorHelper.FromArgb(255, 70, 60, 24),
|
||||
}
|
||||
: shade switch
|
||||
{
|
||||
LampBrightness.Bright => ColorHelper.FromArgb(255, 230, 70, 70),
|
||||
LampBrightness.Dim => ColorHelper.FromArgb(255, 120, 50, 50),
|
||||
_ => ColorHelper.FromArgb(255, 64, 40, 40),
|
||||
};
|
||||
|
||||
// Same half-periods as PanelCanvas, so panel and keyboard blink together.
|
||||
private static bool FlashPhaseOn(LampFlash flash, long tick)
|
||||
{
|
||||
long halfPeriod = flash switch
|
||||
{
|
||||
LampFlash.FlashSlow => 500,
|
||||
LampFlash.FlashMed => 250,
|
||||
LampFlash.FlashFast => 125,
|
||||
_ => 0,
|
||||
};
|
||||
return halfPeriod == 0 || tick / halfPeriod % 2 == 0;
|
||||
}
|
||||
|
||||
private static Dictionary<int, bool> BuildAddressInfo()
|
||||
{
|
||||
var info = new Dictionary<int, bool>();
|
||||
foreach (PanelButton b in CockpitLayout.Buttons())
|
||||
if (b.LampCapable)
|
||||
info[b.Address] = b.Group.Title is "Secondary" or "Screen";
|
||||
return info;
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
using System.Diagnostics;
|
||||
using System.IO.Ports;
|
||||
using VRio.Core.Device;
|
||||
using VRio.Core.Input;
|
||||
|
||||
namespace VRio.App;
|
||||
|
||||
@@ -16,6 +17,13 @@ internal sealed class MainForm : Form
|
||||
private readonly VRioDevice _device = new();
|
||||
private readonly VRioSerialService _service;
|
||||
private readonly PanelCanvas _canvas = new();
|
||||
private readonly InputRouter _router;
|
||||
private readonly XInputGamepad _gamepad = new();
|
||||
private readonly KeyboardLampMirror _lampMirror;
|
||||
private readonly RawKeyboardSource _rawKeyboard = new();
|
||||
private string? _activeInputId; // interface path of the keyboard being captured; null = WinForms focus path
|
||||
private readonly string _bindingsPath = Path.Combine(
|
||||
Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), "vRIO", "bindings.txt");
|
||||
|
||||
private readonly ComboBox _portBox = new()
|
||||
{
|
||||
@@ -33,31 +41,64 @@ internal sealed class MainForm : Form
|
||||
ForeColor = Color.Gray,
|
||||
};
|
||||
|
||||
private readonly NumericUpDown _verMajor = new() { Location = new Point(80, 24), Width = 44, Minimum = 0, Maximum = 127, Value = 4 };
|
||||
private readonly NumericUpDown _verMinor = new() { Location = new Point(140, 24), Width = 44, Minimum = 0, Maximum = 127, Value = 2 };
|
||||
private readonly CheckBox _spring = new() { Text = "Stick springs back to center", Location = new Point(10, 56), AutoSize = true, Checked = true };
|
||||
private readonly Button _centerAxes = new() { Text = "Center all axes", Location = new Point(10, 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 _testExit = new() { Text = "Exit test mode", Location = new Point(156, 114), Width = 140, Height = 26 };
|
||||
private readonly CheckBox _wedgeBug = new()
|
||||
private readonly CheckBox _spring = new() { Text = "Stick springs back to center", Location = new Point(10, 24), AutoSize = true, Checked = true };
|
||||
private readonly Button _centerAxes = new() { Text = "Center all axes", Location = new Point(10, 52), Width = 140, Height = 26 };
|
||||
private readonly Button _lampsOff = new() { Text = "All lamps off", Location = new Point(156, 52), 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 = "Gamepad", Location = new Point(100, 22), AutoSize = true, Checked = true };
|
||||
private readonly CheckBox _invertY = new() { Text = "Invert Y", Location = new Point(190, 22), AutoSize = true };
|
||||
private readonly Label _padStatus = new()
|
||||
{
|
||||
Text = "Emulate the v4.2 reply-wedge bug",
|
||||
Location = new Point(10, 148),
|
||||
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 CheckBox _kbLights = new()
|
||||
{
|
||||
Text = "Mirror lamps on RGB keyboard (Dynamic Lighting)",
|
||||
Location = new Point(10, 100),
|
||||
AutoSize = true,
|
||||
};
|
||||
private readonly Button _wedgeNow = new() { Text = "Wedge analog now", Location = new Point(10, 172), Width = 140, Height = 26 };
|
||||
private readonly ComboBox _kbLightsTarget = new()
|
||||
{
|
||||
Location = new Point(10, 122),
|
||||
Width = 286,
|
||||
DropDownStyle = ComboBoxStyle.DropDownList,
|
||||
Enabled = false, // populated while the mirror is on
|
||||
};
|
||||
private readonly Label _kbInputLabel = new()
|
||||
{
|
||||
Text = "Capture keyboard (no window focus needed):",
|
||||
Location = new Point(10, 144),
|
||||
AutoSize = true,
|
||||
};
|
||||
private readonly ComboBox _kbInputTarget = new()
|
||||
{
|
||||
Location = new Point(10, 162),
|
||||
Width = 286,
|
||||
DropDownStyle = ComboBoxStyle.DropDownList,
|
||||
};
|
||||
|
||||
/// <summary>A keyboard choice in the lamp-mirror picker (null id = all).</summary>
|
||||
private sealed record KbChoice(string? Id, string Name)
|
||||
{
|
||||
public override string ToString() => Name;
|
||||
}
|
||||
|
||||
private readonly Label _counters = new()
|
||||
{
|
||||
Location = new Point(12, 290),
|
||||
Location = new Point(12, 372),
|
||||
AutoSize = true,
|
||||
Font = new Font("Consolas", 8f),
|
||||
};
|
||||
|
||||
private readonly Label _help = new()
|
||||
{
|
||||
Location = new Point(12, 348),
|
||||
Location = new Point(12, 428),
|
||||
MaximumSize = new Size(306, 0),
|
||||
AutoSize = true,
|
||||
ForeColor = Color.Gray,
|
||||
@@ -67,10 +108,10 @@ internal sealed class MainForm : Form
|
||||
|
||||
private readonly TextBox _logBox = new()
|
||||
{
|
||||
Location = new Point(12, 428),
|
||||
Location = new Point(12, 498),
|
||||
Multiline = true,
|
||||
ReadOnly = true,
|
||||
ScrollBars = ScrollBars.Vertical,
|
||||
ScrollBars = ScrollBars.Both, // long wire lines don't wrap — scroll to read
|
||||
BackColor = Color.FromArgb(24, 24, 24),
|
||||
ForeColor = Color.Gainsboro,
|
||||
Font = new Font("Consolas", 8f),
|
||||
@@ -85,18 +126,26 @@ internal sealed class MainForm : Form
|
||||
};
|
||||
|
||||
private readonly System.Windows.Forms.Timer _uiTimer = new() { Interval = 500 };
|
||||
private readonly System.Windows.Forms.Timer _inputTimer = new() { Interval = 16 };
|
||||
private readonly Stopwatch _clock = Stopwatch.StartNew();
|
||||
private double _lastInputTick;
|
||||
|
||||
public MainForm()
|
||||
{
|
||||
Text = "vRIO — Virtual RIO cockpit device";
|
||||
// ProductVersion carries the git stamp (see StampGitVersion in the csproj).
|
||||
Text = $"vRIO v{Application.ProductVersion} — Virtual RIO cockpit device";
|
||||
// Title-bar/taskbar icon from the exe's embedded ApplicationIcon (vwe.ico).
|
||||
Icon = Icon.ExtractAssociatedIcon(Application.ExecutablePath);
|
||||
// Fit the window to its content: the cockpit canvas plus the 330px
|
||||
// control strip, with just enough height for the strip's log area.
|
||||
ClientSize = new Size(_canvas.Width + 332, Math.Max(_canvas.Height, 640));
|
||||
MinimumSize = new Size(1000, 620);
|
||||
StartPosition = FormStartPosition.CenterScreen;
|
||||
KeyPreview = true; // form-level key routing for the input bindings
|
||||
|
||||
_service = new VRioSerialService(_device);
|
||||
_router = new InputRouter(_device);
|
||||
_lampMirror = new KeyboardLampMirror(_device);
|
||||
|
||||
// Panel canvas, scrolled if the window is smaller than the grid.
|
||||
var scroller = new Panel { Dock = DockStyle.Fill, AutoScroll = true, BackColor = Color.FromArgb(28, 28, 28) };
|
||||
@@ -108,56 +157,97 @@ internal sealed class MainForm : Form
|
||||
// Canvas ↔ device wiring.
|
||||
_canvas.LampProvider = _device.GetLamp;
|
||||
_canvas.AxisProvider = _device.GetAxis;
|
||||
_canvas.WireAxisProvider = _device.GetWireAxis;
|
||||
_canvas.AddressPressed += _device.PressAddress;
|
||||
_canvas.AddressReleased += _device.ReleaseAddress;
|
||||
_canvas.AxisMoved += (axis, value) => _device.SetAxis(axis, value);
|
||||
|
||||
// Router-driven presses light up like clicks (router runs on the UI thread).
|
||||
_router.AddressHeldChanged += _canvas.SetExternalHeld;
|
||||
|
||||
// Device / service events arrive on worker threads; marshal to the UI.
|
||||
_device.LampChanged += (_, _) => RunOnUi(_canvas.Invalidate);
|
||||
_device.AxesChanged += () => RunOnUi(_canvas.Invalidate);
|
||||
// A host reset re-zeroes the axes behind the router's back.
|
||||
_device.ResetReceived += _ => RunOnUi(_router.ResetAxisState);
|
||||
_device.Logged += line => RunOnUi(() => PrependLog(line));
|
||||
_service.Logged += line => RunOnUi(() => PrependLog(line));
|
||||
_lampMirror.Logged += line => RunOnUi(() => PrependLog(line));
|
||||
_service.ConnectionChanged += open => RunOnUi(() => OnConnectionChanged(open));
|
||||
_service.HostHandshake += high => RunOnUi(() =>
|
||||
PrependLog(high ? "Host raised DTR (board-reset handshake)" : "Host dropped DTR"));
|
||||
|
||||
_rescan.Click += (_, _) => RefreshPorts();
|
||||
_openClose.Click += (_, _) => ToggleOpen();
|
||||
_verMajor.ValueChanged += (_, _) => _device.VersionMajor = (byte)_verMajor.Value;
|
||||
_verMinor.ValueChanged += (_, _) => _device.VersionMinor = (byte)_verMinor.Value;
|
||||
_spring.CheckedChanged += (_, _) => _canvas.StickSpringsBack = _spring.Checked;
|
||||
_centerAxes.Click += (_, _) =>
|
||||
{
|
||||
foreach (RioAxis axis in (RioAxis[])Enum.GetValues(typeof(RioAxis)))
|
||||
_device.SetAxis(axis, 0);
|
||||
_router.ResetAxisState();
|
||||
};
|
||||
_lampsOff.Click += (_, _) =>
|
||||
{
|
||||
_device.ClearLamps();
|
||||
_canvas.Invalidate();
|
||||
};
|
||||
_testEnter.Click += (_, _) => _device.SendTestMode(1);
|
||||
_testExit.Click += (_, _) => _device.SendTestMode(0);
|
||||
_wedgeBug.CheckedChanged += (_, _) => _device.EmulateReplyWedge = _wedgeBug.Checked;
|
||||
_wedgeNow.Click += (_, _) =>
|
||||
{
|
||||
_device.WedgeAnalogNow();
|
||||
UpdateStatus();
|
||||
};
|
||||
_clearLog.Click += (_, _) => _logBox.Clear();
|
||||
|
||||
_kbInput.CheckedChanged += (_, _) =>
|
||||
{
|
||||
if (!_kbInput.Checked)
|
||||
_router.ReleaseAllKeys();
|
||||
};
|
||||
_invertY.CheckedChanged += (_, _) =>
|
||||
{
|
||||
_device.InvertJoystickY = _invertY.Checked;
|
||||
_canvas.Invalidate(); // the wire readout flips even though the axes didn't move
|
||||
};
|
||||
_device.InvertJoystickY = _invertY.Checked; // the field initializer raises no event
|
||||
_kbLights.CheckedChanged += (_, _) =>
|
||||
{
|
||||
_lampMirror.Enabled = _kbLights.Checked;
|
||||
_kbLightsTarget.Enabled = _kbLights.Checked;
|
||||
};
|
||||
_kbLightsTarget.SelectedIndexChanged += (_, _) =>
|
||||
_lampMirror.SetTarget((_kbLightsTarget.SelectedItem as KbChoice)?.Id);
|
||||
_lampMirror.KeyboardsChanged += list => RunOnUi(() => RebuildKeyboardPicker(list));
|
||||
_kbLightsTarget.Items.Add(new KbChoice(null, "All keyboards"));
|
||||
_kbLightsTarget.SelectedIndex = 0;
|
||||
|
||||
// Raw-input capture: a chosen keyboard drives the panel even while the
|
||||
// sim has focus. Down-edges only fire when keyboard input is enabled;
|
||||
// up-edges always land so a release is never stranded by a mid-hold flip.
|
||||
_rawKeyboard.KeyDown += name => RunOnUi(() => { if (_kbInput.Checked) _router.KeyDown(name); });
|
||||
_rawKeyboard.KeyUp += name => RunOnUi(() => _router.KeyUp(name));
|
||||
_rawKeyboard.Logged += line => RunOnUi(() => PrependLog(line));
|
||||
_rawKeyboard.KeyboardsChanged += list => RunOnUi(() => RebuildInputKeyboardPicker(list));
|
||||
_kbInputTarget.SelectedIndexChanged += (_, _) => ApplyInputSource();
|
||||
_kbInputTarget.DropDown += (_, _) => _rawKeyboard.RefreshKeyboards();
|
||||
_kbInputTarget.Items.Add(new KbChoice(null, "All keyboards (focus only)"));
|
||||
_kbInputTarget.SelectedIndex = 0;
|
||||
_reloadBindings.Click += (_, _) => LoadBindings();
|
||||
_editBindings.Click += (_, _) => OpenBindingsFile();
|
||||
|
||||
_uiTimer.Tick += (_, _) => UpdateStatus();
|
||||
_uiTimer.Start();
|
||||
|
||||
_inputTimer.Tick += (_, _) => InputTick();
|
||||
_inputTimer.Start();
|
||||
|
||||
FormClosed += (_, _) =>
|
||||
{
|
||||
_uiTimer.Dispose();
|
||||
_inputTimer.Dispose();
|
||||
_lampMirror.Dispose();
|
||||
_rawKeyboard.Dispose();
|
||||
_service.Dispose();
|
||||
};
|
||||
|
||||
RefreshPorts();
|
||||
UpdateStatus();
|
||||
PrependLog("vRIO ready. Open a COM port, then point RIOJoy at the other end of the pair.");
|
||||
LoadBindings();
|
||||
}
|
||||
|
||||
private Panel BuildControlStrip()
|
||||
@@ -181,17 +271,17 @@ internal sealed class MainForm : Form
|
||||
panel.Controls.Add(_openClose);
|
||||
panel.Controls.Add(_linkStatus);
|
||||
|
||||
var device = new GroupBox { Text = "Device", Location = new Point(12, 68), Size = new Size(306, 210) };
|
||||
device.Controls.Add(new Label { Text = "Firmware:", Location = new Point(10, 27), AutoSize = true });
|
||||
device.Controls.Add(_verMajor);
|
||||
device.Controls.Add(new Label { Text = ".", Location = new Point(127, 27), AutoSize = true });
|
||||
device.Controls.Add(_verMinor);
|
||||
device.Controls.AddRange(new Control[] { _spring, _centerAxes, _lampsOff, _testEnter, _testExit, _wedgeBug, _wedgeNow });
|
||||
var device = new GroupBox { Text = "Device", Location = new Point(12, 68), Size = new Size(306, 88) };
|
||||
device.Controls.AddRange(new Control[] { _spring, _centerAxes, _lampsOff });
|
||||
panel.Controls.Add(device);
|
||||
|
||||
var input = new GroupBox { Text = "Input", Location = new Point(12, 162), Size = new Size(306, 202) };
|
||||
input.Controls.AddRange(new Control[] { _kbInput, _padInput, _invertY, _padStatus, _reloadBindings, _editBindings, _kbLights, _kbLightsTarget, _kbInputLabel, _kbInputTarget });
|
||||
panel.Controls.Add(input);
|
||||
|
||||
panel.Controls.Add(_counters);
|
||||
panel.Controls.Add(_help);
|
||||
panel.Controls.Add(new Label { Text = "Wire log:", Location = new Point(12, 410), AutoSize = true });
|
||||
panel.Controls.Add(new Label { Text = "Wire log:", Location = new Point(12, 480), AutoSize = true });
|
||||
|
||||
_logBox.Size = new Size(306, ClientSize.Height - _logBox.Top - 44);
|
||||
panel.Controls.Add(_logBox);
|
||||
@@ -252,6 +342,204 @@ internal sealed class MainForm : Form
|
||||
UpdateStatus();
|
||||
}
|
||||
|
||||
// ---- Keyboard / gamepad input -------------------------------------------
|
||||
|
||||
/// <summary>
|
||||
/// Keys route to the panel unless the user is in a control that needs
|
||||
/// them (port list, log box scrolling).
|
||||
/// </summary>
|
||||
private bool KeyboardRoutingActive =>
|
||||
_kbInput.Checked &&
|
||||
!_portBox.ContainsFocus && !_logBox.ContainsFocus;
|
||||
|
||||
protected override void OnHandleCreated(EventArgs e)
|
||||
{
|
||||
base.OnHandleCreated(e);
|
||||
_rawKeyboard.Attach(Handle);
|
||||
_rawKeyboard.RefreshKeyboards(); // seed the capture picker
|
||||
}
|
||||
|
||||
protected override void WndProc(ref Message m)
|
||||
{
|
||||
// Raw Input arrives outside the normal key-message path; feed it to the
|
||||
// capture source, then let DefWindowProc run its WM_INPUT cleanup.
|
||||
const int WM_INPUT = 0x00FF, WM_INPUT_DEVICE_CHANGE = 0x00FE;
|
||||
switch (m.Msg)
|
||||
{
|
||||
case WM_INPUT:
|
||||
_rawKeyboard.ProcessInput(m.LParam);
|
||||
break;
|
||||
case WM_INPUT_DEVICE_CHANGE:
|
||||
_rawKeyboard.HandleDeviceChange();
|
||||
break;
|
||||
}
|
||||
base.WndProc(ref m);
|
||||
}
|
||||
|
||||
protected override bool ProcessCmdKey(ref Message msg, Keys keyData)
|
||||
{
|
||||
// Intercept WM_KEYDOWN before dialog-key processing, or arrows/space
|
||||
// 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))
|
||||
{
|
||||
// While a specific keyboard is captured, that source owns
|
||||
// routing; still swallow the key so it can't click a button.
|
||||
if (!_rawKeyboard.IsCapturing)
|
||||
_router.KeyDown(name);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return base.ProcessCmdKey(ref msg, keyData);
|
||||
}
|
||||
|
||||
protected override void OnKeyUp(KeyEventArgs e)
|
||||
{
|
||||
// Unconditional in focus mode: the router ignores keys it never saw go
|
||||
// down, and a release must land even if the checkbox flipped mid-hold.
|
||||
// In capture mode the raw source owns edges, so don't double-release.
|
||||
if (!_rawKeyboard.IsCapturing)
|
||||
_router.KeyUp(e.KeyCode.ToString());
|
||||
base.OnKeyUp(e);
|
||||
}
|
||||
|
||||
protected override void OnDeactivate(EventArgs e)
|
||||
{
|
||||
// Focus mode loses key-up events once unfocused, so release held keys.
|
||||
// Capture mode keeps receiving them in the background — leave holds be.
|
||||
if (!_rawKeyboard.IsCapturing)
|
||||
_router.ReleaseAllKeys();
|
||||
base.OnDeactivate(e);
|
||||
}
|
||||
|
||||
/// <summary>Apply the capture-picker selection: swap the keyboard input source.</summary>
|
||||
private void ApplyInputSource()
|
||||
{
|
||||
string? id = (_kbInputTarget.SelectedItem as KbChoice)?.Id;
|
||||
if (id == _activeInputId)
|
||||
return;
|
||||
_activeInputId = id;
|
||||
_router.ReleaseAllKeys(); // don't strand holds across a source swap
|
||||
_rawKeyboard.SetTarget(id);
|
||||
PrependLog(id is null
|
||||
? "Keyboard input: all keyboards, only while vRIO has focus"
|
||||
: $"Keyboard input: capturing \"{(_kbInputTarget.SelectedItem as KbChoice)?.Name}\" in the background");
|
||||
}
|
||||
|
||||
/// <summary>Refresh the capture picker, keeping the pick if the device survived.</summary>
|
||||
private void RebuildInputKeyboardPicker(IReadOnlyList<(string Id, string Name)> keyboards)
|
||||
{
|
||||
string? selected = (_kbInputTarget.SelectedItem as KbChoice)?.Id;
|
||||
_kbInputTarget.BeginUpdate();
|
||||
_kbInputTarget.Items.Clear();
|
||||
_kbInputTarget.Items.Add(new KbChoice(null, "All keyboards (focus only)"));
|
||||
int select = 0;
|
||||
foreach ((string id, string name) in keyboards)
|
||||
{
|
||||
int idx = _kbInputTarget.Items.Add(new KbChoice(id, name));
|
||||
if (id == selected)
|
||||
select = idx;
|
||||
}
|
||||
// A vanished capture device falls back to "All keyboards"; the
|
||||
// selection-changed handler (ApplyInputSource) unregisters accordingly.
|
||||
_kbInputTarget.SelectedIndex = select;
|
||||
_kbInputTarget.EndUpdate();
|
||||
}
|
||||
|
||||
private void InputTick()
|
||||
{
|
||||
double now = _clock.Elapsed.TotalSeconds;
|
||||
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;
|
||||
_lampMirror.SetProfile(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);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Refresh the lamp-mirror keyboard picker, keeping the pick if it survived.</summary>
|
||||
private void RebuildKeyboardPicker(IReadOnlyList<(string Id, string Name)> keyboards)
|
||||
{
|
||||
string? selected = (_kbLightsTarget.SelectedItem as KbChoice)?.Id;
|
||||
_kbLightsTarget.BeginUpdate();
|
||||
_kbLightsTarget.Items.Clear();
|
||||
_kbLightsTarget.Items.Add(new KbChoice(null, "All keyboards"));
|
||||
int select = 0;
|
||||
foreach ((string id, string name) in keyboards)
|
||||
{
|
||||
int idx = _kbLightsTarget.Items.Add(new KbChoice(id, name));
|
||||
if (id == selected)
|
||||
select = idx;
|
||||
}
|
||||
// Falls back to "All keyboards" if the picked device vanished (the
|
||||
// selection-changed handler re-targets the mirror accordingly).
|
||||
_kbLightsTarget.SelectedIndex = select;
|
||||
_kbLightsTarget.EndUpdate();
|
||||
}
|
||||
|
||||
// ---- Status / log ------------------------------------------------------
|
||||
|
||||
private void UpdateStatus()
|
||||
|
||||
@@ -43,6 +43,7 @@ internal sealed class PanelCanvas : Control
|
||||
private bool _wasFlashing;
|
||||
|
||||
private readonly HashSet<int> _latched = new();
|
||||
private readonly HashSet<int> _externalHeld = new();
|
||||
private int? _mouseDownAddress;
|
||||
|
||||
private RioAxis? _dragAxis; // Z / L / R gauge drag
|
||||
@@ -72,6 +73,12 @@ internal sealed class PanelCanvas : Control
|
||||
/// <summary>Current raw value of an axis (from the device).</summary>
|
||||
public Func<RioAxis, short>? AxisProvider { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Axis value as transmitted to the host (joystick Y sign flip applied).
|
||||
/// The text readout shows this; the dot and gauges track physical state.
|
||||
/// </summary>
|
||||
public Func<RioAxis, short>? WireAxisProvider { get; set; }
|
||||
|
||||
/// <summary>The user pressed a panel control.</summary>
|
||||
public event Action<int>? AddressPressed;
|
||||
|
||||
@@ -215,7 +222,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)
|
||||
{
|
||||
@@ -287,9 +305,10 @@ internal sealed class PanelCanvas : Control
|
||||
new Size(statusRect.Width, 0), TextFormatFlags.WordBreak).Height + 2;
|
||||
}
|
||||
|
||||
short WireAxis(RioAxis a) => WireAxisProvider?.Invoke(a) ?? Axis(a);
|
||||
string readout =
|
||||
$"Z {Axis(RioAxis.Throttle),4} L {Axis(RioAxis.LeftPedal),3} R {Axis(RioAxis.RightPedal),3} " +
|
||||
$"X {Axis(RioAxis.JoystickX),3} Y {Axis(RioAxis.JoystickY),3}";
|
||||
$"Z {WireAxis(RioAxis.Throttle),4} L {WireAxis(RioAxis.LeftPedal),3} R {WireAxis(RioAxis.RightPedal),3} " +
|
||||
$"X {WireAxis(RioAxis.JoystickX),3} Y {WireAxis(RioAxis.JoystickY),3}";
|
||||
TextRenderer.DrawText(g, readout, statusFont,
|
||||
new Rectangle(statusRect.X, readoutTop, statusRect.Width, statusRect.Bottom - readoutTop), green,
|
||||
TextFormatFlags.Left | TextFormatFlags.Top | TextFormatFlags.SingleLine);
|
||||
|
||||
@@ -0,0 +1,376 @@
|
||||
using System.Runtime.InteropServices;
|
||||
using System.Text.RegularExpressions;
|
||||
using System.Windows.Forms;
|
||||
using Microsoft.Win32;
|
||||
|
||||
namespace VRio.App;
|
||||
|
||||
/// <summary>
|
||||
/// A background keyboard input source built on the Win32 <b>Raw Input</b> API.
|
||||
/// Where the WinForms focus path (<c>ProcessCmdKey</c>/<c>OnKeyUp</c>) only sees
|
||||
/// keys while vRIO is the foreground window and can't tell one keyboard from
|
||||
/// another, Raw Input carries a per-device handle with every keystroke and —
|
||||
/// with the <c>RIDEV_INPUTSINK</c> flag — keeps delivering <c>WM_INPUT</c> even
|
||||
/// when vRIO is in the background. That lets the user nominate one physical
|
||||
/// keyboard as a dedicated cockpit panel: its keys drive the RIO controls while
|
||||
/// the sim (or RIOJoy) holds focus, and other keyboards are ignored.
|
||||
///
|
||||
/// <para>This is the input-side twin of <see cref="KeyboardLampMirror"/>: that
|
||||
/// class <em>writes</em> a chosen keyboard's LEDs while the game has focus, this
|
||||
/// one <em>reads</em> a chosen keyboard's keys under the same condition. Raw
|
||||
/// Input observes without intercepting — the keystroke still reaches whatever
|
||||
/// app has focus — so the selected keyboard should be one dedicated to the
|
||||
/// panel, not the one you also type on.</para>
|
||||
///
|
||||
/// <para>Registration is scoped to when a device is actually selected
|
||||
/// (<see cref="SetTarget"/>): picking "all keyboards" unregisters and hands
|
||||
/// input back to the focus path, so vRIO isn't pumping <c>WM_INPUT</c> for every
|
||||
/// keystroke system-wide when the feature is idle. Not thread-safe;
|
||||
/// <see cref="ProcessInput"/> and the events fire on the UI thread that owns the
|
||||
/// window handle, which is exactly where <see cref="Input.InputRouter"/> wants
|
||||
/// to be called.</para>
|
||||
/// </summary>
|
||||
public sealed class RawKeyboardSource : IDisposable
|
||||
{
|
||||
// --- Win32 Raw Input interop ---------------------------------------------
|
||||
|
||||
[StructLayout(LayoutKind.Sequential)]
|
||||
private struct RAWINPUTDEVICE
|
||||
{
|
||||
public ushort usUsagePage;
|
||||
public ushort usUsage;
|
||||
public uint dwFlags;
|
||||
public IntPtr hwndTarget;
|
||||
}
|
||||
|
||||
[StructLayout(LayoutKind.Sequential)]
|
||||
private struct RAWINPUTDEVICELIST
|
||||
{
|
||||
public IntPtr hDevice;
|
||||
public uint dwType;
|
||||
}
|
||||
|
||||
[StructLayout(LayoutKind.Sequential)]
|
||||
private struct RAWINPUTHEADER
|
||||
{
|
||||
public uint dwType;
|
||||
public uint dwSize;
|
||||
public IntPtr hDevice;
|
||||
public IntPtr wParam;
|
||||
}
|
||||
|
||||
[StructLayout(LayoutKind.Sequential)]
|
||||
private struct RAWKEYBOARD
|
||||
{
|
||||
public ushort MakeCode;
|
||||
public ushort Flags;
|
||||
public ushort Reserved;
|
||||
public ushort VKey;
|
||||
public uint Message;
|
||||
public uint ExtraInformation;
|
||||
}
|
||||
|
||||
[DllImport("user32.dll", SetLastError = true)]
|
||||
private static extern bool RegisterRawInputDevices(
|
||||
[In] RAWINPUTDEVICE[] pRawInputDevices, uint uiNumDevices, uint cbSize);
|
||||
|
||||
[DllImport("user32.dll", SetLastError = true)]
|
||||
private static extern uint GetRawInputData(
|
||||
IntPtr hRawInput, uint uiCommand, IntPtr pData, ref uint pcbSize, uint cbSizeHeader);
|
||||
|
||||
[DllImport("user32.dll", SetLastError = true)]
|
||||
private static extern uint GetRawInputDeviceList(
|
||||
[In, Out] RAWINPUTDEVICELIST[]? pRawInputDeviceList, ref uint puiNumDevices, uint cbSize);
|
||||
|
||||
[DllImport("user32.dll", SetLastError = true, CharSet = CharSet.Unicode)]
|
||||
private static extern uint GetRawInputDeviceInfoW(
|
||||
IntPtr hDevice, uint uiCommand, IntPtr pData, ref uint pcbSize);
|
||||
|
||||
private const uint Err = 0xFFFFFFFF; // sentinel: all three APIs return (UINT)-1 on failure
|
||||
private const uint RIDEV_REMOVE = 0x00000001;
|
||||
private const uint RIDEV_INPUTSINK = 0x00000100;
|
||||
private const uint RIDEV_DEVNOTIFY = 0x00002000; // WM_INPUT_DEVICE_CHANGE on hot-plug
|
||||
private const uint RID_INPUT = 0x10000003;
|
||||
private const uint RIDI_DEVICENAME = 0x20000007;
|
||||
private const uint RIM_TYPEKEYBOARD = 1;
|
||||
private const ushort RI_KEY_BREAK = 0x01; // set = key up (make/break)
|
||||
private const ushort HidUsagePageGeneric = 0x01;
|
||||
private const ushort HidUsageKeyboard = 0x06;
|
||||
|
||||
private static readonly int HeaderSize = Marshal.SizeOf<RAWINPUTHEADER>();
|
||||
|
||||
// --- State ---------------------------------------------------------------
|
||||
|
||||
// hDevice → interface path, so the hot per-keystroke path skips the
|
||||
// two-call GetRawInputDeviceInfo dance once a device is known.
|
||||
private readonly Dictionary<IntPtr, string> _pathByHandle = new();
|
||||
|
||||
private IntPtr _hwnd;
|
||||
private string? _target; // interface path of the keyboard to capture; null = disabled
|
||||
private bool _registered;
|
||||
private bool _disposed;
|
||||
|
||||
/// <summary>A key on the captured keyboard went down (.NET <see cref="Keys"/> name).</summary>
|
||||
public event Action<string>? KeyDown;
|
||||
|
||||
/// <summary>A key on the captured keyboard came up (.NET <see cref="Keys"/> name).</summary>
|
||||
public event Action<string>? KeyUp;
|
||||
|
||||
/// <summary>The set of attached keyboards changed (interface path, display name) — for a picker.</summary>
|
||||
public event Action<IReadOnlyList<(string Id, string Name)>>? KeyboardsChanged;
|
||||
|
||||
/// <summary>Status/diagnostic lines.</summary>
|
||||
public event Action<string>? Logged;
|
||||
|
||||
/// <summary>True while a specific keyboard is being captured (focus path should stand down).</summary>
|
||||
public bool IsCapturing => _target is not null;
|
||||
|
||||
/// <summary>Bind to the window whose message loop delivers <c>WM_INPUT</c>.</summary>
|
||||
public void Attach(IntPtr hwnd) => _hwnd = hwnd;
|
||||
|
||||
/// <summary>
|
||||
/// Capture one keyboard by interface path, or none (<paramref name="deviceId"/>
|
||||
/// null) to release input back to the focus path. Registering/unregistering
|
||||
/// Raw Input is scoped here so vRIO only taps the global key stream while the
|
||||
/// feature is actually in use.
|
||||
/// </summary>
|
||||
public void SetTarget(string? deviceId)
|
||||
{
|
||||
if (_target == deviceId)
|
||||
return;
|
||||
_target = deviceId;
|
||||
if (deviceId is null)
|
||||
Unregister();
|
||||
else
|
||||
Register();
|
||||
}
|
||||
|
||||
/// <summary>Re-enumerate keyboards and raise <see cref="KeyboardsChanged"/>.</summary>
|
||||
public void RefreshKeyboards()
|
||||
{
|
||||
_pathByHandle.Clear(); // handles are only stable per connection
|
||||
KeyboardsChanged?.Invoke(EnumerateKeyboards());
|
||||
}
|
||||
|
||||
/// <summary>Handle a <c>WM_INPUT_DEVICE_CHANGE</c>: a keyboard arrived or left.</summary>
|
||||
public void HandleDeviceChange() => RefreshKeyboards();
|
||||
|
||||
/// <summary>
|
||||
/// Handle a <c>WM_INPUT</c> message (pass its <c>LParam</c>). Silently ignores
|
||||
/// input from any keyboard other than the captured one.
|
||||
/// </summary>
|
||||
public void ProcessInput(IntPtr hRawInput)
|
||||
{
|
||||
string? target = _target;
|
||||
if (target is null)
|
||||
return;
|
||||
|
||||
uint size = 0;
|
||||
if (GetRawInputData(hRawInput, RID_INPUT, IntPtr.Zero, ref size, (uint)HeaderSize) == Err || size == 0)
|
||||
return;
|
||||
|
||||
IntPtr buffer = Marshal.AllocHGlobal((int)size);
|
||||
try
|
||||
{
|
||||
if (GetRawInputData(hRawInput, RID_INPUT, buffer, ref size, (uint)HeaderSize) != size)
|
||||
return;
|
||||
|
||||
var header = Marshal.PtrToStructure<RAWINPUTHEADER>(buffer);
|
||||
if (header.dwType != RIM_TYPEKEYBOARD)
|
||||
return;
|
||||
string? path = PathFor(header.hDevice);
|
||||
if (!string.Equals(path, target, StringComparison.OrdinalIgnoreCase))
|
||||
return;
|
||||
|
||||
var kb = Marshal.PtrToStructure<RAWKEYBOARD>(IntPtr.Add(buffer, HeaderSize));
|
||||
ushort vkey = kb.VKey;
|
||||
// 0 = no VK, 0xFF = fake key emitted as part of an escaped sequence.
|
||||
if (vkey is 0 or 0xFF)
|
||||
return;
|
||||
|
||||
string key = ((Keys)vkey).ToString();
|
||||
if ((kb.Flags & RI_KEY_BREAK) != 0)
|
||||
KeyUp?.Invoke(key);
|
||||
else
|
||||
KeyDown?.Invoke(key); // repeats while held are dropped by the router
|
||||
}
|
||||
finally
|
||||
{
|
||||
Marshal.FreeHGlobal(buffer);
|
||||
}
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (_disposed)
|
||||
return;
|
||||
_disposed = true;
|
||||
_target = null;
|
||||
Unregister();
|
||||
KeyDown = null;
|
||||
KeyUp = null;
|
||||
KeyboardsChanged = null;
|
||||
Logged = null;
|
||||
}
|
||||
|
||||
// --- Registration --------------------------------------------------------
|
||||
|
||||
private void Register()
|
||||
{
|
||||
if (_registered || _hwnd == IntPtr.Zero)
|
||||
return;
|
||||
var rid = new[]
|
||||
{
|
||||
new RAWINPUTDEVICE
|
||||
{
|
||||
usUsagePage = HidUsagePageGeneric,
|
||||
usUsage = HidUsageKeyboard,
|
||||
dwFlags = RIDEV_INPUTSINK | RIDEV_DEVNOTIFY, // background delivery + hot-plug notices
|
||||
hwndTarget = _hwnd,
|
||||
},
|
||||
};
|
||||
if (RegisterRawInputDevices(rid, 1, (uint)Marshal.SizeOf<RAWINPUTDEVICE>()))
|
||||
_registered = true;
|
||||
else
|
||||
Logged?.Invoke($"Raw keyboard capture unavailable (error {Marshal.GetLastWin32Error()})");
|
||||
}
|
||||
|
||||
private void Unregister()
|
||||
{
|
||||
if (!_registered)
|
||||
return;
|
||||
// RIDEV_REMOVE must pass a null target window, or the call fails.
|
||||
var rid = new[]
|
||||
{
|
||||
new RAWINPUTDEVICE
|
||||
{
|
||||
usUsagePage = HidUsagePageGeneric,
|
||||
usUsage = HidUsageKeyboard,
|
||||
dwFlags = RIDEV_REMOVE,
|
||||
hwndTarget = IntPtr.Zero,
|
||||
},
|
||||
};
|
||||
RegisterRawInputDevices(rid, 1, (uint)Marshal.SizeOf<RAWINPUTDEVICE>());
|
||||
_registered = false;
|
||||
}
|
||||
|
||||
// --- Enumeration / naming ------------------------------------------------
|
||||
|
||||
private List<(string Id, string Name)> EnumerateKeyboards()
|
||||
{
|
||||
var result = new List<(string, string)>();
|
||||
uint listSize = (uint)Marshal.SizeOf<RAWINPUTDEVICELIST>();
|
||||
uint count = 0;
|
||||
if (GetRawInputDeviceList(null, ref count, listSize) == Err || count == 0)
|
||||
return result;
|
||||
|
||||
var devices = new RAWINPUTDEVICELIST[count];
|
||||
uint got = GetRawInputDeviceList(devices, ref count, listSize);
|
||||
if (got == Err)
|
||||
return result;
|
||||
|
||||
var labelCounts = new Dictionary<string, int>();
|
||||
for (int i = 0; i < got; i++)
|
||||
{
|
||||
if (devices[i].dwType != RIM_TYPEKEYBOARD)
|
||||
continue;
|
||||
string? path = PathFor(devices[i].hDevice);
|
||||
if (string.IsNullOrEmpty(path))
|
||||
continue;
|
||||
|
||||
string label = FriendlyName(path!);
|
||||
// Physical keyboards can surface as more than one collection; a bare
|
||||
// count suffix keeps otherwise-identical labels distinguishable.
|
||||
if (labelCounts.TryGetValue(label, out int n))
|
||||
{
|
||||
labelCounts[label] = n + 1;
|
||||
label = $"{label} ({n + 1})";
|
||||
}
|
||||
else
|
||||
{
|
||||
labelCounts[label] = 1;
|
||||
}
|
||||
result.Add((path!, label));
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
private string? PathFor(IntPtr hDevice)
|
||||
{
|
||||
if (_pathByHandle.TryGetValue(hDevice, out string? cached))
|
||||
return cached;
|
||||
string? path = DeviceName(hDevice);
|
||||
if (path is not null)
|
||||
_pathByHandle[hDevice] = path;
|
||||
return path;
|
||||
}
|
||||
|
||||
private static string? DeviceName(IntPtr hDevice)
|
||||
{
|
||||
uint chars = 0;
|
||||
if (GetRawInputDeviceInfoW(hDevice, RIDI_DEVICENAME, IntPtr.Zero, ref chars) == Err || chars == 0)
|
||||
return null;
|
||||
|
||||
IntPtr buffer = Marshal.AllocHGlobal((int)chars * sizeof(char));
|
||||
try
|
||||
{
|
||||
if (GetRawInputDeviceInfoW(hDevice, RIDI_DEVICENAME, buffer, ref chars) == Err)
|
||||
return null;
|
||||
return Marshal.PtrToStringUni(buffer);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Marshal.FreeHGlobal(buffer);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Turn a raw device interface path into something a human recognizes: the
|
||||
/// device's registry description if we can resolve it, else its VID:PID.
|
||||
/// </summary>
|
||||
private static string FriendlyName(string interfacePath)
|
||||
{
|
||||
string? desc = RegistryName(interfacePath);
|
||||
if (!string.IsNullOrWhiteSpace(desc))
|
||||
return desc!;
|
||||
|
||||
Match m = Regex.Match(interfacePath, @"VID_([0-9A-Fa-f]{4}).*?PID_([0-9A-Fa-f]{4})");
|
||||
return m.Success
|
||||
? $"Keyboard {m.Groups[1].Value.ToUpperInvariant()}:{m.Groups[2].Value.ToUpperInvariant()}"
|
||||
: "Keyboard";
|
||||
}
|
||||
|
||||
private static string? RegistryName(string interfacePath)
|
||||
{
|
||||
try
|
||||
{
|
||||
// \\?\HID#VID_x&PID_y#instance#{class-guid} → Enum key HID\VID_x&PID_y\instance
|
||||
string s = interfacePath;
|
||||
if (s.StartsWith(@"\\?\", StringComparison.Ordinal) || s.StartsWith(@"\\.\", StringComparison.Ordinal))
|
||||
s = s.Substring(4);
|
||||
int guid = s.LastIndexOf("#{", StringComparison.Ordinal);
|
||||
if (guid >= 0)
|
||||
s = s.Substring(0, guid);
|
||||
string instance = s.Replace('#', '\\');
|
||||
|
||||
using RegistryKey? key = Registry.LocalMachine.OpenSubKey(
|
||||
@"SYSTEM\CurrentControlSet\Enum\" + instance);
|
||||
if (key is null)
|
||||
return null;
|
||||
|
||||
string? name = key.GetValue("FriendlyName") as string;
|
||||
if (string.IsNullOrWhiteSpace(name))
|
||||
name = key.GetValue("DeviceDesc") as string;
|
||||
if (string.IsNullOrWhiteSpace(name))
|
||||
return null;
|
||||
|
||||
// DeviceDesc is "@driver.inf,%token%;Actual Name" — keep the tail.
|
||||
int semi = name!.LastIndexOf(';');
|
||||
return semi >= 0 ? name.Substring(semi + 1) : name;
|
||||
}
|
||||
catch (Exception ex) when (ex is System.Security.SecurityException or UnauthorizedAccessException or IOException)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -8,18 +8,47 @@
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<LangVersion>latest</LangVersion>
|
||||
<ApplicationManifest>app.manifest</ApplicationManifest>
|
||||
<ApplicationIcon>vwe.ico</ApplicationIcon>
|
||||
<AssemblyTitle>vRIO — Virtual RIO device</AssemblyTitle>
|
||||
<!-- StampGitVersion already puts the sha in InformationalVersion; stop the
|
||||
SDK appending "+fullsha" on top of it. -->
|
||||
<IncludeSourceRevisionInInformationalVersion>false</IncludeSourceRevisionInInformationalVersion>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\VRio.Core\VRio.Core.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<!-- Stamp commit date + short sha into InformationalVersion; the window
|
||||
title shows it (Application.ProductVersion) so a running build can be
|
||||
matched to its vYYYY.MM.DD release tag at a glance. Falls back to the
|
||||
default 1.0.0 when git isn't available. -->
|
||||
<Target Name="StampGitVersion" BeforeTargets="GetAssemblyVersion" Condition="'$(DesignTimeBuild)' != 'true'">
|
||||
<!-- %%cs survives cmd's percent expansion as %cs: committer date, YYYY-MM-DD. -->
|
||||
<Exec Command="git -C "$(MSBuildProjectDirectory)" log -1 --format=%%cs"
|
||||
ConsoleToMSBuild="true" StandardOutputImportance="low" IgnoreExitCode="true" ContinueOnError="true">
|
||||
<Output TaskParameter="ConsoleOutput" PropertyName="GitCommitDate" />
|
||||
<Output TaskParameter="ExitCode" PropertyName="GitDateExitCode" />
|
||||
</Exec>
|
||||
<!-- The exclude flag keeps describe off the release tags: always the short
|
||||
sha, with a "dirty" suffix when the working tree has local edits. -->
|
||||
<Exec Command="git -C "$(MSBuildProjectDirectory)" describe --always --dirty --exclude=*"
|
||||
ConsoleToMSBuild="true" StandardOutputImportance="low" IgnoreExitCode="true" ContinueOnError="true">
|
||||
<Output TaskParameter="ConsoleOutput" PropertyName="GitShortSha" />
|
||||
</Exec>
|
||||
<PropertyGroup Condition="'$(GitDateExitCode)' == '0' And '$(GitShortSha)' != ''">
|
||||
<InformationalVersion>$(GitCommitDate.Trim().Replace('-', '.')) ($(GitShortSha))</InformationalVersion>
|
||||
</PropertyGroup>
|
||||
</Target>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="PolySharp" Version="1.14.1">
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
</PackageReference>
|
||||
<!-- WinRT projections for Windows.Devices.Lights (Dynamic Lighting /
|
||||
LampArray) on .NET Framework — the keyboard lamp mirror. -->
|
||||
<PackageReference Include="Microsoft.Windows.SDK.Contracts" Version="10.0.22621.3233" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
After Width: | Height: | Size: 2.9 KiB |
@@ -13,7 +13,9 @@ namespace VRio.Core.Device;
|
||||
/// <para>Wire behavior (mirroring what RIOJoy expects from the real board):</para>
|
||||
/// <list type="bullet">
|
||||
/// <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
|
||||
/// (default 4.2, matching the real board's dumped EPROM).</item>
|
||||
/// <item>AnalogRequest → AnalogReply with the current five axis values.</item>
|
||||
@@ -55,6 +57,15 @@ public sealed class VRioDevice
|
||||
/// <summary>Firmware version reported by VersionReply (real boards run 4.2).</summary>
|
||||
public byte VersionMinor { get; set; } = 2;
|
||||
|
||||
/// <summary>
|
||||
/// The wire natively carries stick-up as negative (full up = −80 in
|
||||
/// AnalogReply); set this to send the physical direction (up = positive)
|
||||
/// instead. Only the host sees the difference — local state
|
||||
/// (<see cref="GetAxis"/>, the panel's dot) keeps the physical stick
|
||||
/// direction either way.
|
||||
/// </summary>
|
||||
public bool InvertJoystickY { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// When true, retry exhaustion leaves the analog reply path wedged (the
|
||||
/// v4.2 latch-leak bug) until a host ResetRequest clears it.
|
||||
@@ -99,6 +110,21 @@ public sealed class VRioDevice
|
||||
lock (_gate) return _axes[(int)axis];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Axis value as the next AnalogReply will carry it — same as
|
||||
/// <see cref="GetAxis"/> except joystick Y follows the wire convention
|
||||
/// (see <see cref="InvertJoystickY"/>).
|
||||
/// </summary>
|
||||
public short GetWireAxis(RioAxis axis)
|
||||
{
|
||||
short value = GetAxis(axis);
|
||||
return axis == RioAxis.JoystickY ? WireY(value) : value;
|
||||
}
|
||||
|
||||
private short WireY(short y) => InvertJoystickY
|
||||
? y
|
||||
: (short)Math.Min(AnalogCodec.Max, -y); // clamp: -Min (8192) is one past the 14-bit Max
|
||||
|
||||
/// <summary>
|
||||
/// Move an axis. Values are clamped to the 14-bit signed range the wire
|
||||
/// can carry; the new value is returned by the next AnalogReply.
|
||||
@@ -239,9 +265,14 @@ public sealed class VRioDevice
|
||||
switch (packet.Command)
|
||||
{
|
||||
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)
|
||||
Send(PacketBuilder.CheckReply(RioStatusType.BoardOk, number));
|
||||
Send(PacketBuilder.TestModeChange(0));
|
||||
break;
|
||||
|
||||
case RioCommand.VersionRequest:
|
||||
@@ -267,7 +298,7 @@ public sealed class VRioDevice
|
||||
y = _axes[(int)RioAxis.JoystickY];
|
||||
x = _axes[(int)RioAxis.JoystickX];
|
||||
}
|
||||
Send(PacketBuilder.AnalogReply(t, l, r, y, x));
|
||||
Send(PacketBuilder.AnalogReply(t, l, r, WireY(y), x));
|
||||
break;
|
||||
|
||||
case RioCommand.ResetRequest:
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
using System.Diagnostics;
|
||||
using System.IO.Ports;
|
||||
using System.Runtime.InteropServices;
|
||||
using VRio.Core.Protocol;
|
||||
|
||||
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
|
||||
/// 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
|
||||
/// 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
|
||||
@@ -18,12 +34,22 @@ public sealed class VRioSerialService : IDisposable
|
||||
/// <summary>RIO link bit rate (must match RIOJoy's transport).</summary>
|
||||
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 object _writeGate = new();
|
||||
private readonly object _txGate = new();
|
||||
private readonly Queue<byte> _txQueue = new();
|
||||
|
||||
private SerialPort? _port;
|
||||
private Thread? _reader;
|
||||
private Thread? _writer;
|
||||
private volatile bool _running;
|
||||
private bool _timerResolutionRaised;
|
||||
|
||||
public VRioSerialService(VRioDevice device)
|
||||
{
|
||||
@@ -74,10 +100,18 @@ public sealed class VRioSerialService : IDisposable
|
||||
|
||||
_port = port;
|
||||
_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.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);
|
||||
}
|
||||
|
||||
@@ -89,6 +123,7 @@ public sealed class VRioSerialService : IDisposable
|
||||
return;
|
||||
|
||||
_running = false;
|
||||
lock (_txGate) Monitor.PulseAll(_txGate); // wake the writer so it can exit
|
||||
_port = null;
|
||||
port.PinChanged -= OnPinChanged;
|
||||
try { port.Close(); }
|
||||
@@ -97,6 +132,15 @@ public sealed class VRioSerialService : IDisposable
|
||||
|
||||
_reader?.Join(1000);
|
||||
_reader = null;
|
||||
_writer?.Join(1000);
|
||||
_writer = null;
|
||||
lock (_txGate) _txQueue.Clear();
|
||||
|
||||
if (_timerResolutionRaised)
|
||||
{
|
||||
timeEndPeriod(1);
|
||||
_timerResolutionRaised = false;
|
||||
}
|
||||
|
||||
Logged?.Invoke("Port closed");
|
||||
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)
|
||||
{
|
||||
SerialPort? port = _port;
|
||||
if (port is null || !port.IsOpen)
|
||||
if (!_running)
|
||||
return; // device poked while offline — drop silently
|
||||
|
||||
try
|
||||
lock (_txGate)
|
||||
{
|
||||
lock (_writeGate)
|
||||
port.Write(data, 0, data.Length);
|
||||
}
|
||||
catch (Exception ex) when (ex is IOException or InvalidOperationException or TimeoutException)
|
||||
{
|
||||
Logged?.Invoke($"Write failed: {ex.Message}");
|
||||
foreach (byte b in data)
|
||||
_txQueue.Enqueue(b);
|
||||
Monitor.Pulse(_txGate);
|
||||
}
|
||||
}
|
||||
|
||||
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()
|
||||
{
|
||||
_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);
|
||||
}
|
||||
}
|
||||
@@ -48,6 +48,7 @@ public class VRioDeviceTests
|
||||
var device = new VRioDevice();
|
||||
var wire = new Wire(device);
|
||||
device.SetAxis(RioAxis.Throttle, 1000);
|
||||
device.SetAxis(RioAxis.JoystickY, 3000);
|
||||
device.SetAxis(RioAxis.JoystickX, -5000);
|
||||
|
||||
Send(device, PacketBuilder.Build(RioCommand.AnalogRequest));
|
||||
@@ -60,11 +61,47 @@ public class VRioDeviceTests
|
||||
Assert.Equal(1000, AnalogCodec.Combine(p[0], p[1])); // throttle
|
||||
Assert.Equal(0, AnalogCodec.Combine(p[2], p[3])); // left pedal
|
||||
Assert.Equal(0, AnalogCodec.Combine(p[4], p[5])); // right pedal
|
||||
Assert.Equal(0, AnalogCodec.Combine(p[6], p[7])); // joystick Y
|
||||
Assert.Equal(-3000, AnalogCodec.Combine(p[6], p[7])); // joystick Y (up is negative on the wire)
|
||||
Assert.Equal(-5000, AnalogCodec.Combine(p[8], p[9])); // joystick X
|
||||
Assert.Equal(1, device.AnalogRequests);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Joystick_Y_is_negated_on_the_wire_but_not_locally()
|
||||
{
|
||||
var device = new VRioDevice();
|
||||
var wire = new Wire(device);
|
||||
device.SetAxis(RioAxis.JoystickY, 3000);
|
||||
|
||||
Send(device, PacketBuilder.Build(RioCommand.AnalogRequest));
|
||||
|
||||
byte[] p = Assert.Single(wire.Packets).Payload;
|
||||
Assert.Equal(-3000, AnalogCodec.Combine(p[6], p[7]));
|
||||
Assert.Equal(3000, device.GetAxis(RioAxis.JoystickY)); // the panel's dot is unflipped
|
||||
Assert.Equal(-3000, device.GetWireAxis(RioAxis.JoystickY)); // the panel's readout matches the wire
|
||||
|
||||
// Full negative deflection: -Min is one past Max, so it clamps rather than throws.
|
||||
device.SetAxis(RioAxis.JoystickY, AnalogCodec.Min);
|
||||
wire.Clear();
|
||||
Send(device, PacketBuilder.Build(RioCommand.AnalogRequest));
|
||||
p = Assert.Single(wire.Packets).Payload;
|
||||
Assert.Equal(AnalogCodec.Max, AnalogCodec.Combine(p[6], p[7]));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void InvertJoystickY_sends_the_physical_direction_instead()
|
||||
{
|
||||
var device = new VRioDevice { InvertJoystickY = true };
|
||||
var wire = new Wire(device);
|
||||
device.SetAxis(RioAxis.JoystickY, 3000);
|
||||
|
||||
Send(device, PacketBuilder.Build(RioCommand.AnalogRequest));
|
||||
|
||||
byte[] p = Assert.Single(wire.Packets).Payload;
|
||||
Assert.Equal(3000, AnalogCodec.Combine(p[6], p[7]));
|
||||
Assert.Equal(3000, device.GetWireAxis(RioAxis.JoystickY));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void VersionRequest_reports_configured_firmware()
|
||||
{
|
||||
@@ -79,22 +116,35 @@ public class VRioDeviceTests
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CheckRequest_reports_every_board_ok()
|
||||
public void CheckRequest_enters_test_mode_reports_boards_then_exits()
|
||||
{
|
||||
var device = new VRioDevice();
|
||||
var wire = new Wire(device);
|
||||
|
||||
Send(device, PacketBuilder.Build(RioCommand.CheckRequest));
|
||||
|
||||
Assert.Equal(RioAddressSpace.Boards.Count, wire.Packets.Count);
|
||||
Assert.All(wire.Packets, p =>
|
||||
// The init handshake: TestModeChange ENTER, one CheckReply per board,
|
||||
// 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((byte)RioStatusType.BoardOk, p.Payload[0]);
|
||||
});
|
||||
Assert.Equal(
|
||||
RioAddressSpace.Boards.Select(b => b.Number),
|
||||
wire.Packets.Select(p => p.Payload[1]));
|
||||
checks.Select(p => p.Payload[1]));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
||||