Phase 1: RioGamepad virtual HID driver (KMDF + VHF) + C# report packer

Author the custom virtual HID gamepad that replaces vJoy, and pin its wire
format on both sides. Builds clean to RioGamepad.sys against the EWDK
(KMDF 1.15 + VHF, x64, warnings-as-errors).

driver/RioGamepad/:
- ReportDescriptor.h: 6x16-bit axes (X,Y,Z,Rx,Ry,Rz), one 4-direction hat with
  null state, and 96 buttons — the legacy vJoy layout. 25-byte input report.
- Device.c/Driver.c: KMDF root-enumerated device that creates the VHF virtual HID
  device (VhfCreate in DeviceAdd, VhfStart in D0Entry, VhfDelete on cleanup) and
  exposes a device interface + IOCTL_RIO_SUBMIT_REPORT that forwards the caller's
  report bytes to VhfReadReportSubmit. Thin relay: no report logic in the kernel.
- Public.h: device-interface GUID, IOCTL, and the report byte layout shared with
  the C# client. RioGamepad.inf + build.cmd (EWDK build, catalog/sign disabled).

src/RioJoy.Core/Hid/RioHidReport.cs: packs AxisOutputs + hat + 96 buttons into
the exact 25-byte report (LE axes, hat nibble with 0x0F=centered, button bitmap).
13 new xUnit tests (136 total).

