Phase 1 (3b): driver signing/install scripts + C# HID feeder sink

Make the virtual gamepad deployable on owned cabinets and wire the real
user-mode feeder:

- driver/sign.ps1 (non-admin): create a self-signed code-signing cert, build the
  catalog with inf2cat, and SHA-256-sign RioGamepad.sys + .cat (embed-sign the sys
  before cataloguing so the .cat matches). Exports RIOJoyTest.cer. Verified
  end-to-end against the EWDK (signability + catalog clean; both files signed).
- driver/install.ps1 (admin, two-phase): trust the cert (LocalMachine Root +
  TrustedPublisher), stage the package (pnputil /add-driver), enable test signing;
  after reboot, -CreateDevice runs devgen to create root\RioGamepad so PnP installs
  it. uninstall.ps1 reverses it.
- RioJoy.Core.Output.HidFeederJoystickSink: opens the driver by
  GUID_DEVINTERFACE_RIOGAMEPAD (SetupAPI), maintains a RioHidReport, and submits it
  via DeviceIoControl(IOCTL_RIO_SUBMIT_REPORT) on each axis/button/hat change.
  RioCoordinator now uses it when the driver is present and falls back to the no-op
  sink otherwise (status shows "[no joystick driver]").
- gitignore the signing outputs (driver/package/, *.cat); driver/README.md gets the
  full build → sign → install → joy.cpl workflow.

Remaining = the actual elevated install + reboot + joy.cpl verification on the
cabinet (admin steps), and on-hardware confirmation of the feeder.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Cyd
2026-06-26 21:17:04 -05:00
co-authored by Claude Opus 4.8
parent 24cdf495e3
commit 5dddbd2694
7 changed files with 453 additions and 7 deletions
+2
View File
@@ -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
+33 -5
View File
@@ -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).
+68
View File
@@ -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
+75
View File
@@ -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
+61
View File
@@ -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."
@@ -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;
/// <summary>
/// <see cref="IJoystickSink"/> that feeds the RioGamepad virtual HID driver: it
/// opens the driver's device interface, maintains the current
/// <see cref="RioHidReport"/>, and submits the whole report on every change via
/// <c>DeviceIoControl(IOCTL_RIO_SUBMIT_REPORT)</c>. Replaces
/// <see cref="NullJoystickSink"/> once the driver is installed (Phase 1/3b).
/// </summary>
[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;
/// <summary>
/// Try to open the virtual joystick driver. Returns <see langword="false"/> if
/// the driver is not installed/present, so callers can fall back to
/// <see cref="NullJoystickSink"/>.
/// </summary>
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<SP_DEVICE_INTERFACE_DATA>();
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);
}
+23 -2
View File
@@ -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<AppConfig> config, Func<string, IRioTransport>? 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;