-
Notifications
You must be signed in to change notification settings - Fork 2
Input Precision
PadForge's input pipeline is designed for precision-critical applications like flight simulators, racing wheels, and HOTAS setups. This page details the polling architecture, axis value pipeline, dead zone math, and output fidelity at each stage.
PadForge polls all connected devices at 1000 Hz (1 ms interval) using a dedicated background thread.
| Property | Value |
|---|---|
| Target rate | 1000 Hz (1 ms) |
| Thread priority | AboveNormal |
| Timer resolution |
timeBeginPeriod(1) for OS-level 1 ms granularity |
| Sleep strategy | 3-tier: HR waitable timer > multimedia timer > Thread.Sleep(1), all followed by SpinWait |
| Drift compensation | Wall-clock cumulative tracking with per-cycle adjustment |
| Jitter | Sub-millisecond — spin-wait eliminates OS scheduler variance |
| GC pressure | Zero allocations in the hot path — no garbage collection pauses during polling |
| Idle mode | ~20 Hz when no slots created (CPU savings) |
The polling loop selects the best available sleep mechanism at startup and falls through tiers if unavailable:
Tier 1: High-Resolution Waitable Timer (CreateWaitableTimerExW with CREATE_WAITABLE_TIMER_HIGH_RESOLUTION). Available on Windows 10 1803+. Achieves tighter clustering around the target interval than Thread.Sleep because the kernel scheduler services the timer at sub-ms resolution. No busy-wait CPU cost during the kernel sleep portion.
Tier 2: Multimedia Timer (timeSetEvent periodic callback signals a ManualResetEvent). The x360ce-style approach. The polling thread blocks with WaitOne() until the callback fires. Precision is ~1-2ms with timeBeginPeriod(1) — less accurate than the HR timer but much better than Thread.Sleep(1) alone.
Tier 3: Thread.Sleep(1) + SpinWait. Legacy fallback when both timers fail. Thread.Sleep(1) absorbs bulk wait when >1.5ms remains.
All three tiers finish with Thread.SpinWait(1) in a tight loop for the final sub-ms portion, ensuring precise cycle boundaries.
Instead of tracking per-cycle overshoot, the loop maintains a cumulative expectedTicks counter incremented by targetTicks each iteration. The actual wallClock.ElapsedTicks is compared; if the loop is behind, the next cycle's sleep is shortened, and if ahead, it is lengthened. This converges the long-term average rate exactly to the target Hz.
If drift exceeds 10x the target interval (e.g., after system sleep/resume or exiting idle mode), the wall clock resets to prevent a burst of short catch-up cycles.
Every axis value passes through a well-defined pipeline with known precision at each stage. At default settings, the pipeline provides bit-perfect passthrough with zero processing overhead.
SDL3 reads raw axis values from the OS HID driver and returns them as signed 16-bit integers.
| Property | Value |
|---|---|
| Type |
short (Int16) |
| Range | -32768 to +32767 |
| Resolution | 65536 positions |
| Source |
SDL_GetJoystickAxis / SDL_GetGamepadAxis
|
SDL values are converted to unsigned for internal processing:
unsigned = sdlValue + 32768
| Property | Value |
|---|---|
| Type |
int (stored as unsigned range) |
| Range | 0 to 65535 |
| Resolution | 65536 positions |
| Conversion | Lossless linear shift |
Mapped back to signed range for the XInput-compatible output struct:
signed = (short)(unsigned - 32768)
| Property | Value |
|---|---|
| Type |
short (Int16) |
| Range | -32768 to +32767 |
| Resolution | 65536 positions |
| Conversion | Lossless linear shift (inverse of Stage 2) |
Triggers use ushort (0–65535), the same 16-bit resolution as stick axes. The full 16-bit range is preserved through the dead zone pipeline and output to vJoy (scaled to 15-bit 0–32767). Xbox 360 and DualShock 4 virtual controllers downscale to byte (0–255) at the final ViGEm output stage — this is a protocol constraint of those controller formats, not a PadForge limitation.
When dead zone, anti-dead zone, or linear curve settings are non-default, axis values are processed through a double-precision floating-point pipeline:
normalized = value / 32767.0 // double precision
processed = ApplyDeadZone(normalized) // all math in double
output = (short)(processed * 32767.0) // back to integer
| Property | Value |
|---|---|
| Internal precision |
double (64-bit IEEE 754) |
| Significant digits | ~15 decimal digits |
| Quantization error | < 1 LSB when converted back to 16-bit |
At default settings (0% dead zone, 0% anti-dead zone, 0% linear, 100% max range), the dead zone function returns immediately without any floating-point math. The axis value passes through bit-for-bit unchanged — zero processing, zero rounding error.
For vJoy (custom DirectInput) controllers, the signed 16-bit value is converted to vJoy's HID logical range:
vjoyValue = (signedValue + 32768) / 2
| Property | Value |
|---|---|
| Type |
int (unsigned range) |
| Range | 0 to 32767 |
| Resolution | 32768 positions |
| Bits | 15 effective bits |
The division by 2 maps the full signed 16-bit range into vJoy's standard HID logical range (0–32767). This is inherent to the vJoy HID descriptor format, not a PadForge design choice.
15-bit resolution exceeds the physical precision of all consumer controller hardware. Typical controller ADCs (analog-to-digital converters) provide 10–12 bits of effective resolution. The 15-bit output preserves every bit the hardware can produce.
For Xbox 360 and DualShock 4 virtual controllers via ViGEmBus, the Gamepad struct is submitted directly — no additional conversion. The signed 16-bit values pass through to the virtual device as-is.
PadForge's dead zone processing uses the same math as x360ce, validated over years of community use. All intermediate calculations use double precision.
| Parameter | Range | Default | Effect |
|---|---|---|---|
| Dead Zone | 0–100% | 0% | Input below this threshold maps to zero |
| Anti-Dead Zone | 0–100% | 0% | Minimum output value — eliminates the game's built-in dead zone |
| Linear | -100–100% | 0% | Response curve: negative = more sensitive near center, positive = less sensitive |
| Max Range | 0–100% | 100% | Limits maximum output |
When all parameters are at their defaults, the dead zone function detects this and returns the input value unchanged. There is no floating-point conversion, no rounding, and no precision loss. The raw SDL axis value arrives at the output driver bit-for-bit identical to what the hardware reported.
When any parameter is non-zero (or max range < 100%), the value enters the double-precision pipeline:
- Normalize to 0.0–1.0 range (double division)
- Apply dead zone — values below threshold map to 0.0
- Rescale remaining range to fill 0.0–1.0
- Apply anti-dead zone — shift minimum output up
- Apply linear curve — power function for response shaping
- Apply max range — scale down maximum
- Convert back to integer output range
Every step uses double arithmetic. The only quantization occurs at the final integer conversion, introducing less than 1 LSB (least significant bit) of error — well below the noise floor of any physical controller.
PadForge uses continuous POV values (0–35900 in hundredths of degrees) rather than discrete 4-way POV. This provides full 8-way diagonal support:
| Direction | Value |
|---|---|
| North | 0 |
| Northeast | 4500 |
| East | 9000 |
| Southeast | 13500 |
| South | 18000 |
| Southwest | 22500 |
| West | 27000 |
| Northwest | 31500 |
| Centered | -1 (0xFFFFFFFF) |
PadForge submits all axes, buttons, and POV values in a single UpdateVJD call per frame per virtual controller. This is a single kernel IOCTL that writes the entire JoystickPositionV2 struct atomically.
The alternative — calling individual SetAxis, SetBtn, SetDiscPov functions — would issue one kernel IOCTL per call (~1–2 ms each), degrading a 1000 Hz loop to ~11 Hz with just two controllers. PadForge avoids this entirely.
Xbox 360 and DualShock 4 virtual controllers receive their state via a single SubmitReport call per frame, passing the complete gamepad struct in one operation.
| Concern | PadForge Behavior |
|---|---|
| Polling rate | 1000 Hz, sub-ms jitter (HR waitable timer preferred) |
| Axis resolution (sticks) | 65536 positions input, 32768 positions vJoy output |
| Axis resolution (triggers) | 65536 positions input, 32768 positions vJoy output, 256 positions Xbox 360/DS4 output |
| Dead zone at defaults | Bit-perfect passthrough, zero processing |
| Dead zone precision | Double-precision (64-bit) floating point |
| Response curve | Configurable linear curve with double-precision math |
| POV hat | 8-way continuous (hundredths of degrees) |
| Output latency | Single kernel call per frame per controller |
| GC pauses | None — zero allocations in polling loop |
| Drift compensation | Wall-clock cumulative tracking, exact long-term Hz |
| Thread priority | AboveNormal with OS timer resolution set to 1 ms |
For maximum fidelity, leave dead zone, anti-dead zone, and linear settings at their defaults (all zero). This provides a completely transparent passthrough from your physical controller to the virtual device with no mathematical processing applied to the axis values.