From 24cdf495e3da7c901ada936dc18d72709e564a5b Mon Sep 17 00:00:00 2001 From: Cyd Date: Fri, 26 Jun 2026 21:06:16 -0500 Subject: [PATCH] Phase 1: RioGamepad virtual HID driver (KMDF + VHF) + C# report packer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- README.md | 12 +- docs/PLAN.md | 18 +- driver/README.md | 32 +++- driver/RioGamepad/Device.c | 154 ++++++++++++++++++ driver/RioGamepad/Driver.c | 26 +++ driver/RioGamepad/Driver.h | 29 ++++ driver/RioGamepad/Public.h | 36 ++++ driver/RioGamepad/ReportDescriptor.h | 56 +++++++ driver/RioGamepad/RioGamepad.inf | 55 +++++++ driver/RioGamepad/RioGamepad.vcxproj | 65 ++++++++ driver/RioGamepad/build.cmd | 26 +++ src/RioJoy.Core/Hid/RioHidReport.cs | 85 ++++++++++ .../Hid/RioHidReportTests.cs | 85 ++++++++++ 13 files changed, 664 insertions(+), 15 deletions(-) create mode 100644 driver/RioGamepad/Device.c create mode 100644 driver/RioGamepad/Driver.c create mode 100644 driver/RioGamepad/Driver.h create mode 100644 driver/RioGamepad/Public.h create mode 100644 driver/RioGamepad/ReportDescriptor.h create mode 100644 driver/RioGamepad/RioGamepad.inf create mode 100644 driver/RioGamepad/RioGamepad.vcxproj create mode 100644 driver/RioGamepad/build.cmd create mode 100644 src/RioJoy.Core/Hid/RioHidReport.cs create mode 100644 tests/RioJoy.Core.Tests/Hid/RioHidReportTests.cs diff --git a/README.md b/README.md index ec19761..9000198 100644 --- a/README.md +++ b/README.md @@ -35,9 +35,11 @@ dotnet test RioJoy.sln ## Status -Phases 2–5 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 1–5 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. diff --git a/docs/PLAN.md b/docs/PLAN.md index 191493d..861c5fd 100644 --- a/docs/PLAN.md +++ b/docs/PLAN.md @@ -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 diff --git a/driver/README.md b/driver/README.md index a8a5689..89d42a1 100644 --- a/driver/README.md +++ b/driver/README.md @@ -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 (`\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 diff --git a/driver/RioGamepad/Device.c b/driver/RioGamepad/Device.c new file mode 100644 index 0000000..e15d9f0 --- /dev/null +++ b/driver/RioGamepad/Device.c @@ -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 +#include // 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); +} diff --git a/driver/RioGamepad/Driver.c b/driver/RioGamepad/Driver.c new file mode 100644 index 0000000..73c4c13 --- /dev/null +++ b/driver/RioGamepad/Driver.c @@ -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; +} diff --git a/driver/RioGamepad/Driver.h b/driver/RioGamepad/Driver.h new file mode 100644 index 0000000..505a8b0 --- /dev/null +++ b/driver/RioGamepad/Driver.h @@ -0,0 +1,29 @@ +/*++ +RioGamepad — driver-wide declarations. +--*/ + +#pragma once + +#include +#include +#include // 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; diff --git a/driver/RioGamepad/Public.h b/driver/RioGamepad/Public.h new file mode 100644 index 0000000..6799b06 --- /dev/null +++ b/driver/RioGamepad/Public.h @@ -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 diff --git a/driver/RioGamepad/ReportDescriptor.h b/driver/RioGamepad/ReportDescriptor.h new file mode 100644 index 0000000..5aaa68b --- /dev/null +++ b/driver/RioGamepad/ReportDescriptor.h @@ -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) +}; diff --git a/driver/RioGamepad/RioGamepad.inf b/driver/RioGamepad/RioGamepad.inf new file mode 100644 index 0000000..7f5240a --- /dev/null +++ b/driver/RioGamepad/RioGamepad.inf @@ -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" diff --git a/driver/RioGamepad/RioGamepad.vcxproj b/driver/RioGamepad/RioGamepad.vcxproj new file mode 100644 index 0000000..10b7f6f --- /dev/null +++ b/driver/RioGamepad/RioGamepad.vcxproj @@ -0,0 +1,65 @@ + + + + + Debug + x64 + + + Release + x64 + + + + {4F1E9C2A-6B3D-4E58-9A77-1C2D3E4F5A6B} + 12.0 + Release + x64 + RioGamepad + + + + Windows10 + true + WindowsKernelModeDriver10.0 + Driver + KMDF + 1 + 15 + + + Windows10 + false + WindowsKernelModeDriver10.0 + Driver + KMDF + 1 + 15 + + + + + false + true + + + %(AdditionalDependencies);$(DDK_LIB_PATH)vhfkm.lib + + + + + + + + + + + + + + + + + + + diff --git a/driver/RioGamepad/build.cmd b/driver/RioGamepad/build.cmd new file mode 100644 index 0000000..82863c8 --- /dev/null +++ b/driver/RioGamepad/build.cmd @@ -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% diff --git a/src/RioJoy.Core/Hid/RioHidReport.cs b/src/RioJoy.Core/Hid/RioHidReport.cs new file mode 100644 index 0000000..4a5a376 --- /dev/null +++ b/src/RioJoy.Core/Hid/RioHidReport.cs @@ -0,0 +1,85 @@ +using RioJoy.Core.Calibration; +using RioJoy.Core.Mapping; + +namespace RioJoy.Core.Hid; + +/// +/// Builds the fixed 25-byte input report consumed by the RioGamepad driver +/// (see driver/RioGamepad/Public.h). Layout: +/// +/// bytes 0–11: axes X,Y,Z,Rx,Ry,Rz — unsigned 16-bit little-endian +/// byte 12: low nibble = hat (0–3; 0x0F = centered/null) +/// bytes 13–24: 96 button bits (button N at byte 13 + (N-1)/8, bit (N-1)%8) +/// +/// 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. +/// +public sealed class RioHidReport +{ + /// Report size in bytes (must match the driver's RIO_REPORT_SIZE). + public const int Size = 25; + + /// Number of buttons in the report. + public const int ButtonCount = 96; + + /// Logical maximum for an axis (HID descriptor logical max). + 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); + } + + /// The current report bytes (length ). + public ReadOnlySpan Bytes => _buffer; + + /// Copy of the current report bytes. + public byte[] ToArray() => (byte[])_buffer.Clone(); + + /// Set an axis value (clamped to 0..), little-endian. + 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); + } + + /// Set the POV hat position ( = null). + public void SetHat(RioHat position) + { + byte nibble = position == RioHat.Centered ? HatCentered : (byte)((int)position & 0x0F); + _buffer[HatByte] = (byte)((_buffer[HatByte] & 0xF0) | nibble); + } + + /// + /// Set a button's state. is the 1-based button + /// number (1..96), matching the legacy vJoy SetBtn numbering. + /// + 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; + } +} diff --git a/tests/RioJoy.Core.Tests/Hid/RioHidReportTests.cs b/tests/RioJoy.Core.Tests/Hid/RioHidReportTests.cs new file mode 100644 index 0000000..9caf07b --- /dev/null +++ b/tests/RioJoy.Core.Tests/Hid/RioHidReportTests.cs @@ -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(() => report.SetButton(0, true)); + Assert.Throws(() => 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 + } +}