Skip to content

CasperVM/cursed_controls

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

67 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

cursed_controls

Test Python 3.11+ Platform License: MIT

Universal controller receiver and mapper running on an sbc.

Got a weird Bluetooth controller that nothing supports natively? Xbox 360 controllers work on nearly everything. So make it one.

cursed_controls runs on a Raspberry Pi Zero between your controllers and the host, emulating a real Xbox 360 wireless receiver over USB OTG. Combine multiple physical devices into a single virtual Xbox pad (think Wii Remote + Nunchuk), with up to 4 controller slots. Works on Windows and Linux; macOS might limited to 1 slot on older versions.

I initially wanted to make this, as some games (especially in unity) can be super finicky about the controllers connected. e.g. even a wireless xbox controller can sometimes cause weird mappings and other issues. In these cases it's useful to 'act' as a wired controller, while still having the benefit of being wireless :D. This completely circumvents having to do painful config on the host/gaming machine where you just want to play. Instead you just let the pi handle it. This is also useful for when you just want to switch between multiple devices without having to pair your BT controllers CONSTANTLY.

Tested on Raspberry Pi Zero W and Pi Zero 2W.

Small disclaimer: The hard part of initially making it work was done by hand. However, to finish this project and make it useful, this was partially vibe coded (yes I know, very bad. But finished is better than never seeing the light of day).

This is a visual mock of the web UI for quick exploration. It does not connect to hardware, Bluetooth, APIs, or the live backend.

How it works

  1. Physical controllers connect to the Pi (Bluetooth, USB, etc.) and appear as evdev devices.
  2. cursed-controls reads their events, applies a YAML mapping config, and builds Xbox 360 HID packets.
  3. Packets are sent to 360-w-raw-gadget (a submodule), which emulates a real Xbox 360 wireless receiver over the Pi's USB OTG port.
  4. Rumble commands from the host are forwarded back to any physical device that supports force feedback.

Latency

The Pi introduces roughly 10–20 ms of processing latency (evdev in → USB HID out), measured on a Pi Zero W. Combined with the Bluetooth link from the physical controller (~8–15 ms), total end-to-end latency is probably in the 20–35 ms range.

For competitive or rhythm games requiring precise timing, you might feel the difference compared to a wired controller (~4 ms). But then you're probably not using a wireless controller anyway.

Requirements

  • Raspberry Pi (or similar SBC) with a USB OTG port
  • Python 3.11+
  • raw_gadget kernel module (handled by install.sh)
  • 360-w-raw-gadget built as a shared library (handled by install.sh)
  • Plugging in the pi over USB (duh)

Setup

See SetupRaspbian.md for the full Pi setup guide.

curl|bash oneline installs (RUN ON PI):

One-liner (recommended):

curl -fsSL https://raw.githubusercontent.com/CasperVM/cursed_controls/main/install.sh | bash

Headless appliance — faster boot, lower idle power, no HDMI:

curl -fsSL https://raw.githubusercontent.com/CasperVM/cursed_controls/main/install.sh | bash -s -- --headless-fast-boot

Prefer to inspect first?

git clone https://github.com/CasperVM/cursed_controls.git
bash ~/cursed_controls/install.sh [--headless-fast-boot]

The installer is safe to re-run — each step skips if already complete.

Web UI / After install go to: http://<pi-ip>:8000

Open the web UI from another device on the same network at the above url.

Example:

http://192.168.1.123:8000

--headless-fast-boot adds these settings to config.txt:

hdmi_blanking=1
hdmi_ignore_hotplug=1
camera_auto_detect=0
display_auto_detect=0
dtparam=audio=off
gpu_mem=16
dtparam=act_led_trigger=none
dtparam=act_led_activelow=on

It does not disable Wi-Fi or Bluetooth.

For even faster booting/more optimization, take a look at the boottime optimization doc here.

Presets

The web UI ships with ready-made presets in the presets/ folder. Apply them from the Profiles tab.

Preset Match name Notes
Xbox Wireless Controller Xbox Wireless Controller
PS5 DualSense Wireless Controller
Nintendo Pro Controller Pro Controller Bluetooth; see note below
8BitDo Pro 3 8BitDo Pro 3
Wii Remote Nintendo Wii Remote Pairs with wiimote connection type
Wii Remote + Nunchuk Nintendo Wii Remote / Nunchuk Two devices, one slot
Wii Remote + Nunchuk — Rocket League Nintendo Wii Remote / Nunchuk

