-
Notifications
You must be signed in to change notification settings - Fork 6
Driver Installation Internals
PadForge manages installation, detection, and uninstallation of three drivers and one service: ViGEmBus (virtual Xbox 360/DS4 controllers), HidHide (device hiding), vJoy (virtual joystick), and Windows MIDI Services (virtual MIDI output). All installation logic lives in a single static class with embedded installer resources.
File: PadForge.App/Common/DriverInstaller.cs
Namespace: PadForge.Common
graph TD
subgraph DriverInstaller["DriverInstaller (static class)"]
direction TB
VG["ViGEmBus<br/>Embedded EXE + MSI"]
HH["HidHide<br/>Embedded EXE + MSI"]
VJ["vJoy<br/>Embedded ZIP + SetupAPI"]
MS["MIDI Services<br/>GitHub API download"]
end
VG -->|"/extract -> msiexec /i"| VIGEM_DRV["ViGEmBus Driver"]
HH -->|"/extract -> msiexec /i"| HH_DRV["HidHide Driver"]
VJ -->|"ZIP extract -> pnputil -> SetupAPI"| VJ_DRV["vjoy.sys"]
MS -->|"GitHub releases API -> download -> /install"| MS_SVC["MIDI Services"]
VG -.->|"runas UAC"| UAC1["UAC Prompt"]
HH -.->|"runas UAC"| UAC2["UAC Prompt"]
VJ -.->|"Single elevated PS1"| UAC3["UAC Prompt"]
MS -.->|"Already elevated"| NOUAC["No UAC Needed"]
| Resource Name | Type | Size | Purpose |
|---|---|---|---|
ViGEmBus_1.22.0_x64_x86_arm64.exe |
EXE (WiX bootstrapper) | ~5 MB | ViGEmBus installer with bundled MSI |
HidHide_1.5.230_x64.exe |
EXE (WiX bootstrapper) | ~3 MB | HidHide installer with bundled MSI |
vJoyDriver.zip |
ZIP | ~200 KB | 5 files: vjoy.sys, hidkmdf.sys, vjoy.inf, vjoy.cat, vJoyInterface.dll |
Declared in PadForge.App.csproj:
<EmbeddedResource Include="Resources\ViGEmBus_1.22.0_x64_x86_arm64.exe" />
<EmbeddedResource Include="Resources\HidHide_1.5.230_x64.exe" />
<EmbeddedResource Include="Resources\vJoyDriver.zip" />These private methods are reused across all driver install/uninstall flows.
private static string ExtractEmbeddedResource(string resourceFileName, string tempDir)- Calls
Assembly.GetExecutingAssembly()to access embedded resources. - Searches
GetManifestResourceNames()for a match usingIndexOf(case-insensitive substring match). - Creates
tempDirif it does not exist. - Streams the resource to
{tempDir}\{resourceFileName}viaGetManifestResourceStream. - Returns the full path to the extracted file.
- Throws
FileNotFoundExceptionif the resource name is not found (error message lists all available resource names for debugging).
private static string ExtractInstallerBundle(string exePath, string tempDir)Runs the WiX bootstrapper with /extract "{extractDir}" to unpack the MSI and supporting files into {tempDir}\Extracted\. Deletes and recreates the extraction directory if it already exists. Uses UseShellExecute = false and CreateNoWindow = true. Waits up to 60 seconds. Returns the extraction directory path.
private static string FindMsi(string extractDir, string primaryName, string fallbackPattern)Searches the extracted bundle for an MSI file using Directory.GetFiles with SearchOption.AllDirectories:
- Tries exact name first (e.g.,
ViGEmBus.x64.msi). - Falls back to glob pattern (e.g.,
ViGEmBus*.msi). - Throws
FileNotFoundExceptionif neither match yields results.
private static void RunElevated(string fileName, string arguments)Starts a process with Verb = "runas" and UseShellExecute = true (triggers UAC prompt). WindowStyle = ProcessWindowStyle.Hidden. Waits up to 180 seconds (3 minutes). Used by ViGEmBus, HidHide, and vJoy install (PowerShell script).
private static void RunMsiElevated(string arguments)Convenience wrapper: RunElevated("msiexec.exe", arguments).
private static void CleanupTempDir(string tempDir)Deletes temp directory recursively via Directory.Delete(tempDir, true), ignoring all exceptions. Called in finally blocks to guarantee cleanup.
public static void InstallViGEmBus()flowchart TD
A[Extract embedded<br/>ViGEmBus_1.22.0_x64_x86_arm64.exe<br/>to %TEMP%\PadForge_ViGEmBus\] --> B
B[Run bootstrapper with /extract<br/>to unpack MSI] --> C
C{Is 64-bit OS?}
C -->|Yes| D1[Find ViGEmBus.x64.msi]
C -->|No| D2[Find ViGEmBus.msi]
D1 --> D3[Fallback: ViGEmBus*.msi glob]
D2 --> D3
D3 --> E["msiexec /i {msiPath} /qb /norestart<br/>(UAC elevation via runas)"]
E --> F[Cleanup temp directory]
Flow:
- Extracts embedded
ViGEmBus_1.22.0_x64_x86_arm64.exeto%TEMP%\PadForge_ViGEmBus\. - Runs the bootstrapper with
/extractto unpack the MSI and supporting files. - Finds the MSI:
ViGEmBus.x64.msion 64-bit OS,ViGEmBus.msifallback, thenViGEmBus*.msiglob. - Runs
msiexec /i "{msiPath}" /qb /norestartwith UAC elevation (Verb = "runas"). - Cleans up temp directory in
finallyblock.
public static void UninstallViGEmBus()Same extraction flow as install, then runs msiexec /x "{msiPath}" /qb /norestart with elevation. Temp directory cleaned up in finally block.
public static string GetViGEmVersion()Scans HKLM\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall (both RegistryView.Registry64 and RegistryView.Registry32 views) for entries containing "ViGEm" in DisplayName (case-insensitive IndexOf). Returns DisplayVersion value, or null if not found.
public static void InstallHidHide()Same pattern as ViGEmBus: extract embedded HidHide_1.5.230_x64.exe bootstrapper, extract MSI (HidHide.msi primary, HidHide*.msi fallback), run msiexec /i with elevation.
public static void UninstallHidHide()Extract and run msiexec /x with elevation. Same extraction flow as install.
public static bool IsHidHideInstalled()
public static string GetHidHideVersion()Both delegate to TryGetHidHideMsiInfo():
private static bool TryGetHidHideMsiInfo(out string displayVersion, out string productCode)Scans registry Uninstall keys (both Registry64 and Registry32 views) for entries where DisplayName contains "HidHide" or "HID Hide" (case-insensitive). Extracts:
-
DisplayVersionfor version display. - The subkey name as
productCode(GUID format{...}) for uninstall operations.
GetHidHideVersion() returns "Installed" as a fallback when DisplayVersion is null/empty.
vJoy installation is significantly more complex than ViGEmBus/HidHide because it bypasses the Inno Setup installer entirely and uses direct driver store + SetupAPI device creation.
public static void InstallVJoy()Everything runs in a single elevated PowerShell script (one UAC prompt).
flowchart TD
A[Extract vJoyDriver.zip<br/>to %TEMP%\PadForge_vJoy\] --> B
B[FindVJoyOemInfs<br/>detect stale OEM .inf files] --> C
C[Generate PadForge_vjoy_install.ps1] --> D
D["RunElevated powershell.exe<br/>-NoProfile -ExecutionPolicy Bypass<br/>(UAC prompt)"]
subgraph PS1["PowerShell Script Execution"]
direction TB
S1["Step 1: Pre-install Cleanup<br/>Remove device nodes 0000-0015<br/>Sleep 2s<br/>Stop vjoy service<br/>Sleep 2s<br/>Delete stale OEM infs<br/>Delete vjoy service<br/>Remove service registry keys<br/>Delete install dir + stale driver"] --> S2
S2["Step 2: Extract Driver Files<br/>Expand-Archive to<br/>C:\Program Files\vJoy\"] --> S3
S3["Step 3: Add Driver to Store<br/>pnputil /add-driver vjoy.inf /install"] --> S4
S4["Step 4: Create Device Node<br/>SetupAPI P/Invoke via Add-Type<br/>(see below)"]
end
D --> PS1
PS1 --> E[Delete script file]
E --> F[Cleanup temp directory]
Critical ordering: Device nodes MUST be removed FIRST so the driver can unload. Reversing this order (stopping service before removing nodes) causes the service to get stuck in STOP_PENDING (irrecoverable without reboot).
The generated PowerShell script executes:
# 1. Remove all device nodes (0000..0015)
for ($i = 0; $i -le 15; $i++) {
pnputil /remove-device "ROOT\HIDCLASS\$($i.ToString('D4'))" /subtree 2>&1 | Out-Null
}
Start-Sleep -Seconds 2
# 2. Stop the vjoy service
sc.exe stop vjoy 2>&1 | Out-Null
Start-Sleep -Seconds 2
# 3. Delete stale OEM inf files (pre-enumerated by FindVJoyOemInfs)
pnputil /delete-driver oem42.inf /uninstall /force 2>&1 | Out-Null
# ... one line per stale OEM inf
# 4. Delete the vjoy service
sc.exe delete vjoy 2>&1 | Out-Null
# 5. Remove service registry keys from ALL ControlSets
foreach ($cs in @('CurrentControlSet','ControlSet001','ControlSet002','ControlSet003')) {
Remove-Item "HKLM:\SYSTEM\$cs\Services\vjoy" -Recurse -Force -ErrorAction SilentlyContinue
}
# 6. Delete install directory and stale driver binary
Remove-Item 'C:\Program Files\vJoy' -Recurse -Force -ErrorAction SilentlyContinue
Remove-Item "$env:SystemRoot\System32\drivers\vjoy.sys" -Force -ErrorAction SilentlyContinueNew-Item -Path 'C:\Program Files\vJoy' -ItemType Directory -Force | Out-Null
Expand-Archive -Path '{zipPath}' -DestinationPath 'C:\Program Files\vJoy' -Forcepnputil /add-driver 'C:\Program Files\vJoy\vjoy.inf' /installThe PowerShell script uses Add-Type to define inline C# P/Invoke declarations for SetupAPI and newdev.dll:
public static class PF_SetupApi
{
// Constants
public const int DIF_REGISTERDEVICE = 0x19;
public const int SPDRP_HARDWAREID = 0x01;
public const int DICD_GENERATE_ID = 0x01;
// Structure
[StructLayout(LayoutKind.Sequential)]
public struct SP_DEVINFO_DATA
{
public int cbSize;
public Guid ClassGuid;
public int DevInst;
public IntPtr Reserved;
}
// setupapi.dll P/Invoke
[DllImport("setupapi.dll", SetLastError = true)]
public static extern IntPtr SetupDiCreateDeviceInfoList(
ref Guid ClassGuid, IntPtr hwndParent);
[DllImport("setupapi.dll", SetLastError = true, CharSet = CharSet.Unicode)]
public static extern bool SetupDiCreateDeviceInfoW(
IntPtr DeviceInfoSet, string DeviceName, ref Guid ClassGuid,
string DeviceDescription, IntPtr hwndParent, int CreationFlags,
ref SP_DEVINFO_DATA DeviceInfoData);
[DllImport("setupapi.dll", SetLastError = true, CharSet = CharSet.Unicode)]
public static extern bool SetupDiSetDeviceRegistryPropertyW(
IntPtr DeviceInfoSet, ref SP_DEVINFO_DATA DeviceInfoData,
int Property, byte[] PropertyBuffer, int PropertyBufferSize);
[DllImport("setupapi.dll", SetLastError = true)]
public static extern bool SetupDiCallClassInstaller(
int InstallFunction, IntPtr DeviceInfoSet,
ref SP_DEVINFO_DATA DeviceInfoData);
[DllImport("setupapi.dll", SetLastError = true)]
public static extern bool SetupDiDestroyDeviceInfoList(
IntPtr DeviceInfoSet);
// newdev.dll P/Invoke
[DllImport("newdev.dll", SetLastError = true, CharSet = CharSet.Unicode)]
[return: MarshalAs(UnmanagedType.Bool)]
public static extern bool UpdateDriverForPlugAndPlayDevicesW(
IntPtr hwndParent, string HardwareId, string FullInfPath,
int InstallFlags, out bool bRebootRequired);
}Device creation sequence in the PowerShell script:
| Step | API Call | Parameters | Error Handling |
|---|---|---|---|
| 1 | SetupDiCreateDeviceInfoList |
ClassGuid = {745a17a0-74d3-11d0-b6fe-00a0c90f57da} (HIDClass), hwndParent = Zero
|
Log + exit 1 if returns -1
|
| 2 | SetupDiCreateDeviceInfoW |
DeviceName = "HIDClass" (not hardware ID), Description = "vJoy Device", Flags = DICD_GENERATE_ID
|
Log + cleanup + exit 1
|
| 3 | SetupDiSetDeviceRegistryPropertyW |
SPDRP_HARDWAREID, Buffer = root\VID_1234&PID_BEAD&REV_0222 (Unicode double-null terminated) |
Log + cleanup + exit 1
|
| 4 | SetupDiCallClassInstaller |
DIF_REGISTERDEVICE |
Log + cleanup + exit 1
|
| 5 | SetupDiDestroyDeviceInfoList |
Frees device info set | Always called |
| 6 | UpdateDriverForPlugAndPlayDevicesW |
HardwareId = root\VID_1234&PID_BEAD&REV_0222, FullInfPath = C:\Program Files\vJoy\vjoy.inf, InstallFlags = 1
|
Log + exit 1
|
Critical: DeviceName parameter in step 2 must be the class name "HIDClass", NOT the hardware ID. Backslashes in DeviceName cause ERROR_INVALID_DEVINST_NAME (0xE0000205).
This creates a ROOT\HIDCLASS\0000 device node. PnP matches the OEM .inf and loads vjoy.sys.
The install script uses InstallFlags = 1 (INSTALLFLAG_FORCE) to bind the driver, while the runtime CreateVJoyDevices uses InstallFlags = 0 to avoid re-binding already-bound devices.
Logging: Every step writes timestamped entries to %TEMP%\PadForge_vjoy_install.log. The final line is "OK" on success or "FAIL: ..." / "EXCEPTION: ..." on failure.
public static void UninstallVJoy()Runs an elevated batch script (.cmd):
flowchart TD
A[FindVJoyOemInfs<br/>detect OEM .inf files] --> B
B[Generate PadForge_vjoy_uninstall.cmd] --> C
C["RunElevated cmd.exe /c script<br/>(UAC prompt)"]
subgraph CMD["Batch Script Execution"]
direction TB
U1["Remove device nodes 0000-0015<br/>pnputil /remove-device /subtree"] --> U2
U2["timeout /t 2"] --> U3
U3["sc stop vjoy"] --> U4
U4["timeout /t 2"] --> U5
U5["sc delete vjoy"] --> U6
U6["Fallback: reg delete vjoy service<br/>from ALL ControlSets"] --> U7
U7["pnputil /delete-driver oem*.inf<br/>/uninstall /force"] --> U8
U8["rmdir /s /q C:\Program Files\vJoy<br/>del vjoy.sys from drivers\"] --> U9
U9["PowerShell one-liner:<br/>Remove legacy Inno Setup<br/>uninstall registry entries"]
end
C --> CMD
CMD --> D[CleanVJoyRegistryArtifacts<br/>C# method, no elevation needed]
D --> E[Delete script file]
Detailed uninstall steps:
-
Remove device nodes FIRST (for i = 0000..0015):
pnputil /remove-device "ROOT\HIDCLASS\{i:D4}" /subtree - Wait 2 seconds for driver to unload.
- Stop service:
sc stop vjoy - Wait 2 seconds.
- Delete service:
sc delete vjoy -
Fallback: If
sc deletefailed (e.g.,STOP_PENDING), directly remove service registry keys from ALL ControlSets viareg delete:HKLM\SYSTEM\CurrentControlSet\Services\vjoyHKLM\SYSTEM\ControlSet001\Services\vjoyHKLM\SYSTEM\ControlSet002\Services\vjoyHKLM\SYSTEM\ControlSet003\Services\vjoy
- Remove driver from store:
pnputil /delete-driver {oemInf} /uninstall /force(one per stale OEM inf). - Delete install directory (
C:\Program Files\vJoy\) and stale driver binary (%SystemRoot%\System32\drivers\vjoy.sys). - Remove legacy Inno Setup uninstall registry entries via PowerShell one-liner that scans both
HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\UninstallandWOW6432Nodefor entries withDisplayName -like '*vJoy*'. - Call
CleanVJoyRegistryArtifacts()for final registry cleanup (runs in-process, no elevation needed since method usesRegistry.LocalMachineAPIs which succeed under elevation).
private static void CleanVJoyRegistryArtifacts()Removes registry keys that can cause the vJoy installer to hang on reinstall. All deletions use throwOnMissingSubKey: false and catch exceptions per-key (best-effort cleanup).
HKLM paths deleted:
| Registry Path | Purpose |
|---|---|
SYSTEM\CurrentControlSet\Services\vjoy |
Service entry (current) |
SYSTEM\ControlSet001\Services\vjoy |
Service entry (ControlSet001) |
SYSTEM\ControlSet002\Services\vjoy |
Service entry (ControlSet002) |
SYSTEM\ControlSet003\Services\vjoy |
Service entry (ControlSet003) |
SYSTEM\CurrentControlSet\Control\MediaProperties\PrivateProperties\Joystick\OEM\VID_1234&PID_BEAD |
Joystick OEM properties |
SYSTEM\ControlSet001\Services\EventLog\System\vjoy |
Event log registration |
SOFTWARE\Microsoft\Windows\CurrentVersion\Setup\PnpLockdownFiles\%SystemRoot%/System32/drivers/hidkmdf.sys |
PnP lockdown entry |
SOFTWARE\Microsoft\Windows\CurrentVersion\Setup\PnpLockdownFiles\%SystemRoot%/System32/drivers/vjoy.sys |
PnP lockdown entry |
SOFTWARE\WOW6432Node\...\PnpLockdownFiles\...\hidkmdf.sys |
Same under WOW6432Node |
SOFTWARE\WOW6432Node\...\PnpLockdownFiles\...\vjoy.sys |
Same under WOW6432Node |
HKCU path deleted:
| Registry Path | Purpose |
|---|---|
System\CurrentControlSet\Control\MediaProperties\PrivateProperties\Joystick\OEM\VID_1234&PID_BEAD |
User-level joystick OEM properties |
Then calls CleanVJoyDeviceClassEntries().
private static void CleanVJoyDeviceClassEntries()Removes vJoy device entries from the shared HID device class key:
HKLM\SYSTEM\ControlSet001\Control\Class\{781ef630-72b2-11d2-b852-00c04fad5101}
Algorithm:
- Open the class key read-only.
- Enumerate all subkeys (e.g.,
0000,0001, ...). - For each subkey, read the
"Class"registry value. - If
Classequals"vjoy"(case-insensitive viaStringComparison.OrdinalIgnoreCase), delete that subkey tree. - All operations are best-effort (exceptions caught silently).
private static string[] FindVJoyOemInfs()Called before install/uninstall to find stale driver store entries.
- Runs
pnputil.exe /enum-driverswithRedirectStandardOutput = true,CreateNoWindow = true. - Reads all output, waits up to 30 seconds.
- Parses line-by-line using a state machine:
- Detects
"Published Name : oemXX.inf"lines (prefix match"Published"+ contains":"). - Saves the OEM inf name as
currentOem. - Scans subsequent lines in the same block for
"shaul"or"vjoy"(case-insensitive). - If found, marks the block as a vJoy entry (
isVJoyBlock = true). - On encountering the next
"Published Name"line, saves the previous match (if any) and starts a new block. - Handles the last block after the loop ends.
- Detects
- Returns array of
oem*.infnames. Returns empty array on any exception.
public static bool IsVJoyInstalled()
public static string GetVJoyVersion()Two detection paths:
-
Primary: Check for
vjoy.sysinC:\Program Files\vJoy\. If present, readFileVersionInfo.GetVersionInfo()for the version. Returns"Installed"if version info is empty. -
Fallback: Scan registry Uninstall keys for
"vJoy"inDisplayNameviaGetVJoyVersionFromRegistry()(detects legacy Inno Setup installs).
private static string GetVJoyVersionFromRegistry()Scans both RegistryView.Registry64 and RegistryView.Registry32 views of HKLM\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall. Returns DisplayVersion or "Installed" (fallback), or null if not found.
Unlike the other drivers, Windows MIDI Services is NOT embedded -- the installer is ~210 MB and must be downloaded from GitHub at install time.
public static async Task InstallMidiServicesAsync()flowchart TD
A[Create %TEMP%\PadForge_MidiServices\] --> B
B["HttpClient with UserAgent='PadForge'<br/>Timeout = 10 minutes"] --> C
C["GET https://api.github.com/repos/<br/>microsoft/MIDI/releases<br/>(full releases list, not /latest)"] --> D
D["FindMidiServicesDownloadUrl<br/>Parse JSON for SDK.Runtime x64 .exe"] --> E
E["Download installer (~210 MB)<br/>Stream to MidiServicesSdkRuntime.exe"] --> F
F["Run installer directly (no runas)<br/>/install /quiet /norestart<br/>Wait up to 5 minutes"] --> G
G["MidiVirtualController.ResetAvailability()<br/>Clear cached SDK check"] --> H
H[Cleanup temp directory]
Why /releases instead of /releases/latest: The microsoft/MIDI repository only publishes pre-releases. The /releases/latest endpoint returns 404 for repos without a non-pre-release. The full /releases endpoint returns all releases as an array; the first entry is the most recent.
Why no runas: PadForge is already elevated when vJoy is installed (auto-elevation in App.xaml.cs). Launching the installer with Verb = "runas" causes a Win32Exception on some systems because the process is already elevated. Instead, it runs directly with UseShellExecute = false and CreateNoWindow = true.
Post-install: Calls MidiVirtualController.ResetAvailability() so the cached availability check re-evaluates (the MIDI SDK DLLs are now present).
private static async Task<string> FindMidiServicesDownloadUrl(HttpClient http)Parses the GitHub releases JSON response to find the download URL for the SDK Runtime x64 installer.
Algorithm (simple string search, no JSON library dependency):
- Search for
"browser_download_url"occurrences in the JSON string. - For each occurrence, extract the URL between
"httpand the closing". - Check if the URL contains
"SDK.Runtime"AND"x64"AND ends with".exe"(all case-insensitive). - Return the first matching URL.
- Throw
InvalidOperationExceptionif no matching asset is found.
Asset name pattern: Windows.MIDI.Services.SDK.Runtime.and.Tools.*-x64.exe
public static void UninstallMidiServices()- Calls
FindMidiServicesUninstallString()to get the registryUninstallString. - Parses the uninstall command:
- If quoted: extracts path between first two
"characters, remainder is existing args. - If unquoted: splits at first space.
- If quoted: extracts path between first two
- Appends
/quiet /norestartto existing arguments. - Launches the uninstaller with
UseShellExecute = false,CreateNoWindow = true. - Waits up to 5 minutes (
300_000ms).
No fire-and-forget: Despite the MIDI Services SDK DLLs being loaded in-process, the method waits for the uninstaller to finish (up to 5 minutes). The comment in the source notes this can cause issues if the backing service is removed mid-session.
private static string FindMidiServicesUninstallString()Scans both Registry64 and Registry32 views of HKLM\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall for an entry where DisplayName equals "Windows MIDI Services Runtime and Tools" (exact match, case-insensitive via StringComparison.OrdinalIgnoreCase). Returns the UninstallString value, or null if not found.
public static bool IsMidiServicesInstalled()Returns true if FindMidiServicesUninstallString() returns a non-null value. This checks the registry for the WiX Burn bootstrapper bundle entry, NOT the SDK runtime availability (that is MidiVirtualController.IsAvailable()).
The Settings page UI disables uninstall buttons when the corresponding driver/service is actively in use. This prevents removing a driver while virtual controllers depend on it.
| Driver | Guard Condition | Delegate |
|---|---|---|
| ViGEmBus | Any created slot uses Xbox 360 or DS4 | HasAnyViGEmSlots |
| vJoy | Any created slot uses vJoy | HasAnyVJoySlots |
| MIDI Services | Any created slot uses MIDI | HasAnyMidiSlots |
| HidHide | Any device has HidHide hiding enabled | HasAnyHidHideDevices |
Guards are implemented as Func<bool> delegates on SettingsViewModel, injected by MainWindow.xaml.cs. RefreshDriverGuards() re-evaluates all uninstall command CanExecute states after slot creation, deletion, or type changes.
Critical design constraint: vjoy.sys reads ALL DeviceNN registry keys from EVERY device node and concatenates them into one HID report descriptor. With N device nodes and N registry keys, each node creates N HID collections, resulting in N^2 total controllers visible in joy.cpl.
| Nodes | Registry Keys | Collections per Node | Total Controllers | Correct? |
|---|---|---|---|---|
| 1 | 2 | 2 | 2 | Yes |
| 2 | 2 | 2 | 4 (2 phantom) | No |
| 3 | 3 | 3 | 9 (6 phantom) | No |
Solution: Always exactly 1 ROOT\HIDCLASS device node. The number of virtual joysticks is controlled by DeviceNN registry keys (Device01..Device{N}), not by the number of device nodes. When scaling up/down: update registry keys, then restart the node via remove+create.
This architecture is enforced by VJoyVirtualController.EnsureDevicesAvailable() (in PadForge.App/Common/Input/VJoyVirtualController.cs), not by DriverInstaller itself.
While DriverInstaller handles the initial driver install/uninstall, runtime device management is handled by VJoyVirtualController. The following methods manage the device node lifecycle during normal operation:
public static bool EnsureDevicesAvailable(int requiredCount, VJoyDeviceConfig[] perDeviceConfigs)
public static bool EnsureDevicesAvailable(int requiredCount = 1)Both delegate to EnsureDevicesAvailableCore(). Called from the polling thread at ~1000Hz, so includes a fast path to avoid expensive operations on every call.
flowchart TD
A["EnsureDevicesAvailableCore(requiredCount, configs)"] --> B{First call?}
B -->|Yes| B1["EnsureDriverInStore()<br/>EnsureFfbRegistryKeys()"]
B1 --> C
B -->|No| C
C{Fast path?<br/>count unchanged,<br/>configs match,<br/>DLL loaded}
C -->|Yes| DONE[return true]
C -->|No| D
D["EnsureDllLoaded()<br/>CountExistingDevices()"] --> E
E["WriteDeviceDescriptors()<br/>Updates registry keys"] --> F
F{requiredCount == 0?}
F -->|Yes| G1{descriptors changed<br/>AND nodes exist?}
G1 -->|Yes| G2["DisableDeviceNode()<br/>(full remove)"]
G1 -->|No| DONE
G2 --> DONE
F -->|No| H{Existing nodes?}
H -->|0 nodes| I["CreateVJoyDevices(1)<br/>Wait up to 5s for PnP bind"]
H -->|1 node| J{Descriptors changed?}
H -->|>1 nodes| K["Remove excess nodes<br/>Keep first node"]
J -->|Yes| L["RestartDeviceNode(countChanged: true)<br/>Full remove + create cycle"]
J -->|No| M{DLL loaded?}
I --> DONE
K --> L
L --> DONE
M -->|Yes| DONE
M -->|No| N[return false]
Fast path: When _currentDescriptorCount == requiredCount and configs haven't changed and the DLL is loaded, returns immediately without any pnputil enumeration or registry writes. This is critical because the method is called at 1000Hz from the polling thread.
Config change detection: Compares per-device configs (Axes, Buttons, Povs) element-by-element against _lastDeviceConfigs. Clones the config array after each call to prevent aliasing.
private static bool WriteDeviceDescriptors(int requiredCount, VJoyDeviceConfig[] perDeviceConfigs)Writes HID report descriptors to registry at HKLM\SYSTEM\CurrentControlSet\services\vjoy\Parameters\.
- Opens the
Parameterskey for writing. -
Deletes excess keys: Enumerates existing
DeviceNNsubkeys, deletes any whereNN > requiredCount. -
Writes descriptors: For each device
i(1..requiredCount):- Uses per-device config if available (
perDeviceConfigs[i-1]), otherwise defaults to Xbox 360 layout (6 axes, 11 buttons, 1 POV). - Calls
BuildHidDescriptor(reportId, axes, buttons, povs)to generate the binary descriptor. -
Change detection: Reads existing
HidReportDescriptor(REG_BINARY) and compares byte-by-byte. Only writes if the descriptor differs (avoids disturbing live devices whose driver has already read the registry). - Writes both
HidReportDescriptor(REG_BINARY) andHidReportDescriptorSize(REG_DWORD).
- Uses per-device config if available (
- Returns
trueif any registry keys were actually modified (written or deleted).
internal static bool CreateVJoyDevices(int count = 1)Creates device nodes via a PowerShell script using the same SetupAPI P/Invoke pattern as the install flow. Key differences from InstallVJoy():
| Aspect | InstallVJoy | CreateVJoyDevices |
|---|---|---|
| UAC |
RunElevated with Verb = "runas"
|
No runas (app already elevated) |
UpdateDriverForPlugAndPlayDevicesW flags |
InstallFlags = 1 (INSTALLFLAG_FORCE) |
InstallFlags = 0 (no force) |
| Service registration | Handled by pnputil /add-driver
|
Manually creates service registry key if missing |
| Driver file copy | Extracted from ZIP | Copies vjoy.sys to System32\drivers\ if missing |
| Pre-cleanup | Full cleanup of existing install | None |
The script also ensures the vjoy service registry key exists (creates it if missing with Type=1, Start=3, ErrorControl=0, ImagePath=System32\DRIVERS\vjoy.sys).
private static void RestartDeviceNode(bool countChanged = true)Restarts the single vJoy device node so the driver re-reads registry descriptors. Strategy depends on whether the descriptor count changed:
-
Content-only change (
countChanged = false): UsesDICS_PROPCHANGEto restart the driver stack in-place. This re-reads the HID descriptor from registry without recreating child PDOs. Falls through to full restart ifDICS_PROPCHANGEfails. -
Count change (
countChanged = true): Must fully remove + recreate the device node because HIDCLASS only creates child PDOs duringEvtDeviceAdd(device node creation).DICS_PROPCHANGE(stop/start) re-reads descriptors but does NOT create or destroy child PDOs.
Full restart cascade (tried in order until one succeeds):
-
SetupApiRestart.DisableDevice()(DICS_DISABLE) -
CfgMgr32.CM_Disable_DevNode()(fallback for Win11 26200+) -
SetupApiRestart.RemoveDevice()(after disable) -
pnputil /remove-device(fallback) -
SetupApiRestart.RemoveDevice()without prior disable (last resort) -
DICS_PROPCHANGEfallback (won't change PDO count but re-reads descriptors) -
DICS_ENABLEre-enable if we disabled but couldn't remove
After successful remove, creates a fresh device node via CreateVJoyDevices(1) and waits up to 5 seconds for PnP to bind.
Generation tracking: _generation is incremented on every restart or removal. Connected controllers use ReAcquireIfNeeded() to detect stale handles (generation mismatch) and re-acquire their device IDs.
internal static void DisableDeviceNode()Fully removes the vJoy device node (not just disable). Called when requiredCount == 0 (all vJoy slots inactive).
Pre-requisites before any node operation:
-
RelinquishAllDevices()-- releases all vJoy device IDs (1-16) so the driver releases its handles. Without this,CM_Disable_DevNodereturnsCR_REMOVE_VETOED(23). -
RefreshVJoyDllHandles()-- sendsWM_DEVICECHANGE / DBT_DEVICEQUERYREMOVEto vJoyInterface.dll's hidden window ("win32app_vJoyInterface_DLL"class) to close its stale internal control device handle (h0) and clear its cached device namespace. Without this, DICS_DISABLE takes ~5 seconds waiting for the handle to time out.
After successful removal, runs pnputil /scan-devices synchronously to clean up ghost child PDOs. Must be synchronous to prevent racing with ViGEm VC creation on the next polling cycle.
public static int CountExistingDevices()Delegates to EnumerateVJoyInstanceIds(), which runs pnputil /enum-devices /class HIDClass and parses the output for ROOT\HIDCLASS\* entries whose description contains "vJoy". Does NOT use GetVJDStatus (which returns stale data from the DLL's cached state). Caches results in _cachedInstanceIds for fast access during shutdown.
File: PadForge.App/Common/HidHideController.cs
Namespace: PadForge.Common
While DriverInstaller handles HidHide install/uninstall, runtime device management (blacklisting, whitelisting, cloaking) is handled by HidHideController. This static class communicates directly with the HidHide control device (\\.\HidHide) via P/Invoke IOCTLs.
| IOCTL | Code | Direction | Purpose |
|---|---|---|---|
GET_WHITELIST |
0x80016000 |
Read | Get whitelisted application paths |
SET_WHITELIST |
0x80016004 |
Write | Replace whitelisted application paths |
GET_BLACKLIST |
0x80016008 |
Read | Get blacklisted device instance IDs |
SET_BLACKLIST |
0x8001600C |
Write | Replace blacklisted device instance IDs |
GET_ACTIVE |
0x80016010 |
Read | Get cloaking active state (1 byte) |
SET_ACTIVE |
0x80016014 |
Write | Enable/disable cloaking (1 byte) |
GET/SET list operations use the Multi-SZ format: null-separated UTF-16 strings with a double-null terminator. SET operations replace the entire list (not append).
static bool IsAvailable() // Can open \\.\HidHide
static List<string> GetBlacklist() // Device instance IDs
static void SetBlacklist(List<string> ids) // Replace entire blacklist
static void AddToBlacklist(string instanceId) // Add if not present
static void RemoveFromBlacklist(string id) // Remove if present
static List<string> GetWhitelist() // DOS device paths
static void SetWhitelist(List<string> paths) // Replace entire whitelist
static void EnsureWhitelisted(string appPath) // Add PadForge if not present
static bool GetActive() // Cloaking enabled?
static void SetActive(bool active) // Enable/disable cloaking
static void RemoveManagedDevices() // Remove only PadForge's entries
static string DevicePathToInstanceId(string p) // \\?\HID#... -> HID\VID_...\..._managedDeviceIds (internal HashSet<string>) tracks which device instance IDs PadForge has added to the blacklist. RemoveManagedDevices() removes only these entries, leaving entries added by other tools (e.g., the HidHide Configuration Client) untouched.
The whitelist requires paths in DOS device format (\Device\HarddiskVolumeN\path\to\app.exe), not regular paths (C:\path\to\app.exe). ToDosDevicePath() uses QueryDosDeviceW to convert drive letters to volume device names.
DevicePathToInstanceId() converts SDL/Windows device paths (\\?\HID#VID_054C&PID_0CE6#...{guid}) to PnP device instance IDs (HID\VID_054C&PID_0CE6\...) for the blacklist:
- Strip
\\?\prefix - Remove trailing GUID suffix (
{...}) - Replace
#with\
PadForge auto-elevates when vJoy is installed (handled in App.xaml.cs). This means:
| Scenario | Elevation Method | UAC Prompts |
|---|---|---|
| ViGEmBus/HidHide install/uninstall |
msiexec via RunElevated with Verb = "runas"
|
1 per operation |
| vJoy install | Single elevated PowerShell script via RunElevated
|
1 |
| vJoy runtime (CreateVJoyDevices, etc.) | App already elevated | 0 |
| MIDI Services install | App already elevated; direct Process.Start
|
0 |
| Driver | Temp Directory |
|---|---|
| ViGEmBus | %TEMP%\PadForge_ViGEmBus\ |
| HidHide | %TEMP%\PadForge_HidHide\ |
| vJoy | %TEMP%\PadForge_vJoy\ |
| vJoy install script | %TEMP%\PadForge_vjoy_install.ps1 |
| vJoy install log | %TEMP%\PadForge_vjoy_install.log |
| vJoy uninstall script | %TEMP%\PadForge_vjoy_uninstall.cmd |
| vJoy uninstall log | %TEMP%\PadForge_vjoy_uninstall.log |
| vJoy device create script | %TEMP%\PadForge_vjoy_create_device.ps1 |
| vJoy device create log | %TEMP%\PadForge_vjoy_create_device.log |
| MIDI Services | %TEMP%\PadForge_MidiServices\ |
All temp directories are cleaned up after each operation via CleanupTempDir() in finally blocks.
All install/uninstall methods use try/finally blocks for cleanup:
- Temp directories are always deleted, even on failure.
- Script files are deleted after execution (
try { File.Delete(scriptPath); } catch { }). - Log files are deleted after reading results.
The vJoy install/uninstall scripts use $ErrorActionPreference = 'Continue' so individual step failures do not abort the entire script. Each critical step logs its result:
- Install script: Every SetupAPI call checks the return value, logs
"FAIL: ..."with the Win32 error code, and exits with code 1. - Uninstall script: Every command redirects stderr to null (
>nul 2>&1) and continues regardless of failure (best-effort cleanup). -
CleanVJoyRegistryArtifacts(): Every registry deletion catches exceptions individually (never aborts on a single missing key).
There is no explicit rollback mechanism. If a driver install fails partway through:
- ViGEmBus/HidHide: MSI installer handles its own rollback.
- vJoy: Pre-install cleanup removes the previous installation first, so a failed install leaves the system in a "not installed" state rather than a corrupted state. Re-running install will start fresh.
- MIDI Services: WiX Burn bootstrapper handles its own rollback.