Skip to content

Wheel Force Feedback Internals

How a game's force feedback reaches a real wheel: the HID PID decode chain, the three vendor writers, the host-sampled and software paths in ForceFeedbackState, and the RPM-LED telemetry system.

This is the developer-side companion to Wheel (the user guide). See Force Feedback for the rumble and generic-haptic side.


Two pipelines, one set of writers

Two force pipelines converge on the same vendor writers:

  • Game PID force feedback (DirectInput games): the game writes HID PID output reports to an Extended virtual controller. HMaestroFfbDecoder parses them into a Vibration carrying direction and condition data.
  • XInput rumble (Xbox-target games): the two motor bytes become scalar Vibration fields, which wheels translate into an oscillating constant force.

Both land in the slot's VibrationStates entry, which ApplyForceFeedback in InputManager.Step2.UpdateInputStates.cs merges into the single aggregation buffer _combinedVibration. Everything downstream reads from it.

A third destination joined in 4.1.0: the Bass Shakers lane (#236) turns the same game force into low-frequency audio. It bypasses _combinedVibration and the writers:

  • HMaestroFfbDecoder.Apply stores the game-authored motor pair in LastComputedMotors, computed purely from the uploaded PID effect set, so macro rumble (a downstream per-device merge) never reaches it.
  • TickFfb packs the pair into the virtual controller's inbound rumble pack via LfeOutputState.Pack.
  • UpdateRumbleAudioLane in InputManager.Step5.VirtualDevices.cs publishes each slot's pack once per poll tick through RumbleAudioService.PublishIfCurrent. It runs after UpdateVirtualDevices so a slot destroyed on this tick publishes zeros on the same tick, and it max-merges the slot's live VibrationStates onto the pack, which is what makes the preview's test-rumble buttons audible on the shakers. A voice the config disables is masked out of the pack by its 16-bit lane.

So game force feedback reaches vendor wheels, scalar rumble devices, and bass-shaker audio endpoints. The slot's Bass Shakers tab configures the endpoint. See Force Feedback for the user-facing side.


Files

File Role
PadForge.App/Common/Input/HMaestroVirtualController.cs Forwards HM HID-output and HID-feature packets into the decoder, and writes XInput motor bytes straight to the slot's VibrationStates entry.
PadForge.App/Common/Input/HMaestroFfbDecoder.cs Parses HID PID reports into a Vibration.
PadForge.App/Common/Input/InputManager.Step2.UpdateInputStates.cs ApplyForceFeedback aggregates per slot and dispatches to a vendor writer.
PadForge.App/Common/Input/LogitechRawHidWriter.cs Logitech native HID. Original C#, wire protocol checked against new-lg4ff, read as documentation.
PadForge.App/Common/Input/FanatecRawHidWriter.cs Fanatec native HID. Original C#, wire protocol checked against hid-fanatecff, read as documentation.
PadForge.App/Common/Input/ThrustmasterRawHidWriter.cs Thrustmaster native HID. Original C#, wire protocol checked against hid-tmff2, read as documentation.
PadForge.App/Common/Input/WheelRangeLimits.cs The rotation range each native wheel takes, from its writer's RangeLimits. The Wheel tab's slider spans it.
PadForge.Engine/Common/ForceFeedbackState.cs The Vibration carrier, the host-sampled and software paths, the SDL writer for generic devices.
PadForge.App/Common/Input/InputManager.Step3.SteeringLockFeedback.cs The at-lock feedback channels for a steering source.
PadForge.App/Common/Telemetry/ The RPM-LED telemetry sources, hub, and LED map.

Decode chain

HMaestroVirtualController holds an HMaestroFfbDecoder, constructed when the controller's HID descriptor carries the PID force-feedback block (the gate is descriptor presence, not VID, so catalog profiles keep their identity). The HM output callback routes by packet source: a HID-output report goes to _ffbDecoder.OnHidOutput then immediately Apply, a HID feature report (the Create-New-Effect path) goes to OnHidFeature, and an XInput packet writes the scalar motor bytes directly and zeroes the trigger motors, since the XUSB wire carries none. A per-tick TickFfb calls ApplyIfDue so duration-bounded effects expire even without new packets.

HMaestroFfbDecoder.OnHidOutput dispatches per report ID: Set Effect (0x11), Set Condition (0x13, the bit-packed per-axis condition coefficients for spring, damper, inertia, and friction), Set Periodic (0x14), Set Constant (0x15), Set Ramp (0x16), Effect Operation (0x1A, start and stop), Block Free (0x1B), Device Control (0x1C), and Device Gain (0x1D). It also handles the pre-allocation flow where the OS writes parameters before the SetFeature allocates the real effect block. ParseFfbScales walks the report descriptor once at construction to normalize hand-authored descriptors (the SideWinder ranges) into canonical units.

Apply(Vibration vib) finds the dominant effect (the largest magnitude after its own effect gain), writes that effect's type, signed magnitude, direction, and period along with the device gain, splits the polar direction into left and right motor scalars so rumble-only devices still feel directional force, and copies the spring, damper, inertia, and friction coefficients into the vibration's condition axes.

The Vibration carrier lives in PadForge.Engine/Common/ForceFeedbackState.cs with scalar motor fields, directional fields (EffectType, SignedMagnitude, Direction, Period, DeviceGain), and condition fields.


Aggregation and dispatch

ApplyForceFeedback(UserDevice ud) in Step 2 combines every slot the device is mapped to (motors max-combined, the first slot with directional or condition data becomes the directional source) into _combinedVibration, then dispatches by device identity:

  • Sony pads (DualSense, DualShock 4) return early. They are written by UserEffectsDispatcher and PlayStationEffectWriter.
  • A Remote Link device (a peer:// path) ships through RemoteLinkOutputRouter instead of writing locally. A vendor wheel ships a semantic wheel frame, and every other device ships the combined Vibration.
  • Xbox One and later take the XboxImpulseHidWriter path.
  • Padix PSX/USB converters take the PadixConverterRawHidWriter path.
  • Bliss-Box adapter ports take the BlissBoxRuntime.SetRumble path while Read Bliss-Box Adapters is on.
  • Logitech, Fanatec, and Thrustmaster wheels and Fanatec pedals (gated by IsLogitechWheel / IsFanatecWheel / IsThrustmasterWheel / IsFanatecPedal) take the vendor-writer block.
  • Everything else falls to ForceFeedbackState.SetDeviceForces (the SDL path).

Per-frame force is throttled by a change-detection struct cached in _appliedWheelFfb, because re-sending an unchanged force every poll is a blocking HID write that halves the poll rate with a wheel attached.


The three vendor writers

All three are internal static class, frame a vendor-shaped command padded to the device's output-report length, and are original C# written against the wire protocols the open Linux drivers document. Those drivers are GPL and were read as documentation only, so none of their code ships.

Logitech (LogitechRawHidWriter, VID 0x046D)

IsLogitechWheel gates G29, G923 (PlayStation variant), G27, G25, Driving Force GT, Driving Force Pro, Driving Force, MOMO Racing, MOMO Force, and WingMan Formula Force GP. The G920 and the Xbox G923 speak HID++ 2.0 and are excluded. Effects: WriteConstantForce (with a per-slot download then refresh state), WriteCondition (spring command 0x0b, friction command 0x0e only on the G27, G25, DFGT, and DFP, else the damper command for damper and inertia), WriteAutocenter (a firmware spring), WriteRange (f8 81 for most, a coarse-plus-fine path for the DFP), and WriteRpmLeds (f8 12, five LEDs). No firmware periodic, so periodic effects arrive host-sampled.

Fanatec (FanatecRawHidWriter, VID 0x0EB7)

IsFanatecWheel gates the CSL Elite, CSL DD, DD Pro, ClubSport DD, ClubSport V2 and V2.5, Podium DD1 and DD2, CSR Elite, and Porsche 911 bases. IsFanatecPedal gates the ClubSport V3 and CSL pedals (rumble only). High-resolution bases write 16-bit force. WriteAutocenter is a software centering spring routed through a slot-1 condition and re-asserted with every constant-force write, because Fanatec bases expose no firmware auto-center and the range command disables the stock spring. WriteRange sends three reports. WriteRpmLeds writes the base strip and the rim strip separately, nine LEDs. No firmware periodic.

Every wheel and pedal command goes out on report 0x01 (CommandReportId), the first output report a Fanatec base declares. hid-fanatecff sends all of its commands through that report (ftec_init), and Linux writes its ID ahead of the payload. Through 5.1.0 the wheel commands went out as report 0x00, which a base that numbers its reports refuses, so a CSL DD got no force feedback. The pedal rumble already used 0x01. No Fanatec base has run the change yet.

Thrustmaster (ThrustmasterRawHidWriter, VID 0x044F)

IsThrustmasterWheel gates the T300RS variants, T-GT II, T248, TX, TS-XW, and TS-PC. The T150 uses a separate driver and is excluded. This writer is stateful and multi-packet (upload, play, update, stop), with a per-device armed-kind cache so switching effect kind re-uploads while the same kind updates live. It is the only writer with WritePeriodic, a firmware-native periodic generator (WaveformCode maps square, triangle, sine, and the two sawtooths). It takes the un-sampled peak, not the host-sampled level. WriteAutocenter and WriteRange are firmware-native. WriteRpmLeds writes fifteen LEDs.


Host-sampled and software paths

ForceFeedbackState holds the static math used by the dispatch plus the SDL writer for generic devices:

  • ComputeWheelSteeringPeak gain-scales the signed magnitude and projects the polar direction onto the steering axis. This is the steady amplitude Thrustmaster uploads as a firmware periodic.
  • ComputeWheelSteeringLevel multiplies that peak by PeriodicWaveform, the instantaneous waveform multiplier. For Logitech and Fanatec, which have no firmware periodic generator, a periodic effect becomes this oscillating constant force, sampled each frame.
  • ComputeWheelRumbleLevel turns rumble-only motor values into an oscillating constant force (heavy motor on a 120 ms period, light on 40 ms). This is what wheels get from XInput games.
  • TryApplyAutoCenterSpring is the software auto-center for generic SDL wheels. It reads AutoCenterStrength and builds a symmetric SDL_HAPTIC_SPRING. Vendor wheels never reach here.
  • IsGenericWheelSpringCapable(hasHaptic, hapticFeatures, inputDeviceType) is the one predicate deciding whether a device may hold that spring: haptic present, SDL_HAPTIC_SPRING in the feature mask, and InputDeviceType.Driving (which SdlDeviceWrapper derives from SDL_JOYSTICK_TYPE_WHEEL). The Wheel tab's hasGenericWheel gate calls the same predicate, so the tab and the spring cannot disagree.

The old clause was NumHapticAxes <= 1. That hid the tab for a wheelbase-plus-pedals composite reporting two haptic axes despite a working spring, which is the Moza shape (#282). Axis count is a shape detail, not an identity, so device type replaced it.

The spring is built as SDL_HAPTIC_CARTESIAN with dir0 = 1, not STEERING_AXIS. SDL encodes STEERING_AXIS as a DirectInput Cartesian effect with a zero direction vector, and Moza is the vendor Linux had to special-case for exactly that on condition effects (HID_PIDFF_QUIRK_FIX_CONDITIONAL_DIRECTION discards the caller's direction and forces 0x4000). Constant and periodic effects keep their real direction: STEERING_AXIS on a device that reports one haptic axis, SDL_HAPTIC_POLAR on one that reports two or more.


Wheel-tab settings

PadSetting.RotationRange (default 900), RotationRangeEnabled (default 1), AutoCenterStrength (default 0), and WheelRpmLeds (default 0) all participate in the settings checksum. The PadViewModel bridges them through InputService. In ApplyForceFeedback, range and auto-center are one-shot, re-sent only when the cached _appliedWheelSettings changes.

RotationRangeEnabled "0" is the Wheel tab's Set Rotation Range cleared (#512): WheelRangeLimits.SendsRange is false, and the one-shot sends the auto-center alone, never WriteRange. The cached range is 0 then, so turning the setting back on sends the range again. For a Fanatec base this also leaves out the f5 coarse-limit report that switches off the firmware centering spring, the spring hid-ftecff disables by setting the range at probe. Through the Remote Link the frame still carries the range, with flag bit 3 (8) of the wheel frame set (OutputEffectCodec.EncodeWheel, rangeLeftToWheel). The owner's InputService.ApplyRemoteWheel reads RemoteWheelRange, 0 for a range left to the wheel, and sends no range then. An owner from before the bit reads bits 0 to 2 only and sets the range it always did.

Each writer's RangeLimits(pid) is the range a model takes. WriteRange clamps to it, and WheelRangeLimits.For gives the Wheel tab the same bounds, which the slider spans through WheelRangeMinimum and WheelRangeMaximum. A wheel with a fixed range (MOMO, Driving Force, WingMan Formula Force GP) has one bound at both ends. The bounds load with the device whose PadSetting loads, through PadViewModel.LoadWheelRotationRange, called from InputService.LoadPadSettingIntoViewModel and from the startup load in SettingsService.LoadPadSettings. They never change from the page's device sync. A device switch saves the outgoing device's values before the incoming device loads, and bounds changed between the two would clamp one wheel's saved range by the other wheel's limits (#512).

Thrustmaster's ranges follow Thrustmaster's documentation where hid-tmff2 differs. The TS-PC and TS-XW take 40–1080°, as their SDK notes state, where hid-tmff2 stops at 140°. A TS-PC set to 40° was sent 140° before this (#512). The T248 takes 180–900°: its support page gives 180° as its minimum. The TX keeps hid-tmff2's 140–900°, since no Thrustmaster document gives its minimum.

The tab-visibility gate is PadPage.SyncTabVisibility. hasWheel is the OR of the three IsXxxWheel gates on the selected device's VID and PID. hasGenericWheel covers a non-vendor SDL wheel that can only self-center: not hasWheel, CapType == InputDeviceType.Driving, and ForceFeedbackState.IsGenericWheelSpringCapable(ud.Device). Moza bases land here, not in a vendor writer. The Wheel tab shows when either is true, but the rotation-range and RPM-LED rows show only for hasWheel. See Wheel for the user-facing behavior.


Steering at-lock feedback

This lane is separate from the force path above and runs in Step 3, not Step 2. ApplySteeringLockFeedback fires once per assigned device per tick from InputManager.Step3.UpdateOutputStates.cs, walking the slot's rows for a steering target driven by a steering Kind (winding or angle) or by a Motion Lean descriptor, whose lock state lives in the same SourceKindRuntime machine.

Four channels, each with its own PadSetting toggle, all off by default:

  • Rumble pulse (SteeringLockRumbleEnabled): a reactive grip-motor pulse on the lock-entry edge through MacroRumbleOverrides.
  • Trigger pulse (SteeringLockTriggerVibEnabled): the same edge on SteeringTrigVibOverrides, a separate override whose scalar routes to the trigger actuators. Xbox impulse triggers pick it up in ApplyForceFeedback through GetSteeringTrigVib, DualSense trigger haptics through the dispatcher. Firing the grip override here would duplicate channel 1 and never reach the trigger actuators.
  • Lightbar pulse (SteeringLockLightbarEnabled): every per-device lightbar on the slot pulses to the lock color, with its own hold and fade windows.
  • Adaptive-trigger resistance (SteeringLockATResistanceEnabled): continuous, not an edge. SteeringAtResistance[slot] holds the maximum lock approach across the slot's steering rows, read by UserEffectsDispatcher through SteeringAtResistanceProvider.

Timings parse from the same settings: SteeringLockPulseMs (default 80) for the rumble and trigger pulses, SteeringLockLightbarHoldMs (80) and SteeringLockLightbarFadeMs (250) for the lightbar.

Two ordering rules are load-bearing. TryGetLockEdgeTransition clears the pending edge as it reads, so the test-target scoping check has to return before the row loop, or a non-target device consumes the edge the test target was about to act on. And that early return deliberately leaves SteeringAtResistance alone rather than zeroing it, except when nothing on the slot is the test target at all, in which case no pass would write the value and the last nonzero reading would stick.


RPM-LED telemetry

When WheelRpmLeds is on and the device is a vendor wheel, Step 2 requests telemetry, computes the rev fraction, and writes the matching WriteRpmLeds mask only when it changes.

TelemetryHub is demand-driven. RequestActive (called each frame a wheel wants LEDs) starts a 60 Hz background poll and auto-stops three seconds after the last request. It polls the registered sources in order and publishes the first with a non-zero max RPM. RpmLedMap turns the rev fraction into a bit-0-first LED mask, with a redline blink, and holds the LED counts (5 Logitech, 9 Fanatec, 15 Thrustmaster).

Each source implements ITelemetrySource (Start, Stop, TryGetSnapshot) and reads RPM and max RPM from a shared-memory map or a UDP feed. BuildSources sets the order:

Source Game Channel
GameTelemetryRowSource(learnedMax: false) (#531) the game the Game Telemetry row reads, when it sends a max RPM the row
ForzaUdpTelemetrySource Forza Horizon and Motorsport UDP
AssettoCorsaTelemetrySource Assetto Corsa and Competizione shared memory
IRacingTelemetrySource iRacing shared memory
RFactor2TelemetrySource rFactor 2 and Le Mans Ultimate shared memory
RaceRoomTelemetrySource RaceRoom shared memory
ScsTruckTelemetrySource Euro Truck Simulator 2 and American Truck Simulator shared memory
MadnessTelemetrySource Automobilista 2 and Project CARS 2 and 3 shared memory
RFactor1TelemetrySource rFactor 1 and Automobilista 1 shared memory
CodemastersUdpTelemetrySource F1 22 to F1 25 (UDP formats 2022 to 2025), DiRT, GRID UDP
OutGaugeTelemetrySource BeamNG and Live for Speed UDP
GameTelemetryRowSource(learnedMax: true) (#531) the game the Game Telemetry row reads, when it sends no max RPM the row

GameTelemetryRowSource opens nothing. It hands the hub the engine values the Game Telemetry row last decoded, through GameTelemetryRuntime.TryGetEngine. That answers only while the row is live, the car is on track (or the format never says), the max RPM is above zero, and the row published within EngineStaleMs (1000 ms).

The hub holds two of them. The first answers when the game sent its max RPM. For a game that sends none, the row learns the max from the highest RPM seen, and the second answers, last in the order, so a source with the game's own max answers before it. For Euro Truck Simulator 2, that is scs-sdk-plugin's. A learned max equals the RPM on the first frame at idle and each time the engine passes its old peak. The second source therefore raises it to 1.05 times the RPM (LearnedHeadroom), the headroom OutGaugeTelemetrySource keeps. At those moments the strip lights in full but does not blink the shift cue, and it reads true once the engine has revved out.

Two sockets cannot share a port, so the row takes its port from the hub. The three UDP sources implement IUdpTelemetrySource, which adds their Port. Before the row binds, it calls TelemetryHub.YieldPort, which stops the source on that port and keeps it closed until ReclaimPort, across hub restarts too. The row calls ReclaimPort when its bind fails and when it closes, and a running hub starts that source again. The row decodes every format those three sources read, so the LEDs keep working while it holds the port.

The telemetry readers are original. Where a game's memory layout came from a published SDK or community header, only field offsets and wire facts were used, with no source carried across.


A shared wheel works over Remote Link through a parallel dispatch in InputService. The consumer ships a semantic wheel frame, and ApplyRemoteWheel re-encodes it on the owner with the owner's own vendor writer, calling the same condition, constant-force, periodic, range, auto-center, and RPM-LED entry points Step 2 uses (WriteCondition / WriteConstantForce on Logitech and Thrustmaster, WriteWheelCondition / WriteWheelConstantForce on Fanatec, WritePeriodic on Thrustmaster only, then WriteRange, WriteAutocenter, and WriteRpmLeds on all three). This is why the writers are referenced from both Step 2 and InputService.



Last updated for PadForge 5.2.0.