Author SHA1 Message Date
CydandClaude Fable 5 767473d1cf Project verify skill: drive the GUI via PostMessage + PrintWindow
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-07 09:47:35 -05:00
CydandClaude Fable 5 007d15e668 Wire carries stick-up as negative; Invert Y opts back to physical
The old Invert joystick Y box (checked by default) is now the wire's
native convention: full up = -80 in AnalogReply with the flag off.
Checking the renamed Invert Y sends the physical direction (up = +80).

Control strip cleanup: test-mode buttons and the firmware selector are
gone (the device still reports 4.2), Keyboard/Gamepad/Invert Y share
one line, the help text drops the bindings sentence, and the wire log
gets the reclaimed height.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-07 09:47:25 -05:00
CydandClaude Opus 4.8 391c53f294 Capture a chosen keyboard's keys without window focus (Raw Input)
The WinForms focus path only sees keys while vRIO is foreground and can't
tell one keyboard from another, so vRIO went deaf the moment the sim took
focus. The new "Capture keyboard" picker in the Input panel taps one
physical keyboard through the Win32 Raw Input API (RIDEV_INPUTSINK) and
routes its keys to the panel even in the background — the input-side twin
of the lamp mirror writing that keyboard's LEDs under the same condition.

Raw Input observes without intercepting, so keys still reach the focused
app; the picker defaults to "All keyboards (focus only)", preserving the
prior focus-bound behavior, and only registers Raw Input while a specific
device is selected. While capturing, the focus path stands down but still
swallows bound keys so they can't click panel buttons.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-06 21:31:49 -05:00
CydandClaude Fable 5 bec3bb1e4a Sparse package icons need resources.pri at the external location
The shell resolves Square44x44Logo through the package resource index;
this package never had a resources.pri, so every icon lookup failed and
Start/taskbar showed a generic icon regardless of the Assets PNGs.
Empirically (SHLoadIndirectString against the live package), with
AllowExternalContent MRT loads resources from the EXTERNAL location,
not the manifest's folder — so Register-vRIO.ps1 now copies Assets\ and
resources.pri next to the exe before registering. Make-Assets.ps1
regenerates the PNGs from vwe.ico and rebuilds the pri (needs makepri
from the Windows SDK / SDK.BuildTools NuGet). Package version bumped;
the MrtCache is keyed by package full name, so same-version
re-registration serves stale lookups.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-06 19:43:37 -05:00
CydandClaude Fable 5 6923c9f252 Panel readout shows the wire values while the dot stays physical
With Invert Y checked the readout claimed "Y 80" while the AnalogReply
carried -80. The flip is factored into VRioDevice.WireY, shared by the
reply builder and the new GetWireAxis accessor; the canvas readout uses
a WireAxisProvider fed from it, and toggling the checkbox repaints so
the sign flips in place. Dot and gauges keep tracking the stick.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-06 19:33:28 -05:00
CydandClaude Fable 5 23204ba9c6 Package assets carry the vwe.ico artwork so the taskbar shows it
With package identity the taskbar/Start icon comes from the manifest's
Square44x44Logo assets, not the exe's embedded icon — the placeholder
PNGs are replaced with renders of vwe.ico, plus the targetsize /
altform-unplated variants the Win11 taskbar prefers.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-06 19:28:31 -05:00
CydandClaude Fable 5 97e78b0eea App icon: vwe.ico as the exe and window icon
ApplicationIcon embeds it in the exe (Explorer, taskbar); MainForm pulls
the same embedded icon for the title bar via ExtractAssociatedIcon.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-06 19:22:30 -05:00
CydandClaude Fable 5 ce5ed1117a Invert Y on the wire, not in the router: the panel keeps tracking the stick
InputRouter.InvertJoystickY flipped the stored axis, so the X/Y dot and
readout moved opposite the physical stick. The toggle now lives on
VRioDevice and negates joystick Y only while building AnalogReply: local
state keeps the physical direction for every source (pad, keys, panel
drags) and only the host sees the flip. Negating a full -8192 deflection
lands one past the 14-bit Max, so the reply clamps it to 8191.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-06 19:21:57 -05:00
CydandClaude Fable 5 41f6ef364d Invert-Y input toggle; git-stamped version in the window title
- InputRouter.InvertJoystickY flips the composed joystick Y (keys and
  pad alike) before wire scaling; panel drags write the device directly
  and stay untouched. New "Invert joystick Y" checkbox in the Input
  group, default on.
