Any Device You Can Describe: Custom HID Descriptors in VirtualController
Two devices in joy.cpl. One has six axes. The other has two. Neither existed five minutes ago.
The Idea
When I started VirtualController, the driver had built-in HID descriptors — one for a 6DOF space controller, one for a HOTAS. You asked for a device type, the driver picked the matching descriptor, handed it to VHF, and Windows enumerated a virtual joystick.
That works. But it doesn't scale. Every new device type means editing kernel code, recompiling, re-signing, redeploying. Want a racing wheel? Edit the driver. A button box? Edit the driver. A flight panel with 47 switches? Good luck.
The right design is obvious once you see it: the driver shouldn't know or care what kind of device it's creating. It should be a dumb pipe. Userspace sends a HID report descriptor — a binary blob that describes axes, buttons, hats, whatever — and the driver passes it straight to VHF. No interpretation. No device-type switch statements. Just bytes in, device out.
Preset device types become what they always should have been: convenience wrappers. A "6DOF" device is just 60 known-good bytes. A "HOTAS" is a different 60 bytes. The plumbing is the same.
The Change
The struct that crosses the kernel boundary got wider:
typedef struct _VC_PLUGIN_REQUEST {
VC_DEVICE_DESCRIPTOR Descriptor; // 144 bytes
ULONG ReportDescriptorLength; // how many bytes follow
UCHAR ReportDescriptor[1024]; // the raw HID descriptor
} VC_PLUGIN_REQUEST;
1172 bytes, shared between user-mode DLL and kernel driver. The DLL fills in the descriptor blob and length. The driver reads them, copies them into the device entry, and passes them to VHF_CONFIG_INIT. If the length is zero, the driver falls back to built-in presets. If it's non-zero, whatever you sent becomes the device.
The client API gained a new function:
VC_API int vc_device_create_custom(
VC_CLIENT_HANDLE client_handle,
const void* report_descriptor,
uint32_t descriptor_length,
uint16_t vendor_id,
uint16_t product_id,
const char* friendly_name,
VC_DEVICE_HANDLE* out_device);
And the Python wrapper makes it trivial:
dev = vc.create_custom_device(
my_descriptor_bytes,
vendor_id=0x1209,
product_id=0x0002,
name="2-Axis Joystick"
)
Done. That's the entire API surface for creating an arbitrary virtual HID device.
The Bug
Except it didn't work.
The code compiled clean. The DLL exported the new function. The driver accepted the IOCTL and returned a device ID. VhfCreate succeeded. VhfStart succeeded. Windows enumerated... nothing. The 6DOF preset still worked — you could see it in joy.cpl, axes moving. But any device created with a custom descriptor was invisible.
I added a diagnostic IOCTL that queries what the driver actually stored for a given device ID. The response was damning:
Driver stored: DescLen=0 Head=[00 00 00 00 00 00 00 00]
Zero. The descriptor bytes weren't reaching the driver. The DLL was filling the struct correctly — I verified with a Python ctypes struct checker that the layout matched (1172 bytes, descriptor length at offset 144, descriptor data at offset 148). But the driver was reading zeroes.
Here's where it got weird: the diagnostic showed DescLen=0 for both preset and custom devices. If the driver code was working correctly, even preset devices should show their descriptor length. Something was fundamentally wrong.
The Bypass Test
When you can't trust the DLL, bypass it.
I wrote a Python script that opens the bus device handle directly via SetupAPI, manually constructs the IOCTL buffer at the byte level — no structs, no DLL, just raw struct.pack into a 1172-byte array — and sends it straight to the driver with DeviceIoControl.
req = bytearray(1172)
struct.pack_into('<I', req, 0, 2) # DeviceType = CustomHID
struct.pack_into('<H', req, 4, 0x1209) # VendorId
struct.pack_into('<I', req, 144, len(DESC)) # ReportDescriptorLength
req[148:148+len(DESC)] = DESC # ReportDescriptor bytes
Before sending, print what's in the buffer:
ReportDescriptorLength at [144:148] = 3c000000 = 60
ReportDescriptor head at [148:156] = 05010904a1010901
The buffer was correct. The bytes were there. Send the IOCTL, query the driver:
Driver stored: DescLen=0 Head=[00 00 00 00 00 00 00 00]
Still zero. The buffer was right. The IOCTL succeeded. The driver returned a device ID. But it stored nothing. The bug wasn't in the DLL. It was in the driver.
Or rather — it was in the driver binary on the tablet.
The Stale Binary
This is the one that burns you.
The driver source code was correct. The struct handling was correct. The descriptor copy logic was correct. But the .sys file running on the tablet was compiled from an older version of the source — before the descriptor storage code was added to VcBusPluginDevice. The INF version had been bumped. The diagnostic struct had been added. But the core plugin logic that copies ReportDescriptorLength and ReportDescriptor into the device entry wasn't in that particular build.
The driver accepted the 1172-byte buffer (because the old sizeof(VC_PLUGIN_REQUEST) check still passed), created the device (because the old code path still worked), but silently ignored the descriptor fields (because they didn't exist in the compiled code). The fallback to built-in presets meant 6DOF devices still worked. Everything looked fine. Except custom devices were invisible.
The fix: rebuild the driver from current source, re-sign, redeploy.
Plugin response: status=0x00000000, deviceId=1
Driver stored: DescLen=60 Head=[05 01 09 04 a1 01 09 01]
Sixty bytes. The exact 6DOF descriptor. Stored in the driver. Readable via diagnostic IOCTL. The binary matched the source.
Two Devices
With the correct driver running, I wrote the real test. Two custom devices, created simultaneously, with completely different descriptors:
Device 1 — a 6DOF space controller. Six signed 16-bit axes (X, Y, Z, Rx, Ry, Rz), two buttons. Sixty bytes of descriptor.
Device 2 — a minimal 2-axis joystick. Two unsigned 16-bit axes (X, Y), four buttons. A descriptor I wrote from scratch, never existed in any preset.
Creating 6DOF device (PID 0x0001)...
6DOF ID: 4
Creating 2-axis device (PID 0x0002)...
2-Axis ID: 5
Both devices created. Sending reports for 60 seconds.
Both appeared in joy.cpl. Both showed correct axis configurations. Both responded to input. The 6DOF swept six axes through sine waves. The 2-axis traced circles on X/Y.
The 2-axis device is the one that matters. It didn't exist anywhere in the driver. No preset, no switch statement, no special case. Just a blob of bytes that says "I'm a joystick with two axes and four buttons" — and the driver passed it through.
Learning Together
I want to be honest about something. Neither of us — me or Claude — walked into this knowing how VHF driver development works. There's no tutorial for this. Microsoft's VHF documentation is a handful of pages. The open-source examples (ViGEmBus, vJoy) solve different problems with different architectures. Custom HID descriptors through a kernel bus driver with a Python userspace API? We're in uncharted territory.
Claude didn't hand me the answer. I didn't hand Claude the answer. We iterated. We got it wrong. We got it wrong again. We wrote diagnostic IOCTLs to see inside the driver's state. We wrote raw bypass scripts to eliminate variables. We stared at hex dumps of struct offsets and said "the bytes are right, so why is the driver reading zeroes?"
And when it turned out to be a stale binary — the kind of bug that makes you feel stupid once you find it — we logged the lesson and moved on. That's how you build things you've never built before. You instrument everything, trust nothing, and keep a short feedback loop between "I changed the code" and "I can see the result."
The stale binary lesson is now in the project memory. Next time we touch the IOCTL structs, both the driver and the DLL get rebuilt and redeployed as a single atomic step. We won't burn another session on it.
What This Means
VirtualController is no longer a fixed-device-type driver. It's a platform. Any HID device that can be described in a report descriptor can be created from userspace:
- Racing wheels with force feedback descriptors
- Button boxes with dozens of switches
- Flight panels with rotary encoders
- Accessibility controllers with custom input layouts
- Multi-axis spacemice with arbitrary axis counts
The driver doesn't change. The DLL doesn't change. You write a descriptor, call create_custom_device, and Windows sees a new controller.

Eight axes sweeping, thirty-two buttons firing, four hats cycling — a single virtual device defined entirely by an 87-byte descriptor blob.
Preset device types are now just dictionaries of known-good descriptors:
PRESETS = {
"6dof": bytes([0x05, 0x01, 0x09, 0x04, ...]),
"hotas": bytes([0x05, 0x01, 0x09, 0x04, ...]),
"wheel": bytes([0x05, 0x01, 0x09, 0x04, ...]),
}
dev = vc.create_custom_device(PRESETS["hotas"], name="Virtual HOTAS")
That's the foundation. Everything built on top — the X52 configurator migration, the GUI device manager, network-connected ESP32 controllers — all of it flows through this one clean interface.
What I Learned
-
A dumb pipe beats a smart router. The driver shouldn't interpret device types. It should pass descriptors through and let VHF and Windows handle the rest. Device intelligence belongs in userspace where you can iterate without re-signing a kernel binary.
-
When a diagnostic says zero but the code says otherwise, check the binary. Source code is not truth. The compiled, signed, deployed binary is truth. If they disagree, the binary wins. Always.
-
Build a bypass test before you blame the layer below. The raw IOCTL script that bypasses the DLL took ten minutes to write. It eliminated the DLL as a suspect in one run and pointed directly at the driver binary. Without it, I'd still be reading source code that looks correct.
-
Bump, rebuild, redeploy atomically. When shared structs change between kernel and user mode, both sides must be compiled from the same source at the same time. A partial deploy — new DLL, old driver — is invisible and devastating.
-
HID report descriptors are the universal language. Any device that can be expressed in USB HID descriptor bytes can be created as a virtual controller. The descriptor is the device. Everything else is plumbing.
Built with Claude Code. Two devices from thin air. Published at indigo-nx.com.