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
+7 -5
View File
@@ -35,9 +35,11 @@ dotnet test RioJoy.sln
## Status
Phases 25 are code-complete and unit-tested (123 tests): serial + RIO protocol
core, input mapping + output routing, axis calibration + plasma display, and the
tray app + profiles (JSON config, `RIO.ini` importer, the three-state auto-switch
watcher, and the `RioRuntime` that wires it together). The virtual-HID feeder and
on-cabinet verification are pending on the Phase 1 driver. See
Phases 15 are implemented and tested (136 unit tests): the `RioGamepad` virtual
HID driver compiles to `.sys` against the EWDK (KMDF + VHF), and the C# side
covers the serial + RIO protocol core, input mapping + output routing, axis
calibration + plasma display, the tray app + profiles (JSON config, `RIO.ini`
importer, three-state auto-switch), and the HID report packer that matches the
driver's wire format. Remaining work is **deploy-side**: test-sign + install the
driver, wire the real `DeviceIoControl` feeder, and verify on a cabinet. See
[`docs/PLAN.md`](docs/PLAN.md) for the full roadmap.
+13 -5
View File
@@ -106,12 +106,20 @@ tray menu is always available.
(tray app); `driver/` placeholder for the WDK project.
- This plan + [PROTOCOL.md](PROTOCOL.md).
### Phase 1 — Virtual HID driver (highest risk; do first)
### Phase 1 — Virtual HID driver — compiling ✅ (sign/install pending)
Implemented in [`driver/RioGamepad/`](../driver/RioGamepad/); builds to
`RioGamepad.sys` against the EWDK (KMDF 1.15 + VHF, x64, warnings-as-errors).
- KMDF + VHF virtual HID gamepad: report descriptor = 6×16-bit axes, 1 hat,
96 buttons.
- Control device + custom IOCTL → `VhfReadReportSubmit`.
- Test harness (throwaway C#) wiggles axes/buttons; verify in `joy.cpl`.
- Test-signing setup for the cabinets.
96 buttons ([`ReportDescriptor.h`](../driver/RioGamepad/ReportDescriptor.h)).
- Device interface + custom `IOCTL_RIO_SUBMIT_REPORT``VhfReadReportSubmit`; the
driver is a thin relay, with the 25-byte report layout pinned in
[`Public.h`](../driver/RioGamepad/Public.h).
- C# side of the contract: `RioJoy.Core.Hid.RioHidReport` packs axes/hat/buttons
into that exact report (unit-tested). Replaces the throwaway test harness idea.
-**Remaining (Phase 6 / on-cabinet):** test-sign + `pnputil` install + verify
in `joy.cpl`; wire the real `DeviceIoControl` feeder sink (replacing
`NullJoystickSink`). The EWDK's in-build catalog/sign tasks are bypassed; the
`.cat` is made with `inf2cat`/`signtool` at install time.
### Phase 2 — Serial + RIO protocol core (`RioJoy.Core`) — code-complete ✅
Implemented in `src/RioJoy.Core/Protocol` + `Serial`, covered by
+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%
+85
View File
@@ -0,0 +1,85 @@
using RioJoy.Core.Calibration;
using RioJoy.Core.Mapping;
namespace RioJoy.Core.Hid;
/// <summary>
/// Builds the fixed 25-byte input report consumed by the RioGamepad driver
/// (see <c>driver/RioGamepad/Public.h</c>). Layout:
/// <list type="bullet">
/// <item>bytes 011: axes X,Y,Z,Rx,Ry,Rz — unsigned 16-bit little-endian</item>
/// <item>byte 12: low nibble = hat (03; 0x0F = centered/null)</item>
/// <item>bytes 1324: 96 button bits (button N at byte 13 + (N-1)/8, bit (N-1)%8)</item>
/// </list>
/// The instance holds current state so successive axis/button/hat updates compose
/// into one report, mirroring how a gamepad reports its whole state each time.
/// </summary>
public sealed class RioHidReport
{
/// <summary>Report size in bytes (must match the driver's RIO_REPORT_SIZE).</summary>
public const int Size = 25;
/// <summary>Number of buttons in the report.</summary>
public const int ButtonCount = 96;
/// <summary>Logical maximum for an axis (HID descriptor logical max).</summary>
public const int AxisMax = 32767;
private const int HatByte = 12;
private const int ButtonsOffset = 13;
private const byte HatCentered = 0x0F;
private readonly byte[] _buffer = new byte[Size];
public RioHidReport()
{
// Center the axes and center the hat at rest.
SetAxis(JoyAxis.X, AxisOutputs.Center);
SetAxis(JoyAxis.Y, AxisOutputs.Center);
SetAxis(JoyAxis.Z, AxisOutputs.Center);
SetAxis(JoyAxis.Rx, AxisOutputs.Center);
SetAxis(JoyAxis.Ry, AxisOutputs.Center);
SetAxis(JoyAxis.Rz, AxisOutputs.Center);
SetHat(RioHat.Centered);
}
/// <summary>The current report bytes (length <see cref="Size"/>).</summary>
public ReadOnlySpan<byte> Bytes => _buffer;
/// <summary>Copy of the current report bytes.</summary>
public byte[] ToArray() => (byte[])_buffer.Clone();
/// <summary>Set an axis value (clamped to 0..<see cref="AxisMax"/>), little-endian.</summary>
public void SetAxis(JoyAxis axis, int value)
{
ushort v = (ushort)Math.Clamp(value, 0, AxisMax);
int offset = (int)axis * 2;
_buffer[offset] = (byte)(v & 0xFF);
_buffer[offset + 1] = (byte)(v >> 8);
}
/// <summary>Set the POV hat position (<see cref="RioHat.Centered"/> = null).</summary>
public void SetHat(RioHat position)
{
byte nibble = position == RioHat.Centered ? HatCentered : (byte)((int)position & 0x0F);
_buffer[HatByte] = (byte)((_buffer[HatByte] & 0xF0) | nibble);
}
/// <summary>
/// Set a button's state. <paramref name="button"/> is the 1-based button
/// number (1..96), matching the legacy vJoy <c>SetBtn</c> numbering.
/// </summary>
public void SetButton(int button, bool pressed)
{
if (button < 1 || button > ButtonCount)
throw new ArgumentOutOfRangeException(nameof(button), $"Button must be 1..{ButtonCount}.");
int bit = button - 1;
int index = ButtonsOffset + (bit / 8);
byte mask = (byte)(1 << (bit % 8));
if (pressed)
_buffer[index] |= mask;
else
_buffer[index] &= (byte)~mask;
}
}
@@ -0,0 +1,85 @@
using RioJoy.Core.Calibration;
using RioJoy.Core.Hid;
using RioJoy.Core.Mapping;
using Xunit;
namespace RioJoy.Core.Tests.Hid;
public class RioHidReportTests
{
[Fact]
public void DefaultReport_CentersAxesAndHat()
{
var report = new RioHidReport();
byte[] b = report.ToArray();
Assert.Equal(RioHidReport.Size, b.Length);
// Each axis = 16383 = 0x3FFF, little-endian.
for (int offset = 0; offset < 12; offset += 2)
{
Assert.Equal(0xFF, b[offset]);
Assert.Equal(0x3F, b[offset + 1]);
}
Assert.Equal(0x0F, b[12] & 0x0F); // hat centered/null
}
[Fact]
public void SetAxis_WritesLittleEndian_AndClamps()
{
var report = new RioHidReport();
report.SetAxis(JoyAxis.Z, 0x1234);
Assert.Equal(0x34, report.Bytes[4]);
Assert.Equal(0x12, report.Bytes[5]);
report.SetAxis(JoyAxis.Z, 70000); // over max → clamped to 32767
Assert.Equal(0xFF, report.Bytes[4]);
Assert.Equal(0x7F, report.Bytes[5]);
}
[Theory]
[InlineData(RioHat.Up, 0x00)]
[InlineData(RioHat.Right, 0x01)]
[InlineData(RioHat.Down, 0x02)]
[InlineData(RioHat.Left, 0x03)]
[InlineData(RioHat.Centered, 0x0F)]
public void SetHat_EncodesNibble(RioHat hat, int expected)
{
var report = new RioHidReport();
report.SetHat(hat);
Assert.Equal(expected, report.Bytes[12] & 0x0F);
}
[Theory]
[InlineData(1, 13, 0x01)] // button 1 → byte 13 bit 0
[InlineData(8, 13, 0x80)] // button 8 → byte 13 bit 7
[InlineData(9, 14, 0x01)] // button 9 → byte 14 bit 0
[InlineData(96, 24, 0x80)] // button 96 → byte 24 bit 7
public void SetButton_SetsCorrectBit(int button, int byteIndex, int mask)
{
var report = new RioHidReport();
report.SetButton(button, pressed: true);
Assert.Equal(mask, report.Bytes[byteIndex]);
report.SetButton(button, pressed: false);
Assert.Equal(0, report.Bytes[byteIndex]);
}
[Fact]
public void SetButton_RejectsOutOfRange()
{
var report = new RioHidReport();
Assert.Throws<ArgumentOutOfRangeException>(() => report.SetButton(0, true));
Assert.Throws<ArgumentOutOfRangeException>(() => report.SetButton(97, true));
}
[Fact]
public void Buttons_ComposeIndependently()
{
var report = new RioHidReport();
report.SetButton(1, true);
report.SetButton(2, true);
report.SetButton(1, false);
Assert.Equal(0x02, report.Bytes[13]); // only button 2 remains
}
}