Naming Things: Dynamic joy.cpl Identity for Virtual Devices
"VHF HID device" is not a name. It's a confession that nobody finished the job.
The Problem
Every device VirtualController creates shows up in joy.cpl as "VHF HID device." You create a HOTAS — VHF HID device. A space controller — VHF HID device. A custom 8-axis monster — VHF HID device. They all look identical.
This matters. When you open Game Controllers and see three entries all called the same thing, you can't tell which is which. Games that let you select a controller by name show a useless list. It looks unfinished because it is.
VHF — the Virtual HID Framework that creates these devices in the kernel — has VendorID, ProductID, and VersionNumber in its config struct. But no ProductString. No ManufacturerString. I checked the WDK header directly:
// vhf.h — VHF_CONFIG fields (WDK 10.0.26100.0)
VendorID
ProductID
VersionNumber
// ...that's it for identity. No strings.
Microsoft gives you three numbers and no words. The device name has to come from somewhere else.
We Already Knew the Answer
Two weeks ago, in the vJoy registry deep dive, we found exactly how joy.cpl resolves device names. It reads OEMName from a specific registry path, and HKCU always wins over HKLM:
HKCU\System\CurrentControlSet\Control\MediaProperties\
PrivateProperties\Joystick\OEM\VID_xxxx&PID_xxxx
OEMName = "Whatever you want"
Write that key, joy.cpl shows your name. We used it to rebrand vJoy devices. Now we needed to bake it into VirtualController itself.
Where to Put It
VirtualController has a clean architecture: kernel driver at the bottom, C++ DLL in the middle, Python wrapper on top. The DLL is the chokepoint — every device, whether created from Python, from a future GUI, or from any other language binding, goes through vc_device_create or vc_device_create_custom.
That's where the registry write belongs. Not in the driver (no registry API at DISPATCH_LEVEL), not in the Python layer (would need duplicating for every language), not in a separate tool. Right in the DLL, immediately after successful device creation.
static void write_oem_name(uint16_t vid, uint16_t pid,
const std::wstring& name)
{
if (name.empty()) return;
wchar_t subkey[256];
swprintf_s(subkey, 256,
L"System\\CurrentControlSet\\Control\\MediaProperties\\"
L"PrivateProperties\\Joystick\\OEM\\VID_%04X&PID_%04X",
vid, pid);
HKEY roots[] = { HKEY_CURRENT_USER, HKEY_LOCAL_MACHINE };
for (HKEY root : roots) {
HKEY key = nullptr;
LONG result = RegCreateKeyExW(root, subkey, 0, nullptr,
REG_OPTION_NON_VOLATILE, KEY_SET_VALUE, nullptr,
&key, nullptr);
if (result == ERROR_SUCCESS) {
RegSetValueExW(key, L"OEMName", 0, REG_SZ,
reinterpret_cast<const BYTE*>(name.c_str()),
static_cast<DWORD>((name.size() + 1) * sizeof(wchar_t)));
RegCloseKey(key);
}
}
}
HKCU first, HKLM as fallback. Same pattern we proved in the vJoy post.
The Static That Crashed Everything
Writing the name on creation was the easy part. But names should disappear when the device does. A destroyed virtual device shouldn't leave a ghost entry in the registry.
The DLL's vc_device_destroy function only receives a device handle — it doesn't know the VID/PID that was used at creation time. So I needed to track which handle maps to which VID/PID.
First attempt: std::mutex and std::unordered_map as static globals in the DLL.
static std::mutex g_oemMapMutex;
static std::unordered_map<VC_DEVICE_HANDLE, DeviceOEMInfo> g_oemMap;
The DLL compiled. The version check worked. The client creation worked. But the moment vc_device_create was called:
OSError: exception: access violation reading 0x0000000000000000
Null pointer. Not in my code — in the CreateDevice call that happens before any of the new map code runs. The C++ static initialisers for the mutex and the map were corrupting something during DLL load. The crash wasn't at the map insertion. It was during device creation, in code that hadn't changed.
I stripped the statics out, rebuilt, redeployed. Device creation worked again immediately.
The Fix: Plain C
C++ static globals in a DLL are a trap. The CRT runs constructors during DllMain, and the order and side effects are fragile. A std::unordered_map triggers heap allocation during DLL load. A std::mutex initialises OS primitives. Either one can interfere with other static initialisation.
The solution is embarrassingly simple. Sixteen devices max. Plain C array. Zero initialisation. No constructors.
struct OEMEntry { void* handle; uint16_t vid; uint16_t pid; };
static OEMEntry g_oemEntries[16] = {};
static int g_oemCount = 0;
On create, append. On destroy, find and remove:
for (int i = 0; i < g_oemCount; i++) {
if (g_oemEntries[i].handle == handle) {
delete_oem_name(g_oemEntries[i].vid, g_oemEntries[i].pid);
g_oemEntries[i] = g_oemEntries[--g_oemCount];
break;
}
}
No heap. No constructors. No crash. The array lives in the BSS segment — zero-initialised by the loader, no CRT involvement.
The Result
Create a device:
dev = vc.create_device(
device_type=VCDeviceType.HOTAS,
vendor_id=0x1209,
product_id=0x4F54,
name="indigo-nx Test Controller"
)
Open joy.cpl: "indigo-nx Test Controller" — not "VHF HID device."
Destroy the device:
dev.destroy()
Refresh joy.cpl: gone. No ghost. No stale registry key. Clean in, clean out.
What I Learned
-
VHF has no string identity fields. If you want your virtual HID device to have a name in joy.cpl, you must write
OEMNameto the Joystick OEM registry. The driver framework won't do it for you. -
HKCU always wins for OEMName. Write there first. HKLM is a fallback for accounts that haven't seen the device yet.
-
C++ statics in DLLs are dangerous.
std::mutexandstd::unordered_mapas file-scope globals can corrupt DLL initialisation. If you need process-wide state in a DLL, use plain C types with zero initialisation or Win32 primitives likeCRITICAL_SECTION(initialised inDllMain). -
Put the fix at the chokepoint. The DLL handles every device creation path. Registry writes there mean every consumer — Python, C++, future GUI — gets naming for free without duplicating the logic.
-
Clean up after yourself. A virtual device that writes registry state on creation must delete that state on destruction. Otherwise you leave ghost names that outlive the device.
VirtualController v0.2.0. Built with Claude Code. Published at indigo-nx.com.