Skip to content

Input Precision

hifihedgehog edited this page May 25, 2026 · 19 revisions

Input Precision

PadForge polls at 1000 Hz, holds 16-bit stick values, and passes the raw axis through to the virtual controller bit-for-bit at default settings.

Sticks tab: where the precision pipeline ends up visible to you

This page covers the polling loop, the axis value pipeline, the deadzone math, POV hats, and output throughput. It uses an Extended slot (HIDMaestro custom HID) as the reference path. That path has the fewest protocol constraints. Xbox and PlayStation slots use bit depths fixed by the protocol. See Stage 5: HIDMaestro output for the per-type breakdown.

Related pages: Stick Deadzones, Trigger Deadzones, Force Feedback, Controller Slots.


Polling rate

PadForge polls every connected device at 1000 Hz (1 ms cycle) on 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 Three tiers (see below), each followed by SpinWait
Drift compensation Wall-clock cumulative tracking with per-cycle adjustment
Jitter Sub-millisecond. SpinWait removes OS scheduler variance
GC pressure Zero allocations in the hot path. No GC pauses during polling
Idle rate ~20 Hz when no slots are created (CPU savings)

Three-tier sleep strategy

The polling loop picks the best timer available at startup. If the top tier fails, it falls through.

Tier 1: high-resolution waitable timer. CreateWaitableTimerExW with CREATE_WAITABLE_TIMER_HIGH_RESOLUTION. Windows 10 1803 and later. The kernel scheduler services the timer at sub-ms resolution. Clusters tighter around the target than Thread.Sleep does. No busy-wait CPU cost during the kernel sleep portion.

Tier 2: multimedia timer. timeSetEvent fires a periodic callback that signals a ManualResetEvent. The polling thread blocks on WaitOne() until the callback fires. Precision is 1 to 2 ms with timeBeginPeriod(1). The same approach x360ce used.

Tier 3: Thread.Sleep(1) plus SpinWait. Legacy fallback when both timers fail. Thread.Sleep(1) absorbs the bulk wait when more than 1.5 ms remains.

All three tiers finish with Thread.SpinWait(1) in a tight loop for the final sub-ms portion. That keeps the cycle boundary precise.

Wall-clock drift compensation

The loop holds a cumulative expectedTicks counter, incremented by targetTicks each iteration. Each cycle it compares wallClock.ElapsedTicks to expectedTicks. If the loop is behind, the next sleep shortens. If it is ahead, the next sleep lengthens. The long-term average rate matches the target Hz exactly.

If drift exceeds 10× the target interval (after sleep/resume or when leaving idle mode), the wall clock resets. That prevents a burst of short catch-up cycles.


Axis value pipeline

Every axis value walks a defined pipeline with known precision at each stage. At default settings the pipeline is bit-perfect passthrough with zero processing.

Stage 1: SDL3 input (signed 16-bit)

SDL3 reads the raw axis value from the OS HID driver and returns a signed 16-bit integer.

Property Value
Type short (Int16)
Range -32768 to +32767
Resolution 65536 positions
Source SDL_GetJoystickAxis / SDL_GetGamepadAxis

Stage 2: CustomInputState (unsigned 16-bit)

SDL values shift 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 (signed 16-bit)

The value shifts back to signed 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 to 65535) at the same 16-bit resolution as stick axes. The full 16-bit range survives the deadzone pipeline. The on-the-wire bit depth depends on which HID descriptor HIDMaestro encodes the report against. See Stage 5.

Stage 4: Deadzone processing (64-bit floating point)

When deadzone, anti-deadzone, or linear curve settings are non-default, the value runs 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% deadzone, 0% anti-deadzone, 0% linear, 100% max range), the deadzone function returns immediately without any floating-point math. The axis value passes through bit-for-bit unchanged. Zero processing. Zero rounding error.

Stage 5: HIDMaestro output

Every virtual controller submits through HIDMaestro. PadForge converts the internal Gamepad struct (signed 16-bit sticks, unsigned 16-bit triggers) to normalized float at the SDK boundary.

LeftStickX  = gp.ThumbLX / 32767f      // -1.0 .. +1.0
LeftTrigger = gp.LeftTrigger / 65535f  //  0.0 .. +1.0

Single-precision float represents every integer in the [-32768, +32767] range exactly (the float mantissa is 24 bits). The conversion is lossless. HIDMaestro then encodes the report against whichever HID descriptor the slot's profile defines.