Nintendo Pro Controller over Bluetooth: The hid-nintendo driver emits IMU (gyro/accelerometer) updates at high frequency regardless of whether you use them, which can make input noticeably laggy or spammy on the Pi. Rumble also tends to work poorly over BT with this driver.

All presets were made with Bluetooth devices, this hasnt been tested on e.g. a pi4

Running CLI

# List detected input devices
cursed-controls list-devices

# Run with a mapping config (requires root for raw-gadget)
sudo cursed-controls run mapping.yaml

# Dry-run: print packets to stdout instead of opening gadget
cursed-controls run --stdout mapping.yaml

# Interactive simulation (no hardware needed)
cursed-controls simulate mapping.yaml

# Interactive TUI to build a new mapping file (WIP — see note below)
cursed-controls map mapping.yaml

# Live axis debug TUI: shows per-axis current value, min/max, and bar chart
# Run without arguments to get a device selection menu
python scripts/show_axis_range.py
python scripts/show_axis_range.py /dev/input/eventN   # skip menu

Note on map: The interactive mapper is a work in progress. It can be finicky with drifty axes (e.g. Nunchuk joystick). For reliable results, especially when tuning axis ranges, it's often easier to edit the YAML directly. Use show_axis_range.py to find the real min/max values, then set source_min/source_max by hand.

The Raspberry Pi install uses cursed-controls-web.service, which starts the web UI on boot. From the UI, you can edit mapping.yaml and start or stop the gadget runtime.

Config format

runtime:
  output_mode: gadget        # or "stdout" for dry-run
  gadget_library: 360-w-raw-gadget/target/release/libx360_w_raw_gadget.so
  # gadget_driver is auto-detected from /sys/class/udc/ — override only if needed
  interfaces: 1              # controller slots (1–4)
  rumble: true               # forward rumble to physical devices

devices:
  - id: my-controller
    connection:
      type: wiimote          # wiimote | bluetooth | evdev (default)
      timeout_s: 60          # how long to wait/scan before giving up
    match:
      name: "Nintendo Wii Remote"   # match by name, uniq, or phys
    mappings:
      # Button → button
      - source_type: 1    # EV_KEY
        source_code: 304  # BTN_A
        target: A
        kind: button

      # Button → axis (e.g. trigger)
      - source_type: 1
        source_code: 305  # BTN_B
        target: RIGHT_TRIGGER
        kind: button
        on_value: 255
        off_value: 0

      # Axis → axis (with scaling and deadzone)
      - source_type: 3    # EV_ABS
        source_code: 16   # ABS_HAT0X
        target: LEFT_JOYSTICK_X
        kind: axis
        source_min: -120
        source_max: 120
        target_min: -32767
        target_max: 32767
        deadzone: 0.05

      # Hat axis → d-pad button (ABS_HAT0X/Y, values -1/0/1)
      - source_type: 3
        source_code: 16   # ABS_HAT0X
        target: DPAD_LEFT
        kind: hat

Connection types

type behaviour
evdev Device is already in /dev/input/ (default)
bluetooth Connect by MAC at startup (mac: required)
wiimote Scan for Nintendo Wii Remote; user presses 1+2

Mapping kinds

kind source target notes
button EV_KEY or EV_ABS button or trigger on_value/off_value optional
axis EV_ABS joystick or trigger scales source_min..maxtarget_min..max
hat EV_ABS (-1/0/1) DPAD_* infers direction from target surface

Xbox target surfaces

Buttons: A B X Y BUMPER_L BUMPER_R STICK_L STICK_R START OPTIONS XBOX DPAD_UP DPAD_DOWN DPAD_LEFT DPAD_RIGHT

Axes: LEFT_JOYSTICK_X LEFT_JOYSTICK_Y RIGHT_JOYSTICK_X RIGHT_JOYSTICK_Y LEFT_TRIGGER RIGHT_TRIGGER

Example configs

Rumble

When rumble: true, the runtime polls the gadget for rumble commands from the host each tick and forwards them to any bound physical device that exposes EV_FF / FF_RUMBLE. Works with Wii Remotes (the hid-wiimote driver exposes FF).

Testing

pytest
# or with uv:
uv run pytest

All tests run without hardware (FakeSink, mock devices).