Remaining (deploy-side): test-sign + pnputil install + verify in joy.cpl, and
wire the real DeviceIoControl feeder sink (replacing NullJoystickSink). The
EWDK's in-build catalog task (DrvCat) can't load Microsoft.Kits.Logger on this
image, so the .cat is produced with inf2cat/signtool at install time instead.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Cyd
2026-06-26 21:06:16 -05:00
co-authored by Claude Opus 4.8
parent 1348040e1c
commit 24cdf495e3
13 changed files with 664 additions and 15 deletions
+27 -5
View File
@@ -17,13 +17,35 @@ to Windows via `VhfReadReportSubmit`.
## Status
Phase 0 placeholder. Implementation is **Phase 1** in [../docs/PLAN.md](../docs/PLAN.md).
**Phase 1 — implemented and compiling.** The driver source under
[`RioGamepad/`](RioGamepad/) builds cleanly to `RioGamepad.sys` against the EWDK
(KMDF 1.15 + VHF, x64). It is a thin VHF relay: it creates the virtual HID device
from the report descriptor and, on each `IOCTL_RIO_SUBMIT_REPORT`, forwards the
caller's 25-byte report to `VhfReadReportSubmit`. All report packing lives in the
C# client (`RioJoy.Core.Hid.RioHidReport`, unit-tested) so the wire format is
pinned on both sides ([`RioGamepad/Public.h`](RioGamepad/Public.h)).
## Build prerequisites (Phase 1)
Not yet done: **test-signing + install + verify in `joy.cpl`** (the on-cabinet
step), and wiring the real `DeviceIoControl` feeder sink (replacing the C# side's
`NullJoystickSink`).
- Windows Driver Kit (WDK) for Windows 11 + matching Visual Studio + Windows SDK
- A separate WDK/MSBuild project lives here (`RioGamepad.vcxproj`); it is **not**
part of `RioJoy.sln` (different toolchain).
## Building
With the **EWDK** mounted (e.g. drive `E:`), from this folder:
```cmd
RioGamepad\build.cmd E:
```
This sources the EWDK env (`<EWDK>\BuildEnv\SetupBuildEnv.cmd`) and runs MSBuild,
producing `RioGamepad\x64\Release\RioGamepad.sys`. The project is a WDK/MSBuild
`.vcxproj`; it is **not** part of `RioJoy.sln` (different toolchain).
> **EWDK note:** the build disables the managed catalog task
> (`/p:DriverCatalog_Enable=false`) and auto-signing (`/p:SignMode=Off`). On this
> EWDK image the in-build `DrvCat` task can't load `Microsoft.Kits.Logger`, so the
> `.cat` is produced separately with `inf2cat.exe` and signed with `signtool.exe`
> as part of install (below), rather than during compilation.
## Signing
+154
View File
@@ -0,0 +1,154 @@
/*++
RioGamepad — device creation, VHF setup, and the report-submit IOCTL.
The driver is a thin relay: it creates a virtual HID device via VHF using the
report descriptor in ReportDescriptor.h, exposes a device interface, and on each
IOCTL_RIO_SUBMIT_REPORT forwards the caller's report bytes to the HID stack with
VhfReadReportSubmit. All report packing lives in the user-mode client.
--*/
#include <ntddk.h>
#include <initguid.h> // makes DEFINE_GUID in Public.h allocate the GUID storage
#include "Driver.h"
#include "ReportDescriptor.h"
NTSTATUS
RioGamepadEvtDeviceAdd(
_In_ WDFDRIVER Driver,
_Inout_ PWDFDEVICE_INIT DeviceInit
)
{
NTSTATUS status;
WDFDEVICE device;
WDF_OBJECT_ATTRIBUTES deviceAttributes;
WDF_PNPPOWER_EVENT_CALLBACKS pnpPowerCallbacks;
WDF_IO_QUEUE_CONFIG queueConfig;
PDEVICE_CONTEXT deviceContext;
VHF_CONFIG vhfConfig;
UNREFERENCED_PARAMETER(Driver);
WDF_PNPPOWER_EVENT_CALLBACKS_INIT(&pnpPowerCallbacks);
pnpPowerCallbacks.EvtDeviceD0Entry = RioGamepadEvtDeviceD0Entry;
WdfDeviceInitSetPnpPowerEventCallbacks(DeviceInit, &pnpPowerCallbacks);
WDF_OBJECT_ATTRIBUTES_INIT_CONTEXT_TYPE(&deviceAttributes, DEVICE_CONTEXT);
deviceAttributes.EvtCleanupCallback = RioGamepadEvtDeviceCleanup;
status = WdfDeviceCreate(&DeviceInit, &deviceAttributes, &device);
if (!NT_SUCCESS(status))
{
return status;
}
deviceContext = DeviceGetContext(device);
deviceContext->VhfHandle = NULL;
status = WdfDeviceCreateDeviceInterface(device, &GUID_DEVINTERFACE_RIOGAMEPAD, NULL);
if (!NT_SUCCESS(status))
{
return status;
}
//
// Default parallel queue for the report-submit IOCTL.
//
WDF_IO_QUEUE_CONFIG_INIT_DEFAULT_QUEUE(&queueConfig, WdfIoQueueDispatchParallel);
queueConfig.EvtIoDeviceControl = RioGamepadEvtIoDeviceControl;
status = WdfIoQueueCreate(device, &queueConfig, WDF_NO_OBJECT_ATTRIBUTES, NULL);
if (!NT_SUCCESS(status))
{
return status;
}
//
// Create the virtual HID device. VhfStart is deferred to D0Entry.
//
VHF_CONFIG_INIT(
&vhfConfig,
WdfDeviceWdmGetDeviceObject(device),
(USHORT)sizeof(g_RioReportDescriptor),
(PUCHAR)g_RioReportDescriptor);
vhfConfig.VendorID = RIO_VENDOR_ID;
vhfConfig.ProductID = RIO_PRODUCT_ID;
vhfConfig.VersionNumber = RIO_VERSION;
status = VhfCreate(&vhfConfig, &deviceContext->VhfHandle);
if (!NT_SUCCESS(status))
{
deviceContext->VhfHandle = NULL;
return status;
}
return STATUS_SUCCESS;
}
NTSTATUS
RioGamepadEvtDeviceD0Entry(
_In_ WDFDEVICE Device,
_In_ WDF_POWER_DEVICE_STATE PreviousState
)
{
PDEVICE_CONTEXT deviceContext = DeviceGetContext(Device);
UNREFERENCED_PARAMETER(PreviousState);
return VhfStart(deviceContext->VhfHandle);
}
VOID
RioGamepadEvtDeviceCleanup(
_In_ WDFOBJECT Object
)
{
PDEVICE_CONTEXT deviceContext = DeviceGetContext((WDFDEVICE)Object);
if (deviceContext->VhfHandle != NULL)
{
VhfDelete(deviceContext->VhfHandle, TRUE);
deviceContext->VhfHandle = NULL;
}
}
VOID
RioGamepadEvtIoDeviceControl(
_In_ WDFQUEUE Queue,
_In_ WDFREQUEST Request,
_In_ size_t OutputBufferLength,
_In_ size_t InputBufferLength,
_In_ ULONG IoControlCode
)
{
NTSTATUS status = STATUS_INVALID_DEVICE_REQUEST;
PDEVICE_CONTEXT deviceContext = DeviceGetContext(WdfIoQueueGetDevice(Queue));
UNREFERENCED_PARAMETER(OutputBufferLength);
if (IoControlCode == IOCTL_RIO_SUBMIT_REPORT)
{
if (InputBufferLength != RIO_REPORT_SIZE)
{
status = STATUS_INVALID_BUFFER_SIZE;
}
else
{
PVOID buffer;
size_t bufferSize;
status = WdfRequestRetrieveInputBuffer(Request, RIO_REPORT_SIZE, &buffer, &bufferSize);
if (NT_SUCCESS(status))
{
HID_XFER_PACKET packet;
packet.reportId = 0;
packet.reportBuffer = (PUCHAR)buffer;
packet.reportBufferLen = RIO_REPORT_SIZE;
status = VhfReadReportSubmit(deviceContext->VhfHandle, &packet);
}
}
}
WdfRequestComplete(Request, status);
}
+26
View File
@@ -0,0 +1,26 @@
/*++
RioGamepad — DriverEntry and driver object setup.
--*/
#include "Driver.h"
NTSTATUS
DriverEntry(
_In_ PDRIVER_OBJECT DriverObject,
_In_ PUNICODE_STRING RegistryPath
)
{
WDF_DRIVER_CONFIG config;
NTSTATUS status;
WDF_DRIVER_CONFIG_INIT(&config, RioGamepadEvtDeviceAdd);
status = WdfDriverCreate(
DriverObject,
RegistryPath,
WDF_NO_OBJECT_ATTRIBUTES,
&config,
WDF_NO_HANDLE);
return status;
}
+29
View File
@@ -0,0 +1,29 @@
/*++
RioGamepad — driver-wide declarations.
--*/
#pragma once
#include <ntddk.h>
#include <wdf.h>
#include <vhf.h> // VHF API; also defines HID_XFER_PACKET if hidclass.h absent
#include "Public.h"
//
// Per-device context: the VHF handle for the virtual HID device.
//
typedef struct _DEVICE_CONTEXT
{
VHFHANDLE VhfHandle;
} DEVICE_CONTEXT, *PDEVICE_CONTEXT;
WDF_DECLARE_CONTEXT_TYPE_WITH_NAME(DEVICE_CONTEXT, DeviceGetContext)
//
// WDF event callbacks.
//
DRIVER_INITIALIZE DriverEntry;
EVT_WDF_DRIVER_DEVICE_ADD RioGamepadEvtDeviceAdd;
EVT_WDF_DEVICE_D0_ENTRY RioGamepadEvtDeviceD0Entry;
EVT_WDF_OBJECT_CONTEXT_CLEANUP RioGamepadEvtDeviceCleanup;
EVT_WDF_IO_QUEUE_IO_DEVICE_CONTROL RioGamepadEvtIoDeviceControl;
+36
View File
@@ -0,0 +1,36 @@
/*++
RioGamepad — shared definitions for the virtual HID gamepad driver and its
user-mode client (the RIOJoy tray app's HID feeder).
The input report is fixed-size and has no report ID:
bytes 0..11 : 6 axes (X,Y,Z,Rx,Ry,Rz), each unsigned 16-bit little-endian
byte 12 : low nibble = hat (0..3; 0x0F = centered/null), high nibble pad
bytes 13..24 : 96 button bits (button N at byte 13 + (N-1)/8, bit (N-1)%8)
--*/
#pragma once
//
// Device interface GUID the user-mode client opens to submit reports.
// {b6a3f1c2-7e84-4d2a-9c1f-2a5e8d3b6071}
//
DEFINE_GUID(GUID_DEVINTERFACE_RIOGAMEPAD,
0xb6a3f1c2, 0x7e84, 0x4d2a, 0x9c, 0x1f, 0x2a, 0x5e, 0x8d, 0x3b, 0x60, 0x71);
//
// Custom IOCTL: submit one input report (input buffer = RIO_REPORT_SIZE bytes).
//
#define IOCTL_RIO_SUBMIT_REPORT \
CTL_CODE(FILE_DEVICE_UNKNOWN, 0x800, METHOD_BUFFERED, FILE_WRITE_ACCESS)
//
// HID input report size in bytes: 6*2 (axes) + 1 (hat+pad) + 12 (96 buttons).
//
#define RIO_REPORT_SIZE 25
//
// Virtual device identity (matches the legacy vJoy layout's intent).
//
#define RIO_VENDOR_ID 0x1209 // pid.codes test/community VID
#define RIO_PRODUCT_ID 0x5249 // 'R','I'
#define RIO_VERSION 0x0100
+56
View File
@@ -0,0 +1,56 @@
/*++
RioGamepad — HID report descriptor: a joystick with 6 16-bit axes (X,Y,Z,Rx,
Ry,Rz), one 4-direction hat with null state, and 96 buttons. This mirrors the
fidelity the legacy app drove through vJoy. The resulting input report is
RIO_REPORT_SIZE (25) bytes; see Public.h for the byte layout.
--*/
#pragma once
static const UCHAR g_RioReportDescriptor[] =
{
0x05, 0x01, // Usage Page (Generic Desktop)
0x09, 0x04, // Usage (Joystick)
0xA1, 0x01, // Collection (Application)
0x09, 0x01, // Usage (Pointer)
0xA1, 0x00, // Collection (Physical)
0x05, 0x01, // Usage Page (Generic Desktop)
0x09, 0x30, // Usage (X)
0x09, 0x31, // Usage (Y)
0x09, 0x32, // Usage (Z)
0x09, 0x33, // Usage (Rx)
0x09, 0x34, // Usage (Ry)
0x09, 0x35, // Usage (Rz)
0x15, 0x00, // Logical Minimum (0)
0x26, 0xFF, 0x7F, // Logical Maximum (32767)
0x75, 0x10, // Report Size (16)
0x95, 0x06, // Report Count (6)
0x81, 0x02, // Input (Data,Var,Abs)
0xC0, // End Collection (Physical)
0x09, 0x39, // Usage (Hat switch)
0x15, 0x00, // Logical Minimum (0)
0x25, 0x03, // Logical Maximum (3)
0x35, 0x00, // Physical Minimum (0)
0x46, 0x0E, 0x01, // Physical Maximum (270)
0x65, 0x14, // Unit (Degrees)
0x75, 0x04, // Report Size (4)
0x95, 0x01, // Report Count (1)
0x81, 0x42, // Input (Data,Var,Abs,Null)
0x65, 0x00, // Unit (None)
0x75, 0x04, // Report Size (4)
0x95, 0x01, // Report Count (1)
0x81, 0x03, // Input (Const,Var,Abs) ; 4-bit padding
0x05, 0x09, // Usage Page (Button)
0x19, 0x01, // Usage Minimum (Button 1)
0x29, 0x60, // Usage Maximum (Button 96)
0x15, 0x00, // Logical Minimum (0)
0x25, 0x01, // Logical Maximum (1)
0x75, 0x01, // Report Size (1)
0x95, 0x60, // Report Count (96)
0x81, 0x02, // Input (Data,Var,Abs)
0xC0 // End Collection (Application)
};
+55
View File
@@ -0,0 +1,55 @@
;
; RioGamepad.inf — RIOJoy virtual HID gamepad (KMDF + VHF), root-enumerated.
;
[Version]
Signature = "$WINDOWS NT$"
Class = System
ClassGuid = {4d36e97d-e325-11ce-bfc1-08002be10318}
Provider = %ManufacturerName%
CatalogFile = RioGamepad.cat
DriverVer =
PnpLockdown = 1
[DestinationDirs]
DefaultDestDir = 13
[SourceDisksNames]
1 = %DiskName%
[SourceDisksFiles]
RioGamepad.sys = 1
[Manufacturer]
%ManufacturerName% = Standard,NT$ARCH$.10.0...16299
[Standard.NT$ARCH$.10.0...16299]
%DeviceName% = RioGamepad_Device, root\RioGamepad
[RioGamepad_Device.NT]
CopyFiles = RioGamepad_CopyFiles
[RioGamepad_CopyFiles]
RioGamepad.sys
[RioGamepad_Device.NT.Services]
AddService = RioGamepad, 0x00000002, RioGamepad_Service
[RioGamepad_Service]
DisplayName = %ServiceName%
ServiceType = 1 ; SERVICE_KERNEL_DRIVER
StartType = 3 ; SERVICE_DEMAND_START
ErrorControl = 1 ; SERVICE_ERROR_NORMAL
ServiceBinary = %13%\RioGamepad.sys
[RioGamepad_Device.NT.Wdf]
KmdfService = RioGamepad, RioGamepad_wdfsect
[RioGamepad_wdfsect]
KmdfLibraryVersion = 1.15
[Strings]
ManufacturerName = "VWE"
DiskName = "RioGamepad Installation Disk"
DeviceName = "RIOJoy Virtual Gamepad"
ServiceName = "RIOJoy Virtual Gamepad Service"
+65
View File
@@ -0,0 +1,65 @@
<?xml version="1.0" encoding="utf-8"?>
<Project DefaultTargets="Build" ToolsVersion="15.0" xmlns="http://schemas.microsoft.com/developer/msbuild/2003">
<ItemGroup Label="ProjectConfigurations">
<ProjectConfiguration Include="Debug|x64">
<Configuration>Debug</Configuration>
<Platform>x64</Platform>
</ProjectConfiguration>
<ProjectConfiguration Include="Release|x64">
<Configuration>Release</Configuration>
<Platform>x64</Platform>
</ProjectConfiguration>
</ItemGroup>
<PropertyGroup Label="Globals">
<ProjectGuid>{4F1E9C2A-6B3D-4E58-9A77-1C2D3E4F5A6B}</ProjectGuid>
<MinimumVisualStudioVersion>12.0</MinimumVisualStudioVersion>
<Configuration Condition="'$(Configuration)' == ''">Release</Configuration>
<Platform Condition="'$(Platform)' == ''">x64</Platform>
<RootNamespace>RioGamepad</RootNamespace>
</PropertyGroup>
<Import Project="$(VCTargetsPath)\Microsoft.Cpp.Default.props" />
<PropertyGroup Condition="'$(Configuration)|$(Platform)'=='Debug|x64'" Label="Configuration">
<TargetVersion>Windows10</TargetVersion>
<UseDebugLibraries>true</UseDebugLibraries>
<PlatformToolset>WindowsKernelModeDriver10.0</PlatformToolset>
<ConfigurationType>Driver</ConfigurationType>
<DriverType>KMDF</DriverType>
<KMDFVersionMajor>1</KMDFVersionMajor>
<KMDFVersionMinor>15</KMDFVersionMinor>
</PropertyGroup>
<PropertyGroup Condition="'$(Configuration)|$(Platform)'=='Release|x64'" Label="Configuration">
<TargetVersion>Windows10</TargetVersion>
<UseDebugLibraries>false</UseDebugLibraries>
<PlatformToolset>WindowsKernelModeDriver10.0</PlatformToolset>
<ConfigurationType>Driver</ConfigurationType>
<DriverType>KMDF</DriverType>
<KMDFVersionMajor>1</KMDFVersionMajor>
<KMDFVersionMinor>15</KMDFVersionMinor>
</PropertyGroup>
<Import Project="$(VCTargetsPath)\Microsoft.Cpp.props" />
<ItemDefinitionGroup>
<ClCompile>
<WppEnabled>false</WppEnabled>
<TreatWarningAsError>true</TreatWarningAsError>
</ClCompile>
<Link>
<AdditionalDependencies>%(AdditionalDependencies);$(DDK_LIB_PATH)vhfkm.lib</AdditionalDependencies>
</Link>
</ItemDefinitionGroup>
<ItemGroup>
<ClCompile Include="Driver.c" />
<ClCompile Include="Device.c" />
</ItemGroup>
<ItemGroup>
<ClInclude Include="Driver.h" />
<ClInclude Include="Public.h" />
<ClInclude Include="ReportDescriptor.h" />
</ItemGroup>
<ItemGroup>
<Inf Include="RioGamepad.inf" />
</ItemGroup>
<ItemGroup>
<FilesToPackage Include="$(TargetPath)" />
</ItemGroup>
<Import Project="$(VCTargetsPath)\Microsoft.Cpp.targets" />
</Project>
+26
View File
@@ -0,0 +1,26 @@
@echo off
REM Build RioGamepad.sys against a mounted Enterprise WDK (EWDK).
REM Usage: build.cmd [EWDK_DriveOrRoot] (default: E:)
REM
REM Produces driver\RioGamepad\x64\Release\RioGamepad.sys.
REM Catalog (.cat) creation and signing are intentionally disabled here — that is
REM the deploy/test-sign step (see driver\README.md). On this EWDK the managed
REM DrvCat MSBuild task fails to load Microsoft.Kits.Logger, so the .cat is built
REM separately with inf2cat.exe / signtool.exe at install time.
setlocal
set "EWDK=%~1"
if "%EWDK%"=="" set "EWDK=E:"
call "%EWDK%\BuildEnv\SetupBuildEnv.cmd" >nul
if errorlevel 1 (
echo Failed to initialize the EWDK build environment from "%EWDK%".
exit /b 1
)
msbuild "%~dp0RioGamepad.vcxproj" ^
/p:Configuration=Release /p:Platform=x64 ^
/p:SignMode=Off /p:DriverCatalog_Enable=false ^
/nologo /v:m /clp:NoSummary
exit /b %ERRORLEVEL%