A VCV Rack plugin that bridges a Microsoft SideWinder Force Feedback 2 joystick and a modular patch, in both directions:
- the stick's axes, hat and buttons come out as CV and gates
- CV goes back in and drives the stick's force-feedback motors
- a live instrument display shows both halves at once
| Output | Range | Notes |
|---|---|---|
| X, Y | ±5 V | Y is positive when the stick is pushed away from you |
| TWIST | ±5 V | rudder twist |
| THROTTLE | 0–10 V | switchable to ±5 V, and invertible |
| HAT X, HAT Y | ±5 V | unit vector of the hat direction, 0 V when centred |
| RADIUS | 0–10 V | stick deflection from centre |
| ANGLE | ±5 V | bearing: 0 V forward, ±5 V dead astern |
| TRIGGER, B2–B8 | 0/10 V gates | switchable to toggles |
Axes pass through a circular dead zone and a one-pole smoother, both set from the context menu. The dead zone is radial rather than per-axis, so a stick resting slightly off centre cannot emit a diagonal.
Each control is a knob plus a CV input; bipolar controls take ±5 V for full swing, unipolar ones 0–10 V.
| Control | Effect |
|---|---|
| FORCE X / FORCE Y | constant force vector |
| SPRING | centring stiffness |
| DAMPER | resists velocity |
| FRICTION | resists motion |
| INERTIA | resists acceleration |
| SHAKE | periodic effect depth |
| RATE | periodic frequency, 1 V/oct on the CV input |
| WAVE | sine, square, triangle, sawtooth up/down |
| GAIN | device master gain |
| AUTO CENTRE | hands centring back to the joystick's own spring |
| FORCE ON | arms the actuators |
Which way a commanded force actually pushes is set in the context menu under
Force orientation: four quarter turns and an optional mirror, covering every
axis-aligned mapping. The PID direction field is a polar angle whose zero and
handedness are not identical on every device, so this is a setting rather than
an assumption. To calibrate: turn FORCE X fully right, arm FORCE ON, and pick
the option where the stick pushes right. A non-default orientation is shown
under the force dial as R90, R180M and so on, so it is never a silent
surprise.
By default the spring's neutral point follows FORCE X/Y, so a CV pair does not just push the stick, it places it: raise SPRING and the stick physically travels to the commanded position and holds there. Turn that off in the context menu if you want a fixed centre and a separate push.
Left to right: the stick's position with a fading trail, its dead zone, the commanded force vector and the spring target it is being pulled toward; twist and throttle bars; a polar force dial with the spring ring and shake pulse; the hat rosette; the button grid; and a status block with the device name, the input and force update rates, effect slot usage and the effect types the hardware declares.
Input arrives through IOHIDManager, report-driven rather than polled.
Output does not use ForceFeedback.framework. That framework dispatches to a
per-device force-feedback plugin looked up in the device's IOCFPlugInTypes,
and macOS ships none for generic PID joysticks — the SideWinder's IORegistry
entry advertises only IOHIDLib plugins, and a search of the whole IORegistry
finds zero registrations of the force-feedback plugin type
(F4545CE5-BF5B-11D6-A4BB-0003933E3E3E). FFIsForceFeedback() therefore cannot
succeed for this device on a current macOS, whatever the API docs imply.
So the plugin speaks the USB HID Physical Interface Device protocol
directly, which is what the framework would have done: Create New Effect and
PID Block Load feature reports to allocate an effect block, then Set Effect
plus the type-specific Set Constant Force / Set Condition / Set Periodic
output reports, then Effect Operation to start it. Report layouts came from
the SideWinder's own report descriptor and match the USB PID 1.0 class
specification. See src/PidFF.cpp.
The device's PID state input report is read back as well, so the module can show whether the actuators are enabled, whether the actuator rail has power, and whether the hardware currently reports an effect playing.
One dedicated thread owns the CFRunLoop and every HID handle. The audio thread
only ever reads and writes atomic scalars; it never locks, allocates or touches
the OS. Force parameters are pushed to the hardware at 100 Hz with change
detection, so a knob that is not moving costs no USB traffic.
- macOS. The device layer is implemented for macOS only. The module builds and loads on Windows and Linux but reports that no backend is compiled in; Windows would be served by DirectInput 8 and Linux by evdev's force-feedback ioctls.
- The joystick's power supply must be connected. The SideWinder Force
Feedback 2 runs its position sensing off USB but its motors off a separate
12 V supply. Without it the stick reports position perfectly and accepts every
force command while doing nothing at all. The module says
NO ACTUATOR POWERwhen the device reports that state, reading the bit from the device's own PID state report. - If macOS denies HID access, grant Rack Input Monitoring under System Settings → Privacy & Security.
Measured on a real SideWinder Force Feedback 2 (045E:001B):
- axes, hat and buttons read correctly, report-driven
- the PID effect pool reads back as 1350 bytes with 15 simultaneous effects
- effect allocation, parameter updates and start/stop all succeed with zero
protocol errors, confirmed by the device's own
Effect Playingstatus bit following the commands for constant force, spring and periodic effects - the device reports
Actuators Enabled,Actuator PowerandSafety Switch - force output works: the motors are driven by CV as described above
- all three example patches load with every module and cable intact, checked by
tools/check_examples.sh, which loads each one in a headless Rack and diffs the patch Rack saves back against the one it was given - the module loads, runs and unloads cleanly in a live engine with the device attached, with the device thread joining on plugin unload
Force direction is deliberately a setting rather than a measured constant, for
the reason given under Force orientation above. On a unit where the mapping is
in question, ./sw-probe --calibrate measures it closed-loop: it pushes the
stick in eight directions and reads back where the stick's own position sensor
says it went.
Six patches in examples/, each loadable as-is. They are generated by
tools/gen_examples.py and checked by tools/check_examples.sh, which loads
every one in a headless Rack and confirms nothing was dropped.
01-joystick-voice.vcv - the stick played as an instrument. X goes through
a quantizer into the VCO, Y opens the filter, the throttle sets resonance, and
the trigger fires the envelope. The module's spring is switched on, which
matters more than it sounds: the FF2 has no mechanical centring spring at all,
so without the motors the stick simply stays wherever you leave it. Here the
force feedback supplies the centring, and the pitch springs back when you let go.
02-cv-places-the-stick.vcv - two slow LFOs drive FORCE X and FORCE Y with
the spring following them, so the stick physically walks a Lissajous figure
around its whole travel while nobody touches it. The scope shows the position
the stick actually reached, which lags and overshoots the commanded one.
03-haptic-sequencer.vcv - one CV row sets shake depth per step and another
sets shake rate, so each step has its own texture in the hand while a third row
plays the pitch. The joystick's trigger resets the sequencer.
These use the loop in both directions at once. The reason that is worth doing is that the stick is not a CV shaper: it is a real mass on real motors, with inertia, friction and a resonance of its own. Put it inside a modulation path and it contributes dynamics that no module in the rack will give you, and it never contributes them quite the same way twice.
04-physical-portamento.vcv - the glide between notes is a mass settling.
The sequencer does not set pitch; it sets where the stick should be. The
spring drags it there against its own inertia, and the pitch comes from where
the stick actually is along the way, so overshoot becomes a blip past the note
and damping becomes portamento time. Two slow random walks pull on the spring
and the damper, so the articulation keeps changing while the notes do not. Turn
DAMPER down for rubbery slides, up for clean steps.
05-self-oscillating-stick.vcv - the stick made into an oscillator, then
played. Under spring and damping the stick is a second-order mechanical system,
so its response to force already inverts above resonance; feeding its own
position back in as force closes a loop that breaks into oscillation once the
gain beats the damping. The stick starts moving on its own and that motion is
the patch's modulation source. The lag on the X path lowers the pitch of the
oscillation and a slow random walk on the spring keeps detuning it. Row 1 of the
attenuverters is the loop gain and DAMPER is the brake: turn row 1 up until it
sings, turn DAMPER up to calm it down. The defaults sit near the threshold
deliberately, so how lively it is will depend on your particular stick.
06-generative-haptic-drone.vcv - the whole feedback path runs through the
hardware. Two slow random walks pull the stick around its travel; where it ends
up picks the pitch and the blend of two oscillators, and how far out it is opens
the filter. The loop closes on the way back: the sound's own envelope drives the
shake, the shake moves the stick, and the moved stick changes the sound. Nothing
repeats and nothing is quantised in time. Grab the stick and you are inside the
loop too.
Download the .vcvplugin for your platform from the
releases and
drop it into your Rack user folder's plugins-mac-arm64 (or the matching
directory for your platform), then restart Rack. On macOS that folder is
~/Library/Application Support/Rack2/.
make RACK_DIR=/path/to/Rack-SDK
make install RACK_DIR=/path/to/Rack-SDK
The panel and the widget layout header are generated together, so artwork and component positions cannot drift apart:
python3 -m venv .venv && .venv/bin/pip install fonttools
.venv/bin/python tools/gen_panel.py
sw-probe exercises the device layer with no Rack involved:
make probe
./sw-probe # live axis, button and PID state readout
./sw-probe --force # walk through each effect type, one at a time
./sw-probe --calibrate # closed-loop force direction measurement
GPL-3.0-or-later. The panel is lettered with Roboto Condensed, under the SIL
Open Font License; see tools/fonts/OFL.txt.