- StampGitVersion target bakes "YYYY.MM.DD (shortsha)" of HEAD into
  InformationalVersion; the title shows it via Application.ProductVersion
  so a running build can be matched to its vYYYY.MM.DD release tag.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-06 18:46:02 -05:00
CydandClaude Fable 5 f77cd55b11 pkg: package identity so Dynamic Lighting grants background control
The Settings "Background light control" list only offers apps that have
BOTH package identity and the com.microsoft.windows.lighting
windows.appExtension in their manifest (declaration verified against
Lenovo's LegionLightingController manifest) — without a grant, Windows
hands the LEDs to vRIO only while it is the foreground window, i.e.
never during gameplay.

pkg/AppxManifest.xml is a sparse package (external location) that
grants the existing VRio.App.exe identity as-is: win32App runtime
behavior, runFullTrust + unvirtualizedResources, and the lighting
app-extension. Register-vRIO.ps1 registers/unregisters it against any
exe folder (repo build or an extracted dist zip); unsigned registration
needs Developer Mode. Identity is granted through shell activation
only — launch from the Start menu entry or an AUMID taskbar pin, not by
double-clicking the exe (the script prints this warning).

Verified end-to-end on this machine: process reports the package full
name, vRIO appears in the background-control list, and the wire log
shows the withheld → available transition once granted.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-06 15:08:38 -05:00
CydandClaude Fable 5 31c3a910f0 Mirror lamps onto RGB keyboards via Windows Dynamic Lighting
Opt-in checkbox in the Input group: keys bound to lamp-capable button
addresses glow with the panel's palette (red family, yellow for the
Secondary/Screen columns) and blink at the panel's own flash rates, so
keyboard and on-screen panel stay in step; unbound keys are blacked out
so the keyboard reads as the button field. Zone-lit keyboards without
per-key addressing (e.g. this laptop's 24-zone ITE board) fall back to
mirroring the strongest current lamp board-wide. A picker under the
checkbox narrows the mirror to one keyboard when several are attached
(hot-plug aware; releases the others back to Windows).

KeyboardLampMirror claims keyboard-kind LampArrays via a DeviceWatcher,
repaints at 100 ms only when colors actually change, survives paint
faults (a leaked Timer exception would kill the process), and logs
attach/detach plus IsAvailable transitions with a pointer to the
Dynamic Lighting background-control setting. Disabling releases every
claim so the LEDs revert to the ambient scene.

Also: the wire log gets a horizontal scrollbar (long lines don't wrap).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-06 15:08:38 -05:00
25 changed files with 1358 additions and 44 deletions
+50
View File
@@ -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.
+27 -5
View File
@@ -23,6 +23,17 @@ 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
@@ -37,6 +48,17 @@ controls:
F1F6 / F7F12 = 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
@@ -60,11 +82,10 @@ device behavior grounded in the **real v4.2 firmware dump**
- `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
@@ -86,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
+70
View File
@@ -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>
Binary file not shown.

After

Width:  |  Height:  |  Size: 8.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 696 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 696 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 744 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 744 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

+62
View File
@@ -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.'
+1
View File
@@ -0,0 +1 @@
Public folder for the com.microsoft.windows.lighting app extension (required by the manifest schema).
+60
View File
@@ -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.'
BIN
View File
Binary file not shown.
+431
View File
@@ -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;
}
}
+180 -35
View File
@@ -19,6 +19,9 @@ internal sealed class MainForm : Form
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");
@@ -38,16 +41,13 @@ 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 _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 = "Xbox gamepad", Location = new Point(120, 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 = "No controller detected.",
@@ -57,31 +57,61 @@ internal sealed class MainForm : Form
};
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 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, 336),
Location = new Point(12, 372),
AutoSize = true,
Font = new Font("Consolas", 8f),
};
private readonly Label _help = new()
{
Location = new Point(12, 392),
Location = new Point(12, 428),
MaximumSize = new Size(306, 0),
AutoSize = true,
ForeColor = Color.Gray,
Text = "Left-click a cell: momentary press. Right-click: latch it down. " +
"Drag the X/Y box and the Z / L / R gauges to move the axes. " +
"Keyboard and Xbox-pad input follow the bindings file (Edit bindings…).",
"Drag the X/Y box and the Z / L / R gauges to move the axes.",
};
private readonly TextBox _logBox = new()
{
Location = new Point(12, 476),
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),
@@ -102,7 +132,10 @@ internal sealed class MainForm : Form
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));
@@ -112,6 +145,7 @@ internal sealed class MainForm : Form
_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) };
@@ -123,6 +157,7 @@ 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);
@@ -137,14 +172,13 @@ internal sealed class MainForm : Form
_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 += (_, _) =>
{
@@ -157,8 +191,6 @@ internal sealed class MainForm : Form
_device.ClearLamps();
_canvas.Invalidate();
};
_testEnter.Click += (_, _) => _device.SendTestMode(1);
_testExit.Click += (_, _) => _device.SendTestMode(0);
_clearLog.Click += (_, _) => _logBox.Clear();
_kbInput.CheckedChanged += (_, _) =>
@@ -166,6 +198,34 @@ internal sealed class MainForm : Form
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();
@@ -179,6 +239,8 @@ internal sealed class MainForm : Form
{
_uiTimer.Dispose();
_inputTimer.Dispose();
_lampMirror.Dispose();
_rawKeyboard.Dispose();
_service.Dispose();
};
@@ -209,21 +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, 150) };
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 });
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, 224), Size = new Size(306, 104) };
input.Controls.AddRange(new Control[] { _kbInput, _padInput, _padStatus, _reloadBindings, _editBindings });
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, 458), 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);
@@ -288,11 +346,35 @@ internal sealed class MainForm : Form
/// <summary>
/// Keys route to the panel unless the user is in a control that needs
/// them (port list, firmware spinners, log box scrolling).
/// them (port list, log box scrolling).
/// </summary>
private bool KeyboardRoutingActive =>
_kbInput.Checked &&
!_portBox.ContainsFocus && !_verMajor.ContainsFocus && !_verMinor.ContainsFocus && !_logBox.ContainsFocus;
!_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)
{
@@ -306,7 +388,10 @@ internal sealed class MainForm : Form
string name = (keyData & Keys.KeyCode).ToString();
if (_router.HasKeyBinding(name))
{
_router.KeyDown(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;
}
}
@@ -315,18 +400,57 @@ internal sealed class MainForm : Form
protected override void OnKeyUp(KeyEventArgs e)
{
// Unconditional: the router ignores keys it never saw go down, and a
// release must land even if the checkbox flipped mid-hold.
_router.KeyUp(e.KeyCode.ToString());
// 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)
{
_router.ReleaseAllKeys(); // key-up events are lost once unfocused
// 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;
@@ -370,6 +494,7 @@ internal sealed class MainForm : Form
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})");
@@ -395,6 +520,26 @@ internal sealed class MainForm : Form
}
}
/// <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()
+9 -2
View File
@@ -73,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;
@@ -299,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);
+376
View File
@@ -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;
}
}
}
+29
View File
@@ -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 &quot;$(MSBuildProjectDirectory)&quot; 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 &quot;$(MSBuildProjectDirectory)&quot; 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>
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 KiB

+25 -1
View File
@@ -57,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.
@@ -101,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.
@@ -274,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:
+38 -1
View File
@@ -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()
{