-
Notifications
You must be signed in to change notification settings - Fork 5
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.

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.
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) |
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.
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.
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.
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
|
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 |
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.
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.
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.
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 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 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.
PadForge uses the same deadzone math as x360ce. All intermediate calculations run in double precision.
| 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 |
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.
When any parameter is non-zero (or max range < 100%), the value enters the double-precision pipeline:
- Normalize to the 0.0–1.0 range (double division).
- Apply deadzone. Values below the threshold map to 0.0.
- Rescale the remaining range to fill 0.0–1.0.
- Apply anti-deadzone. Shift the minimum output up.
- Apply linear curve. Power function for response shaping.
- Apply max range. Scale the maximum down.
- 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.
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) |
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.