Skip to content

Input Precision

hifihedgehog edited this page Mar 15, 2026 · 4 revisions

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.


Polling Architecture

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)

3-Tier Sleep Strategy

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.

Wall-Clock Drift Compensation

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.


Axis Value Pipeline

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.

Stage 1: SDL3 Input (16-bit signed)

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

Stage 2: CustomInputState (16-bit unsigned)

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

Stage 3: Gamepad Struct (16-bit signed)

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.

Stage 4: Dead Zone Processing (64-bit floating point)

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.

Stage 5: vJoy Output (15-bit unsigned)

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.

Stage 5 (Alt): ViGEm Output (native)

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.


Dead Zone Math

PadForge's dead zone processing uses the same math as x360ce, validated over years of community use. All intermediate calculations use double precision.

Parameters

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

Default Settings = Bit-Perfect

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.

Non-Default Settings

When any parameter is non-zero (or max range < 100%), the value enters the double-precision pipeline:

  1. Normalize to 0.0–1.0 range (double division)
  2. Apply dead zone — values below threshold map to 0.0
  3. Rescale remaining range to fill 0.0–1.0
  4. Apply anti-dead zone — shift minimum output up
  5. Apply linear curve — power function for response shaping
  6. Apply max range — scale down maximum
  7. 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.


POV Hat Support

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)

Output Throughput

vJoy: Single-Call Batch Update

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.

ViGEm: Direct Struct Submit

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.


Summary for Flight Sim Users

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.

Clone this wiki locally