diff --git a/.gitignore b/.gitignore
index 9010480..8deaa88 100644
--- a/.gitignore
+++ b/.gitignore
@@ -366,7 +366,9 @@ FodyWeavers.xsd
###############################################################################
# WDK / driver build outputs
[Dd]river/**/[Xx]64/
+[Dd]river/package/
*.cer
+*.cat
*.pvk
# Local run-time config that shouldn't be versioned
*.local.json
diff --git a/driver/README.md b/driver/README.md
index 89d42a1..f9f3c7e 100644
--- a/driver/README.md
+++ b/driver/README.md
@@ -47,9 +47,37 @@ producing `RioGamepad\x64\Release\RioGamepad.sys`. The project is a WDK/MSBuild
> `.cat` is produced separately with `inf2cat.exe` and signed with `signtool.exe`
> as part of install (below), rather than during compilation.
-## Signing
+## Test-signing & install (cabinets you own)
-For the cockpit cabinets, enable **test signing** (`bcdedit /set testsigning on`)
-and install a self-signed test certificate — appropriate for hardware you own.
-Redistribution would instead use Microsoft **attestation signing** via Partner
-Center. See the deployment notes in [../docs/PLAN.md](../docs/PLAN.md) (Phase 6).
+Scripts in this folder automate the test-signing flow. Steps marked **(admin)**
+need an elevated shell; everything else is non-admin.
+
+```cmd
+:: 1. Build the driver (non-admin), EWDK mounted at E:
+RioGamepad\build.cmd E:
+
+:: 2. Test-sign: makes a self-signed cert, builds + signs the .cat and .sys,
+:: and exports RIOJoyTest.cer (non-admin)
+powershell -ExecutionPolicy Bypass -File sign.ps1 -Ewdk E:
+
+:: 3. (admin) Trust the cert, stage the driver, enable test signing
+powershell -ExecutionPolicy Bypass -File install.ps1 -Ewdk E:
+
+:: 4. REBOOT (test signing only takes effect after a restart)
+
+:: 5. (admin) Create the device; PnP then installs the staged driver
+powershell -ExecutionPolicy Bypass -File install.ps1 -Ewdk E: -CreateDevice
+```
+
+Then open **`joy.cpl`** and confirm **"RIOJoy Virtual Gamepad"** appears with 6
+axes / POV / 96 buttons. To roll back: `uninstall.ps1 [-DisableTestSigning] [-RemoveCert]`.
+
+The signed package (`package/`) and the exported cert are local artifacts and are
+git-ignored. Redistribution beyond owned hardware would instead use Microsoft
+**attestation signing** via Partner Center (see [../docs/PLAN.md](../docs/PLAN.md),
+Phase 6).
+
+The user-mode side opens this device by `GUID_DEVINTERFACE_RIOGAMEPAD` and feeds
+reports via `IOCTL_RIO_SUBMIT_REPORT` — implemented by
+`RioJoy.Core.Output.HidFeederJoystickSink`, which the tray app uses automatically
+when the driver is present (falling back to a no-op sink when it is not).
diff --git a/driver/install.ps1 b/driver/install.ps1
new file mode 100644
index 0000000..2bdf337
--- /dev/null
+++ b/driver/install.ps1
@@ -0,0 +1,68 @@
+<#
+.SYNOPSIS
+ Trust the test cert, enable test signing, stage and install the RioGamepad
+ driver. REQUIRES AN ELEVATED (admin) shell.
+
+.DESCRIPTION
+ Two phases, because enabling test signing needs a reboot:
+
+ install.ps1 (phase 1, pre-reboot)
+ - import RIOJoyTest.cer into LocalMachine Root + TrustedPublisher
+ - stage the signed package into the driver store (pnputil /add-driver)
+ - bcdedit /set testsigning on
+ - THEN REBOOT
+
+ install.ps1 -CreateDevice (phase 2, after the reboot)
+ - create the root-enumerated device with devgen, which makes PnP install
+ the staged driver. Then check joy.cpl.
+
+ Run driver\sign.ps1 first to produce the signed package + RIOJoyTest.cer.
+
+.PARAMETER Ewdk
+ Drive or root where the EWDK is mounted (default: E:).
+.PARAMETER CreateDevice
+ Run phase 2 (create the device); use after the reboot.
+#>
+param(
+ [string]$Ewdk = "E:",
+ [switch]$CreateDevice
+)
+
+$ErrorActionPreference = "Stop"
+$scriptDir = $PSScriptRoot
+$pkg = Join-Path $scriptDir "package"
+$inf = Join-Path $pkg "RioGamepad.inf"
+$cer = Join-Path $scriptDir "RIOJoyTest.cer"
+$devgen = Join-Path $Ewdk "Program Files\Windows Kits\10\Tools\10.0.28000.0\x64\devgen.exe"
+$hardwareId = "root\RioGamepad"
+
+if (-not ([Security.Principal.WindowsPrincipal][Security.Principal.WindowsIdentity]::GetCurrent()).IsInRole(
+ [Security.Principal.WindowsBuiltInRole]::Administrator)) {
+ throw "This script must be run from an elevated (Administrator) shell."
+}
+
+if ($CreateDevice) {
+ if (-not (Test-Path $devgen)) { throw "devgen not found: $devgen" }
+ Write-Host "Creating device node ($hardwareId)..."
+ & $devgen /add /instanceid "RIOJOY01" /hardwareid $hardwareId
+ Write-Host ""
+ Write-Host "Done. Open 'joy.cpl' and look for 'RIOJoy Virtual Gamepad'." -ForegroundColor Green
+ Write-Host "If it shows a problem, check Device Manager and 'pnputil /enum-drivers'."
+ return
+}
+
+if (-not (Test-Path $inf)) { throw "Signed package not found ($inf). Run driver\sign.ps1 first." }
+if (-not (Test-Path $cer)) { throw "Certificate not found ($cer). Run driver\sign.ps1 first." }
+
+Write-Host "Trusting test certificate (LocalMachine Root + TrustedPublisher)..."
+Import-Certificate -FilePath $cer -CertStoreLocation Cert:\LocalMachine\Root | Out-Null
+Import-Certificate -FilePath $cer -CertStoreLocation Cert:\LocalMachine\TrustedPublisher | Out-Null
+
+Write-Host "Staging driver into the driver store..."
+pnputil /add-driver $inf
+
+Write-Host "Enabling test signing..."
+bcdedit /set testsigning on | Out-Null
+
+Write-Host ""
+Write-Host "Phase 1 complete. REBOOT, then run: install.ps1 -CreateDevice" -ForegroundColor Yellow
diff --git a/driver/sign.ps1 b/driver/sign.ps1
new file mode 100644
index 0000000..ac48be4
--- /dev/null
+++ b/driver/sign.ps1
@@ -0,0 +1,75 @@
+<#
+.SYNOPSIS
+ Test-sign the RioGamepad driver package. No admin required.
+
+.DESCRIPTION
+ Creates (once) a self-signed code-signing certificate in the current user's
+ store, stages the built RioGamepad.sys + RioGamepad.inf into driver\package\,
+ generates the security catalog with Inf2Cat, and signs both the .sys and the
+ .cat with SHA-256. Exports the public cert (RIOJoyTest.cer) for the install
+ step to trust.
+
+ Order matters: the .sys is embed-signed BEFORE the catalog is generated, so the
+ catalog hashes the signed binary; then the catalog itself is signed.
+
+.PARAMETER Ewdk
+ Drive or root where the EWDK is mounted (default: E:).
+#>
+param([string]$Ewdk = "E:")
+
+$ErrorActionPreference = "Stop"
+$scriptDir = $PSScriptRoot
+$projectDir = Join-Path $scriptDir "RioGamepad"
+$buildOut = Join-Path $projectDir "x64\Release"
+$pkg = Join-Path $scriptDir "package"
+
+$kitsBin = Join-Path $Ewdk "Program Files\Windows Kits\10\bin\10.0.28000.0"
+$signtool = Join-Path $kitsBin "x64\signtool.exe"
+$inf2cat = Join-Path $kitsBin "x86\Inf2Cat.exe"
+$certName = "RIOJoy Test Cert"
+$cerPath = Join-Path $scriptDir "RIOJoyTest.cer"
+
+foreach ($tool in @($signtool, $inf2cat)) {
+ if (-not (Test-Path $tool)) { throw "Tool not found: $tool (is the EWDK mounted at $Ewdk?)" }
+}
+if (-not (Test-Path (Join-Path $buildOut "RioGamepad.sys"))) {
+ throw "RioGamepad.sys not found. Build first: driver\RioGamepad\build.cmd $Ewdk"
+}
+
+# 1. Self-signed code-signing certificate (idempotent).
+$cert = Get-ChildItem Cert:\CurrentUser\My | Where-Object { $_.Subject -eq "CN=$certName" } | Select-Object -First 1
+if (-not $cert) {
+ Write-Host "Creating self-signed code-signing certificate '$certName'..."
+ $cert = New-SelfSignedCertificate -Type CodeSigningCert -Subject "CN=$certName" `
+ -CertStoreLocation Cert:\CurrentUser\My -KeyExportPolicy Exportable `
+ -NotAfter (Get-Date).AddYears(5)
+}
+Export-Certificate -Cert $cert -FilePath $cerPath -Force | Out-Null
+Write-Host "Certificate thumbprint: $($cert.Thumbprint)"
+Write-Host "Public cert exported to: $cerPath"
+
+# 2. Stage the package.
+if (Test-Path $pkg) { Remove-Item $pkg -Recurse -Force }
+New-Item -ItemType Directory -Path $pkg | Out-Null
+Copy-Item (Join-Path $buildOut "RioGamepad.sys") $pkg
+Copy-Item (Join-Path $buildOut "RioGamepad.inf") $pkg
+
+# 3. Embed-sign the .sys.
+& $signtool sign /fd SHA256 /s My /n $certName (Join-Path $pkg "RioGamepad.sys")
+if ($LASTEXITCODE -ne 0) { throw "signtool failed on RioGamepad.sys ($LASTEXITCODE)" }
+
+# 4. Generate the catalog (Win10 x64).
+& $inf2cat /driver:$pkg /os:10_X64 /verbose
+if ($LASTEXITCODE -ne 0) { throw "inf2cat failed ($LASTEXITCODE)" }
+
+# 5. Sign the catalog.
+& $signtool sign /fd SHA256 /s My /n $certName (Join-Path $pkg "RioGamepad.cat")
+if ($LASTEXITCODE -ne 0) { throw "signtool failed on RioGamepad.cat ($LASTEXITCODE)" }
+
+Write-Host ""
+Write-Host "Signed package ready in: $pkg" -ForegroundColor Green
+Write-Host "Next: run driver\install.ps1 from an elevated shell to trust the cert," -ForegroundColor Yellow
+Write-Host "enable test signing, and install the device." -ForegroundColor Yellow
+Write-Host ""
+Write-Host "(Note: 'signtool verify /pa' will fail until the cert is trusted by install.ps1 —" -ForegroundColor DarkGray
+Write-Host " that is expected for a self-signed test driver.)" -ForegroundColor DarkGray
diff --git a/driver/uninstall.ps1 b/driver/uninstall.ps1
new file mode 100644
index 0000000..227ee13
--- /dev/null
+++ b/driver/uninstall.ps1
@@ -0,0 +1,61 @@
+<#
+.SYNOPSIS
+ Remove the RioGamepad device and driver. REQUIRES AN ELEVATED shell.
+
+.DESCRIPTION
+ Removes the device node, deletes the driver package from the store, and
+ (optionally) turns test signing back off and removes the trusted test cert.
+
+.PARAMETER DisableTestSigning
+ Also run 'bcdedit /set testsigning off' (needs a reboot to take effect).
+.PARAMETER RemoveCert
+ Also remove the RIOJoy test certificate from the machine trust stores.
+#>
+param(
+ [switch]$DisableTestSigning,
+ [switch]$RemoveCert
+)
+
+$ErrorActionPreference = "Continue"
+
+if (-not ([Security.Principal.WindowsPrincipal][Security.Principal.WindowsIdentity]::GetCurrent()).IsInRole(
+ [Security.Principal.WindowsBuiltInRole]::Administrator)) {
+ throw "This script must be run from an elevated (Administrator) shell."
+}
+
+# 1. Remove the device instance(s) matching our hardware id.
+Write-Host "Removing RioGamepad device node(s)..."
+$instances = (pnputil /enum-devices /connected /class System) 2>$null
+& pnputil /remove-device /deviceid "root\RioGamepad" 2>$null
+
+# 2. Delete the driver package(s) from the store (oem*.inf published from RioGamepad.inf).
+Write-Host "Locating staged driver package..."
+$drivers = pnputil /enum-drivers
+$published = $null
+for ($i = 0; $i -lt $drivers.Count; $i++) {
+ if ($drivers[$i] -match 'Original Name:\s*RioGamepad\.inf') {
+ for ($j = $i; $j -ge 0; $j--) {
+ if ($drivers[$j] -match 'Published Name:\s*(oem\d+\.inf)') { $published = $Matches[1]; break }
+ }
+ }
+}
+if ($published) {
+ Write-Host "Deleting $published ..."
+ pnputil /delete-driver $published /uninstall /force
+} else {
+ Write-Host "No staged RioGamepad driver package found."
+}
+
+if ($RemoveCert) {
+ Write-Host "Removing test certificate from trust stores..."
+ Get-ChildItem Cert:\LocalMachine\Root, Cert:\LocalMachine\TrustedPublisher |
+ Where-Object { $_.Subject -eq "CN=RIOJoy Test Cert" } |
+ ForEach-Object { Remove-Item $_.PSPath -Force }
+}
+
+if ($DisableTestSigning) {
+ Write-Host "Disabling test signing (reboot required)..."
+ bcdedit /set testsigning off | Out-Null
+}
+
+Write-Host "Done."
diff --git a/src/RioJoy.Core/Output/HidFeederJoystickSink.cs b/src/RioJoy.Core/Output/HidFeederJoystickSink.cs
new file mode 100644
index 0000000..f515f6b
--- /dev/null
+++ b/src/RioJoy.Core/Output/HidFeederJoystickSink.cs
@@ -0,0 +1,191 @@
+using System.Runtime.InteropServices;
+using System.Runtime.Versioning;
+using Microsoft.Win32.SafeHandles;
+using RioJoy.Core.Calibration;
+using RioJoy.Core.Hid;
+using RioJoy.Core.Mapping;
+
+namespace RioJoy.Core.Output;
+
+///
+/// that feeds the RioGamepad virtual HID driver: it
+/// opens the driver's device interface, maintains the current
+/// , and submits the whole report on every change via
+/// DeviceIoControl(IOCTL_RIO_SUBMIT_REPORT). Replaces
+/// once the driver is installed (Phase 1/3b).
+///
+[SupportedOSPlatform("windows")]
+public sealed class HidFeederJoystickSink : IJoystickSink, IDisposable
+{
+ // Must match driver/RioGamepad/Public.h.
+ private static readonly Guid InterfaceGuid =
+ new(0xb6a3f1c2, 0x7e84, 0x4d2a, 0x9c, 0x1f, 0x2a, 0x5e, 0x8d, 0x3b, 0x60, 0x71);
+
+ // CTL_CODE(FILE_DEVICE_UNKNOWN=0x22, 0x800, METHOD_BUFFERED=0, FILE_WRITE_ACCESS=0x2).
+ private const uint IoctlSubmitReport = (0x22u << 16) | (0x2u << 14) | (0x800u << 2);
+
+ private readonly SafeFileHandle _device;
+ private readonly RioHidReport _report = new();
+ private readonly object _gate = new();
+
+ private HidFeederJoystickSink(SafeFileHandle device) => _device = device;
+
+ ///
+ /// Try to open the virtual joystick driver. Returns if
+ /// the driver is not installed/present, so callers can fall back to
+ /// .
+ ///
+ public static bool TryCreate(out HidFeederJoystickSink? sink)
+ {
+ sink = null;
+ string? path = FindDevicePath();
+ if (path is null)
+ return false;
+
+ SafeFileHandle handle = CreateFileW(
+ path,
+ GENERIC_WRITE,
+ FILE_SHARE_READ | FILE_SHARE_WRITE,
+ IntPtr.Zero,
+ OPEN_EXISTING,
+ 0,
+ IntPtr.Zero);
+
+ if (handle.IsInvalid)
+ {
+ handle.Dispose();
+ return false;
+ }
+
+ sink = new HidFeederJoystickSink(handle);
+ sink.Submit(); // push the centered rest state
+ return true;
+ }
+
+ public void SetButton(int button, bool pressed)
+ {
+ lock (_gate)
+ {
+ _report.SetButton(button, pressed);
+ Submit();
+ }
+ }
+
+ public void SetHat(RioHat position)
+ {
+ lock (_gate)
+ {
+ _report.SetHat(position);
+ Submit();
+ }
+ }
+
+ public void SetAxis(JoyAxis axis, int value)
+ {
+ lock (_gate)
+ {
+ _report.SetAxis(axis, value);
+ Submit();
+ }
+ }
+
+ private void Submit()
+ {
+ byte[] buffer = _report.ToArray();
+ if (!DeviceIoControl(_device, IoctlSubmitReport, buffer, (uint)buffer.Length,
+ IntPtr.Zero, 0, out _, IntPtr.Zero))
+ {
+ // Device may have been removed; surface for the caller's error handling.
+ throw new InvalidOperationException(
+ $"IOCTL_RIO_SUBMIT_REPORT failed (Win32 {Marshal.GetLastWin32Error()}).");
+ }
+ }
+
+ public void Dispose() => _device.Dispose();
+
+ // --- SetupAPI device-interface resolution --------------------------------
+
+ private static string? FindDevicePath()
+ {
+ Guid guid = InterfaceGuid;
+ IntPtr devInfo = SetupDiGetClassDevs(ref guid, IntPtr.Zero, IntPtr.Zero,
+ DIGCF_PRESENT | DIGCF_DEVICEINTERFACE);
+ if (devInfo == INVALID_HANDLE_VALUE)
+ return null;
+
+ try
+ {
+ var ifData = new SP_DEVICE_INTERFACE_DATA();
+ ifData.cbSize = Marshal.SizeOf();
+
+ if (!SetupDiEnumDeviceInterfaces(devInfo, IntPtr.Zero, ref guid, 0, ref ifData))
+ return null;
+
+ uint required = 0;
+ SetupDiGetDeviceInterfaceDetail(devInfo, ref ifData, IntPtr.Zero, 0, ref required, IntPtr.Zero);
+ if (required == 0)
+ return null;
+
+ IntPtr detail = Marshal.AllocHGlobal((int)required);
+ try
+ {
+ // SP_DEVICE_INTERFACE_DETAIL_DATA.cbSize: 8 on x64, 6 on x86.
+ Marshal.WriteInt32(detail, IntPtr.Size == 8 ? 8 : 6);
+ if (!SetupDiGetDeviceInterfaceDetail(devInfo, ref ifData, detail, required, ref required, IntPtr.Zero))
+ return null;
+
+ // DevicePath follows the cbSize DWORD.
+ return Marshal.PtrToStringUni(IntPtr.Add(detail, 4));
+ }
+ finally
+ {
+ Marshal.FreeHGlobal(detail);
+ }
+ }
+ finally
+ {
+ SetupDiDestroyDeviceInfoList(devInfo);
+ }
+ }
+
+ private const int DIGCF_PRESENT = 0x02;
+ private const int DIGCF_DEVICEINTERFACE = 0x10;
+ private const uint GENERIC_WRITE = 0x40000000;
+ private const uint FILE_SHARE_READ = 0x01;
+ private const uint FILE_SHARE_WRITE = 0x02;
+ private const uint OPEN_EXISTING = 3;
+ private static readonly IntPtr INVALID_HANDLE_VALUE = new(-1);
+
+ [StructLayout(LayoutKind.Sequential)]
+ private struct SP_DEVICE_INTERFACE_DATA
+ {
+ public int cbSize;
+ public Guid InterfaceClassGuid;
+ public int Flags;
+ public IntPtr Reserved;
+ }
+
+ [DllImport("setupapi.dll", SetLastError = true)]
+ private static extern IntPtr SetupDiGetClassDevs(ref Guid ClassGuid, IntPtr Enumerator, IntPtr hwndParent, int Flags);
+
+ [DllImport("setupapi.dll", SetLastError = true)]
+ private static extern bool SetupDiEnumDeviceInterfaces(IntPtr DeviceInfoSet, IntPtr DeviceInfoData,
+ ref Guid InterfaceClassGuid, uint MemberIndex, ref SP_DEVICE_INTERFACE_DATA DeviceInterfaceData);
+
+ [DllImport("setupapi.dll", SetLastError = true, CharSet = CharSet.Unicode)]
+ private static extern bool SetupDiGetDeviceInterfaceDetail(IntPtr DeviceInfoSet,
+ ref SP_DEVICE_INTERFACE_DATA DeviceInterfaceData, IntPtr DeviceInterfaceDetailData,
+ uint DeviceInterfaceDetailDataSize, ref uint RequiredSize, IntPtr DeviceInfoData);
+
+ [DllImport("setupapi.dll", SetLastError = true)]
+ private static extern bool SetupDiDestroyDeviceInfoList(IntPtr DeviceInfoSet);
+
+ [DllImport("kernel32.dll", SetLastError = true, CharSet = CharSet.Unicode)]
+ private static extern SafeFileHandle CreateFileW(string lpFileName, uint dwDesiredAccess, uint dwShareMode,
+ IntPtr lpSecurityAttributes, uint dwCreationDisposition, uint dwFlagsAndAttributes, IntPtr hTemplateFile);
+
+ [DllImport("kernel32.dll", SetLastError = true)]
+ private static extern bool DeviceIoControl(SafeFileHandle hDevice, uint dwIoControlCode,
+ byte[] lpInBuffer, uint nInBufferSize, IntPtr lpOutBuffer, uint nOutBufferSize,
+ out uint lpBytesReturned, IntPtr lpOverlapped);
+}
diff --git a/src/RioJoy.Tray/RioCoordinator.cs b/src/RioJoy.Tray/RioCoordinator.cs
index c9a57be..bc3c8fe 100644
--- a/src/RioJoy.Tray/RioCoordinator.cs
+++ b/src/RioJoy.Tray/RioCoordinator.cs
@@ -28,6 +28,7 @@ public sealed class RioCoordinator : IDisposable
private RioSerialLink? _link;
private RioRuntime? _runtime;
private CancellationTokenSource? _cts;
+ private IDisposable? _joystick;
private string? _activeProfileName;
public RioCoordinator(Func config, Func? transportFactory = null)
@@ -104,20 +105,37 @@ public sealed class RioCoordinator : IDisposable
try
{
+ // Use the real virtual-joystick driver if installed; otherwise fall back
+ // to a no-op sink (keyboard/mouse still work) and note it in the status.
+ IJoystickSink joystick;
+ string joystickNote;
+ if (HidFeederJoystickSink.TryCreate(out HidFeederJoystickSink? feeder))
+ {
+ joystick = feeder!;
+ _joystick = feeder;
+ joystickNote = string.Empty;
+ }
+ else
+ {
+ joystick = new NullJoystickSink();
+ _joystick = null;
+ joystickNote = " [no joystick driver]";
+ }
+
_transport = _transportFactory(port);
_link = new RioSerialLink(_transport);
_runtime = new RioRuntime(
_link,
profile.ToInputMap(),
new SendInputSink(),
- new NullJoystickSink(),
+ joystick,
new AxisCalibrator(profile.Calibration));
_cts = new CancellationTokenSource();
_ = _link.RunAsync(_cts.Token);
_runtime.Start();
_activeProfileName = profile.Name;
- SetStatus($"Active: {profile.Name} ({_link.Description})");
+ SetStatus($"Active: {profile.Name} ({_link.Description}){joystickNote}");
}
catch (Exception ex)
{
@@ -143,6 +161,9 @@ public sealed class RioCoordinator : IDisposable
_link = null;
+ _joystick?.Dispose(); // closes the HID feeder handle
+ _joystick = null;
+
_transport?.Dispose(); // releases the COM port
_transport = null;