Extended slots (HIDMaestro custom HID)

This is the path you use when you build a custom controller in PadForge. An Extended slot backed by a HIDMaestro profile built with HMProfileBuilder (AddStick, AddTrigger, AddButtons, AddHat, FFB pages). It has the fewest protocol constraints. Default precision:

Property Sticks Triggers
Type unsigned 16-bit unsigned 16-bit
Range 0 to 65535 0 to 65535
Resolution 65536 positions 65536 positions
Bits 16 effective bits 16 effective bits

Sticks and triggers are 16-bit unsigned (HID logical range 0 to 65535) by default in PadForge's Extended profile builder. HIDMaestro requires axis bit depths to be a multiple of 8. The only valid alternative is 8-bit. Non-aligned sizes (such as 10-bit) are rejected because they force a Const pad item that Chromium's RawInput parser surfaces as a phantom axis.

16-bit stick resolution exceeds the effective precision of typical consumer analog sticks. Most consumer-controller stick ADCs report 10 to 12 bits of effective resolution. HIDMaestro's 16-bit output (65536 positions) preserves every bit the hardware can produce, double the resolution of vJoy's 15-bit (32768-position) format. That precision floor matters for flight sticks and HOTAS, where small stick deflections drive long-throw control surfaces. It matters for racing wheels, where smooth sub-degree steering input reaches the sim without quantization.

POV hats use continuous values (0 to 35900 in hundredths of a degree) for full 8-way diagonal support. See POV hat support.

Xbox slots (Xbox 360 protocol)

Xbox slots emit the Xbox 360 HID report. The protocol fixes the bit depth, not HIDMaestro.

Property Sticks Triggers
Type signed 16-bit unsigned 8-bit
Range -32768 to +32767 0 to 255
Resolution 65536 positions 256 positions

Sticks pass through at full input resolution. Triggers downsample to 8-bit because the Xbox 360 HID descriptor uses one byte per trigger.

PlayStation slots (DS4 / DualSense protocol)

PlayStation slots emit the DualShock 4 or DualSense HID report depending on the selected HIDMaestro profile. Both protocols set sticks and triggers at 8-bit unsigned.

Property Sticks Triggers
Type unsigned 8-bit unsigned 8-bit
Range 0 to 255 0 to 255
Resolution 256 positions 256 positions

If you want 16-bit stick precision and PlayStation button labels at the same time, use an Extended slot with a custom DS4-style or DualSense-style profile. Extended profiles are not bound by the original protocol's bit depth.


Deadzone math

PadForge uses the same deadzone math as x360ce. All intermediate calculations run in double precision.

Parameters

Parameter Range Default Effect
Deadzone 0–100% 0% Input below this threshold maps to zero
Anti-Deadzone 0–100% 0% Minimum output value. Cancels the game's built-in deadzone
Linear -100–100% 0% Response curve. Negative = more sensitive near center, positive = less sensitive
Max Range 0–100% 100% Limits the maximum output

Default settings = bit-perfect

When every parameter is at default, the deadzone function detects it and returns the input value unchanged. No floating-point conversion. No rounding. 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 the 0.0–1.0 range (double division).
  2. Apply deadzone. Values below the threshold map to 0.0.
  3. Rescale the remaining range to fill 0.0–1.0.
  4. Apply anti-deadzone. Shift the minimum output up.
  5. Apply linear curve. Power function for response shaping.
  6. Apply max range. Scale the maximum down.
  7. Convert back to integer output range.

Every step uses double arithmetic. Quantization happens only at the final integer conversion. It introduces less than 1 LSB (least significant bit) of error. That is well below the noise floor of any physical controller.


POV hat support

PadForge uses continuous POV values (0 to 35900 in hundredths of degrees) instead of discrete 4-way POV. That gives 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

Every virtual controller submits its full state to HIDMaestro in one HMController.SubmitState call per frame. Each call passes a complete HMGamepadState (or a custom-HID payload for Extended profiles). HIDMaestro forwards each call to the kernel as a single HID report write. Per-frame throughput cost is constant. It does not scale with the number of axes, buttons, or hats in the profile.

There is no per-axis or per-button kernel call. The 1000 Hz polling loop submits one frame per controller per cycle. That scales linearly with slot count, with no per-element IOCTL overhead like vJoy's old SetAxis / SetBtn / SetDiscPov API forced on callers.

Clone this wiki locally