padctl
padctl maps gamepad HID reports to Linux uinput events using a declarative TOML device config.
Overview
- TOML device config — describe report layout, field offsets, button groups, and output capabilities without writing code.
- User mapping config (
~/.config/padctl/) — personal button remaps, gyro mouse, layers, and macros; separate from device configs. - XDG auto-discovery — daemon finds device and mapping configs across user, system, and builtin directories automatically.
- Runtime mapping switch —
padctl switch <name>swaps the active mapping without restarting the daemon. - Exclusive device grab — grabs the hidraw/evdev node so the original device is hidden from other processes (games, Steam, etc.) while padctl is running.
- padctl-capture — record HID traffic and generate a TOML skeleton automatically.
- padctl --validate — static config checker for CI and community contributions.
- padctl --doc-gen — generate device reference pages from TOML.
Source
https://github.com/BANANASJIM/padctl
Getting Started
Install via Package Manager
Arch Linux (AUR)
yay -S padctl-bin # prebuilt binary
yay -S padctl-git # build from source
If you previously installed from source with sudo padctl install, remove that
manual install before switching to AUR so pacman can own the files:
sudo padctl uninstall --prefix /usr --no-immutable
yay -S padctl-bin # or: yay -S padctl-git
Debian / Ubuntu
curl -fLO https://github.com/BANANASJIM/padctl/releases/latest/download/padctl_amd64.deb
sudo dpkg -i padctl_amd64.deb
For arm64:
curl -fLO https://github.com/BANANASJIM/padctl/releases/latest/download/padctl_arm64.deb
sudo dpkg -i padctl_arm64.deb
Package installs place the binary, udev rules, device configs, and system-wide
user unit on disk. They do not run the live padctl install phase, so enable
the service from your normal login session and reload udev rules:
systemctl --user daemon-reload
systemctl --user enable --now padctl.service
sudo udevadm control --reload-rules
If your controller config uses block_kernel_drivers (currently Flydigi Vader
5), the driver-block udev rules act on their own: they unbind the kernel driver
only while a padctl daemon is running (its control socket exists under
/run/user/<uid>/ or /run/padctl/). Unplug and replug the controller after
starting the service. When the service is stopped or disabled, the rules leave
the kernel driver alone and restore it on the next unplug.
Prerequisites
- Zig 0.15+ (build from source)
- Linux kernel ≥ 5.10 (uinput + hidraw support)
- libusb-1.0 (system package, optional — pass
-Dlibusb=falseto build without) - A HID gamepad accessible via
/dev/hidraw*
Build from Source
git clone https://github.com/BANANASJIM/padctl
cd padctl
zig build -Doptimize=ReleaseSafe
Optional build flags:
-Dlibusb=false— disable libusb linkage (uses hidraw-only path)-Dwasm=false— disable WASM plugin runtime
GCC 15 build failure (issue #147): Arch Linux and similar distros with glibc 2.43+ may hit
error: relocation R_X86_64_PC64 in .sframe section is unsupported— glibc 2.43 adds.sframesections tocrt1.ostartup objects, which Zig 0.15.x's linker does not yet handle. This is an upstream Zig limitation, not a padctl bug. Build inside the canonical Docker image (./scripts/padctl-docker build, Debian bookworm + glibc 2.36) — see Build with Docker below — or install Zig 0.15.2 from the official tarball on a system with glibc ≤ 2.41 (Debian 12, Ubuntu 22.04/24.04 all work; Arch with glibc 2.43+ does NOT). Upstream fix: ziglang/zig#31272.
Build with Docker
If you do not have Zig installed — or hit the glibc 2.43+ linker error above — build inside the canonical Docker image. It pins the Zig version from .zigversion against Debian bookworm (glibc 2.36) and matches the CI build environment, so it needs only Docker:
./scripts/padctl-docker build # zig build inside the image
./scripts/padctl-docker test # zig build test inside the image
./scripts/padctl-docker shell # interactive shell for debugging
The first run builds the image (padctl-build:<zig-version>) and later runs reuse it. The repository is bind-mounted at /src, so zig-out/ appears in your working tree as with a native build.
Install
sudo ./zig-out/bin/padctl install
This copies the binary, systemd service, device configs, and udev rules into /usr. It also runs systemctl daemon-reload and udevadm trigger automatically, and removes any legacy udev rules left by previous installs.
Custom prefix (e.g. for packaging):
sudo ./zig-out/bin/padctl install --prefix /usr --destdir "$DESTDIR"
Lifecycle scope override
PADCTL_INSTALL_PHASE=package forces the installer to act as if invoked by a
package post-install script (no service start/enable, no XDG path creation,
no UDEV reload). Useful for dpkg --configure, rpm --install, and AUR
PKGBUILD package() functions. Scope is resolved in this priority order:
--destdir flag > PADCTL_INSTALL_PHASE env > DESTDIR env > --scope
flag > euid > --prefix.
Additional Services
padctl install also sets up the following on all systems:
padctl-reconnect— A hotplug script triggered by udev when a controller is plugged in. It re-applies the active mapping through any running padctl daemon socket. It does not start or restart the daemon;padctl installenables/startspadctl.serviceunless you pass--no-enableor--no-start. After suspend/resume the kernel re-emits udev events for re-enumerated devices, so the same hook handles post-wake reconnect — no separate resume unit is needed.- Driver conflict rules — Auto-generated udev rules that unbind conflicting kernel drivers (e.g.,
xpad) from devices that padctl manages. Configured per-device viablock_kernel_driversin device TOML configs. The udev rule only detaches a kernel driver while a padctl daemon is running (its control socket exists), so a plain package install never strips a controller before the service starts. Whenpadctl installruns as root, it also walks/sys/bus/usb/drivers/<driver>/unbindfor matching VID:PID pairs immediately, so already-bound devices are evicted without waiting for replug (issue #162).
Install a Mapping
To install a mapping config to /etc/padctl/mappings/ during install:
sudo ./zig-out/bin/padctl install --mapping vader5
The --mapping flag is repeatable. Use --force-mapping to overwrite existing mapping files.
When --mapping is given, the installer also writes a device-to-mapping binding in /etc/padctl/config.toml so the daemon auto-applies the mapping on every boot. Use --force-binding to overwrite an existing binding for the same device.
Bazzite / immutable distros: See the Bazzite / Immutable Distros guide for special installation steps.
Install problems? See Troubleshooting for daemon/socket checks, udev permission issues, missing device configs, and known build failures.
Verify
padctl status
padctl scan
padctl list-mappings
padctl status verifies the user service socket is reachable. padctl scan
lists connected HID devices and shows whether a matching device config was found
for each. padctl list-mappings verifies the mapping search paths are readable.
If padctl status fails, run padctl doctor — it checks the daemon, the
systemd service state, and every supported device, and prints next-step hints.
Run as Service
If you built from source, run the installer first — zig build alone does not install the service file:
zig build
sudo ./zig-out/bin/padctl install # installs binary, service, device configs, and udev rules
padctl install automatically runs daemon-reload, enables, and starts padctl.service via sudo -u $SUDO_USER systemctl --user. Driver-block udev rules activate on their own once the daemon is running. The systemctl --user enable --now padctl.service line is only needed if you used --no-enable or --no-start.
To auto-start at boot without an active login session (headless setups, Steam Deck game mode):
sudo loginctl enable-linger $USER
The service runs padctl in daemon mode, scanning all config directories (user, system, and builtin) with automatic hotplug support. udev rules grant access via uaccess — no sudo needed for the logged-in user.
Check the daemon is running:
$ padctl status
STATUS device=Flydigi Vader 5 Pro state=active mapping=fps
Each managed device prints one space-separated triple: device=<name>,
state=<active|suspended>, mapping=<active mapping name|(none)>. Multiple
devices appear on the same line. Exit code is 0 when the daemon answered
and 1 when the response begins with ERR or the socket is unreachable.
Run Manually
Bare invocation — padctl auto-discovers configs via XDG paths:
padctl
Or target specific configs:
# Single config
padctl --config /usr/share/padctl/devices/sony/dualsense.toml
# All configs in a directory
padctl --config-dir /usr/share/padctl/devices/
Validate a Config
padctl --validate devices/sony/dualsense.toml # device config
padctl --validate ~/.config/padctl/mappings/fps.toml # mapping config
--validate auto-detects which schema to apply by scanning for a [device]
section header — files containing [device], [device.*], or [[device.*]]
are validated as device configs; everything else (including bare name = ...
mapping files) is validated against the mapping schema.
Exit 0 = valid. Exit 1 = validation errors printed to stderr. Exit 2 = file not found or parse failure.
The flag is repeatable: padctl --validate a.toml --validate b.toml validates both files and exits with the worst code seen.
Adding a new device
padctl 的目标是「新设备 = 一个 .toml,零代码」。当 padctl scan 显示某个设备
unmatched(没有匹配的 device config)时,用 padctl-capture 录制它的 HID 报文并
生成一个起始 TOML 骨架:
padctl-capture --vid 0xVVVV --pid 0xPPPP --output mypad.toml
padctl-capture 会提示后续步骤。完整流程:
- Capture — 运行上面的命令录制报文,得到
mypad.toml骨架。 - Refine — 检查并调整
[report]字段的 offset / type,使按键和摇杆映射正确。 - Install — 放到用户配置目录:
mkdir -p ~/.config/padctl/devices && cp mypad.toml ~/.config/padctl/devices/ - Validate —
padctl --validate ~/.config/padctl/devices/mypad.toml - Test decode —
padctl config test实时预览解码出的具名按键/摇杆事件,确认字段正确。
Generate Device Docs
padctl --doc-gen --config devices/sony/dualsense.toml
User Config
padctl reads a config file to set per-device default mappings. The loader checks these paths in order (first found wins):
~/.config/padctl/config.toml— user overrides (highest priority)/etc/padctl/config.toml— system-wide defaults (written bypadctl install --mapping)
version = 1
[[device]]
name = "Flydigi Vader 5 Pro"
default_mapping = "fps"
On daemon start, padctl matches the connected device name (case-insensitive) and loads the named mapping profile automatically. The system path is the fallback for environments where HOME is not set (e.g. systemd services).
padctl switch <name> automatically updates the user config, so the choice is remembered for bare padctl switch (re-apply without a name). Bare padctl switch (no argument) reads default_mapping from the connected device's entry in config.toml; if no entry exists, it prints error: no default_mapping in config.toml for device "<name>" and exits. To make the choice survive reboots, use padctl switch <name> --persist which copies the mapping and config to /etc/padctl/ via sudo.
CLI Reference
padctl switch [name] [--device <id>] # switch mapping; omit name to fall back to default_mapping from config.toml
padctl switch <name> --persist # switch + copy to /etc/padctl/ for reboot persistence (sudo)
padctl status [--socket <path>] # show daemon status
padctl devices [--socket <path>] # list connected devices
padctl list-mappings [--config-dir <dir>] # list available mapping profiles
padctl reload [--pid <pid>] # send SIGHUP to reload configs
padctl config list # show XDG config search paths
padctl config init [--device <name>] # interactive mapping creator; templates: default, fps, racing, fighting
padctl config edit [name] # open mapping in $VISUAL/$EDITOR
padctl config test [--config] [--mapping] # live input preview; prints decoded named events by default, --raw restores hex dump
padctl dump enable|disable # toggle diagnostic logging (persists)
padctl dump status # show dump state, log path, size, time span
padctl dump export --period Nm|Nh|Nd [-o file] # export filtered log window
padctl dump clear # delete all log files
See the Diagnostic Logging guide for the full padctl dump workflow, log paths, and the [diagnostics] config section.
udev Permissions
padctl needs access to /dev/hidraw*, /dev/uinput, and /dev/uhid. The first two are standard for HID gamepad daemons; /dev/uhid is required for the SDL3-visible IMU pairing path (per ADR-015). padctl install writes the necessary udev rules (60-padctl.rules) automatically.
The padctl install command generates and installs udev rules automatically from device configs.
If you need to regenerate rules after adding custom device configs:
sudo padctl install
The udev rules use TAG+="uaccess" to grant the active graphical user access
without requiring root. For SSH, headless, or test sessions without desktop ACLs,
the rules also grant GROUP="input", MODE="0660"; add your user to the input
group and log in again if those sessions need direct device access.
Installing padctl on Bazzite / Immutable Linux
This guide covers installing padctl on immutable Linux distributions where /usr is read-only (Bazzite, Fedora Atomic, Universal Blue, etc.).
Quick Install
Run the bootstrap script — it handles everything automatically:
curl -fsSL https://raw.githubusercontent.com/BANANASJIM/padctl/main/scripts/bazzite-setup.sh \
| bash -s -- --mapping vader5
Replace vader5 with the mapping for your controller, or omit --mapping to install without a mapping. Available mappings are in the mappings/ directory.
When run locally (not via curl), the script prompts for mapping selection interactively if --mapping is not provided:
bash scripts/bazzite-setup.sh
What the script does:
- Detects immutable OS (checks for ostree or read-only
/usr) - Installs dependencies via Homebrew (Zig compiler, libusb) — no system reboot needed
- Clones and builds padctl from source with
ReleaseSafeoptimization - Installs the daemon, systemd service, udev rules, and reconnect scripts
- Persists the selected mapping as a device binding in
/etc/padctl/config.toml(auto-applies on every boot) - Applies the mapping to the current session
- Verifies the installation
Safe to re-run for updates — it rebuilds and reinstalls while preserving your mapping configs in ~/.config/padctl/mappings/.
Script Options
| Flag | Description |
|---|---|
--mapping <name> | Install a mapping and auto-apply on boot |
--repo-url <url> | Use a fork or alternative repo URL |
--branch <name> | Clone/checkout a specific branch |
<path> | Use an existing local repo instead of cloning |
What the Install Does
Why /usr is a Problem
On immutable distros, /usr is a read-only filesystem overlay. The standard padctl install places systemd service files and udev rules under /usr/lib/, which works on regular Linux but fails silently on immutable systems — the files exist but systemd can't resolve them through symlinks during boot.
How --immutable Fixes It
The padctl install --immutable flag changes where system files are placed:
| File | Standard (/usr) | Immutable (--immutable) |
|---|---|---|
| Binaries | /usr/bin/ | /usr/local/bin/ |
| Service file | /usr/lib/systemd/user/ | /etc/systemd/user/ |
| Service drop-in | (not created) | /etc/systemd/user/padctl.service.d/immutable.conf |
| udev rules | /usr/lib/udev/rules.d/ | /etc/udev/rules.d/ |
| Device configs | /usr/share/padctl/devices/ | /usr/local/share/padctl/devices/ |
padctl-resume.servicewas removed (issue #131-B); udev hotplug handles post-suspend reconnect.
Files in /etc/ persist across system updates on immutable distros.
The immutable.conf Drop-in
The immutable install creates a systemd user-service drop-in override with these changes:
| Directive | Purpose |
|---|---|
DeviceAllow= | Clears any inherited device allowlist so libusb can open USB bus nodes when permissions allow it |
ProtectHome=read-only | Allows reading user mapping configs from ~/.config/padctl/mappings/ |
ReadWritePaths=/run/user/%U | Keeps the daemon socket writable when ProtectHome=read-only also covers runtime paths |
TimeoutStopSec=3 | Short stop timeout for processes stuck in uninterruptible I/O |
KillMode=mixed | SIGTERM to main process + SIGKILL to stuck worker threads |
Security note on DeviceAllow=: The immutable drop-in does not grant file
permissions by itself. Device access still comes from the installed udev rules,
desktop uaccess ACLs, or input-group membership for headless sessions. Clearing
the systemd device allowlist is needed so libusb can use USB bus nodes
(/dev/bus/usb/) for vendor-specific control transfers once normal file
permissions allow access.
Managing Mappings
Mapping configs can live in two places:
| Location | Priority | Editable without sudo |
|---|---|---|
~/.config/padctl/mappings/ | First (highest) | Yes |
/etc/padctl/mappings/ | Second | No (requires sudo) |
padctl switch <name> searches ~/.config/ first, so you can customize mappings without root:
# Edit your personal mapping
nano ~/.config/padctl/mappings/vader5.toml
# Apply changes immediately (no sudo needed)
padctl switch vader5
The /etc/padctl/mappings/ copy is used as a fallback by the hotplug reconnect script (which runs as root).
Auto-apply on boot
When you install with --mapping, the installer writes a device binding to /etc/padctl/config.toml:
version = 1
[[device]]
name = "Flydigi Vader 5 Pro"
default_mapping = "vader5"
The daemon reads this file at startup and auto-applies the mapping — no manual padctl switch needed after reboot. User-level overrides in ~/.config/padctl/config.toml take priority when available.
You can also persist a mapping change after switching at runtime:
padctl switch vader5 --persist
This copies your user mapping and config to /etc/padctl/ via sudo, so the change survives reboots without re-running the installer.
Uninstalling
sudo padctl uninstall --immutable --prefix /usr/local --mapping vader5
This removes all installed files including the /etc/systemd/user/ service files and the specified mapping. User configs in ~/.config/padctl/ are never touched.
Mapping Configuration Guide
Overview
A mapping config controls how padctl translates physical inputs to virtual outputs. It is separate from the device config:
- Device config (
devices/*.toml) — describes the hardware HID protocol. Stable, community-maintained. You usually don't touch this. - Mapping config (
~/.config/padctl/mappings/*.toml) — your personal preferences: remapped buttons, gyro mouse, layers, macros.
Without a mapping config, padctl passes all inputs through unchanged as a standard gamepad.
Quick Start
Create a mapping
Use the interactive creator:
mkdir -p ~/.config/padctl/mappings/
padctl config init
Or, from a source checkout, copy the repository example and edit it:
cp config/example-mapping.toml ~/.config/padctl/mappings/my-config.toml
$EDITOR ~/.config/padctl/mappings/my-config.toml
XDG Search Paths
padctl searches for mapping profiles in this order (first match wins):
~/.config/padctl/mappings/— user overrides/etc/padctl/mappings/— system-wide profiles/usr/share/padctl/mappings/— builtin profiles
Apply a mapping
Switch the active mapping at runtime:
padctl switch fps
Every switch automatically saves your choice to ~/.config/padctl/config.toml, so you can restore it later with a bare switch:
padctl switch # re-applies default_mapping from config.toml for the connected device
Bare padctl switch queries the running daemon for the connected device name, then looks up default_mapping in config.toml (user path first, then /etc/padctl/config.toml). If no entry is found it exits with error: no default_mapping in config.toml for device "<name>".
Persist across reboots (--persist)
By default, padctl switch only saves to your user config (~/.config/padctl/config.toml). The systemd daemon cannot read this at boot because HOME is not set in its service environment. To make the mapping survive reboots:
padctl switch fps --persist
This will:
- Apply the mapping at runtime (same as without
--persist) - Save to your user config (same as without
--persist) - Prompt for confirmation, then ask for your sudo password
- Copy the mapping file to
/etc/padctl/mappings/ - Copy your user config to
/etc/padctl/config.toml
The daemon reads /etc/padctl/ at boot, so the mapping auto-applies on every reboot without manual intervention.
Limitations:
--persistis not yet supported with--device(multi-controller setups). In multi-device sessions, auto-save and barepadctl switchresolve against the first connected device. Usepadctl install --mapping <name>for explicit per-device persistence in multi-controller setups.- A future version may persist by default, but this behavior is uncertain and subject to change.
Config file precedence
The daemon checks these paths in order when resolving default mappings:
~/.config/padctl/config.toml— user overrides (highest priority, only available whenHOMEis set)/etc/padctl/config.toml— system-wide defaults (written bypadctl install --mappingorpadctl switch --persist)
version = 1
[[device]]
name = "Flydigi Vader 5 Pro"
default_mapping = "fps"
If you installed with padctl install --mapping vader5, the system config is already written for you.
Manual run
Or pass a mapping directly when running padctl manually:
padctl --mapping ~/.config/padctl/mappings/my-config.toml
Validate
Mapping configs are validated at daemon startup. Errors are written to the journal:
journalctl --user -u padctl.service -n 30
Mapping configs can also be validated with padctl --validate; the flag auto-detects device vs mapping schema by scanning for a [device] table.
Configuration Sections
Button Remapping ([remap])
Keys are button names; values are the target action.
[remap]
A = "B" # swap A and B
M1 = "KEY_F13" # back paddle → keyboard key
M2 = "mouse_left" # grip button → mouse left click
M3 = "disabled" # silence an unused button
M4 = "macro:dodge_roll" # run a macro (defined below)
LM = "mouse_side"
RM = "RS"
Available target types:
| Value | Effect |
|---|---|
"A", "B", "LB", … | Remap to another gamepad button |
"LT" / "RT" | Remap to a trigger — emits the digital press plus a full analog pull (axis 255) while held; tap/double gesture legs emit only the digital press, so use a plain remap or a hold leg for an analog pull |
"KEY_*" | Emit a Linux keyboard key (e.g. "KEY_F13", "KEY_LEFTSHIFT") |
"mouse_left" / "mouse_right" / "mouse_middle" / "mouse_side" / "mouse_extra" | Emit a mouse button |
"mouse_forward" / "mouse_back" | Emit mouse forward/back (button 4/5) |
"disabled" | Suppress the button entirely |
"macro:<name>" | Run a named macro sequence |
For example, to fire the triggers from back buttons:
[remap]
M1 = "RT" # full analog pull (255) while M1 is held
M2 = "LT"
Note:
BTN_*values (e.g."BTN_SOUTH") are routed to the virtual mouse device, not the gamepad. To remap to a gamepad button use a friendlyButtonIdname ("A","Select", etc.) instead.
Available button names: A, B, X, Y, LB, RB, LT, RT, Start, Select, Home, Capture, LS, RS, DPadUp, DPadDown, DPadLeft, DPadRight, M1, M2, M3, M4, Paddle1, Paddle2, Paddle3, Paddle4, TouchPad, Mic, C, Z, LM, RM, O
Tap / hold / double-press on one button
Bind separate actions to a short press, a long press, and a double press of the same button by using an inline table instead of a string:
[remap]
A = { tap = "KEY_X", hold = "KEY_Y", double = "KEY_Z" }
B = { tap = "B", hold = "KEY_LEFTSHIFT" } # tap passes B through, hold = shift
RB = { tap = "RB", hold = "KEY_TAB", hold_ms = 400, double_ms = 200 }
tapfires on release (short press),holdfires once held pasthold_ms(default 300 ms),doublefires on a second press withindouble_ms(default 250 ms) of the first release.- At least one leg is required; absent legs do nothing. Legs are single targets
only —
macro:<name>and chord arrays are not allowed inside a gesture. - Works the same in
[layer.remap]. - Trade-off: when
doubleis set,tapis delayed until the double-press window closes (the only way to tell a single press from the first of a double). Omitdoubleto keeptapinstant. Plain string and chord remaps are unchanged and never add latency.
Gyroscope ([gyro])
Translates gyroscope motion to mouse movement or a virtual stick. Off by default.
[gyro]
mode = "mouse"
activate = "LS" # hold left stick click to enable gyro
sensitivity = 2.0
deadzone = 300 # raw gyro units; filters small wobble
smoothing = 0.4 # 0–1; higher = smoother but more latency
invert_y = true
Omit activate to have gyro always active when mode is "mouse".
activate accepts a bare button name ("LS") or the hold_<BTN> form ("hold_RB") — both behave identically. For analog triggers (LT/RT) you must also declare trigger_threshold at the top level, otherwise the trigger axis is never converted to a button press and the gyro gate never fires.
Joystick mode
Set mode = "joystick" to route the gyro signal to a virtual stick axis instead of mouse events. Use target to choose which stick receives the output:
[gyro]
mode = "joystick"
target = "right_stick" # "right_stick" (default) or "left_stick"
deadzone = 200
sensitivity = 100 # stick mode needs a much larger value than mouse
# mode — see the note below
smoothing = 0.3
This is useful for games that read the right stick for camera control but do not natively support gyro input. In the default response = "rate" mode the same gyro fields (deadzone, smoothing, curve, invert_x, invert_y) behave as in mouse mode — but sensitivity does not scale the same way.
Mouse mode integrates the gyro rate into relative cursor motion frame by frame, so even small values keep moving the pointer. Joystick mode instead emits an absolute stick deflection: the raw gyro rate is normalized against max_val (default 32767, the full int16 range) before scaling. A normal hand motion produces a raw rate that is only a small fraction of max_val, so a mouse-style sensitivity = 1.5 yields a stick deflection of just a few percent — usually inside the game's own deadzone, so it feels as if gyro is off. Start around sensitivity = 100 and tune in the 50–150 range. Lowering max_val (for example max_val = 2000) raises the normalized signal and so increases the deflection for a given motion — it is an equivalent lever to sensitivity, useful if you would rather keep sensitivity small.
For racing games, response = "tilt" maps controller tilt to an absolute stick
position instead of treating gyro motion like stick velocity. degrees_full
controls how far the controller must be tilted for full stick deflection, and
axis_x / axis_y choose which motion axis drives each virtual stick axis:
[gyro]
mode = "joystick"
response = "tilt"
target = "left_stick"
axis_x = "roll" # steer by rolling the controller left/right
axis_y = "none" # leave the Y stick axis unchanged
degrees_full = 35.0 # +/-35 degrees maps to full stick deflection
smoothing = 0.2
response = "tilt" uses the accelerometer to estimate roll and pitch, so
it gives a stable absolute stick position without gyro integration drift. yaw
does not have an absolute tilt estimate and resolves to neutral in tilt mode.
When response = "tilt", the default X source is roll; in the default
response = "rate" mode, X still uses yaw. If a device has not reported a
non-zero accelerometer vector yet, tilt mode leaves the physical stick axes
unchanged for that frame. In tilt mode, deadzone is applied after converting
tilt to virtual stick output rather than to raw gyro units.
Blending gyro with the physical stick (blend_stick)
By default, gyro joystick output replaces the physical stick value while gyro is active. Set blend_stick = true to add the gyro signal on top of the physical stick instead:
[gyro]
mode = "joystick"
target = "right_stick"
activate = "LS"
sensitivity = 1.5
deadzone = 200
smoothing = 0.3
blend_stick = true # add gyro onto physical stick instead of replacing it
This lets the physical stick handle coarse movement while the gyro provides fine aim adjustment simultaneously — useful for aim-assist workflows. The combined value is clamped to the valid axis range (-32767..32767).
Note: when the physical stick is already at full deflection (±32767), the gyro contribution is clamped out entirely.
blend_stickhas no effect whenmode = "mouse".
Escaping an in-game deadzone (minimum_output)
Some games apply their own deadzone to the right stick, so a small gyro deflection produces no in-game camera movement at all — slow, precise aiming simply does nothing until you move past the game's threshold. minimum_output raises the gyro output to a floor whenever gyro is active, so even a slight motion clears that deadzone:
[gyro]
mode = "joystick"
target = "right_stick"
activate = "LS"
sensitivity = 100
deadzone = 200
smoothing = 0.3
minimum_output = 0.15 # floor stick output at 15% of full scale while gyro moves
minimum_output is a fraction of full scale clamped to the 0.0–1.0 range (where 1.0 corresponds to roughly 20000 stick units). When the computed output magnitude is non-zero but below this floor, padctl scales it up to exactly minimum_output while preserving the aim direction — so a 5% deflection becomes a 15% deflection pointing the same way. The floor only kicks in for motion that already cleared the gyro deadzone: a perfectly still controller stays at zero, and any input absorbed by deadzone is never resurrected by minimum_output. Tune the value so the smallest deliberate motion just clears the game's own stick deadzone — start around 0.10–0.20 and raise it if slow aiming still produces no movement.
Note:
minimum_outputonly applies inmode = "joystick". Mouse mode integrates motion frame by frame, so it has no deadzone to escape and ignores this field. The defaultminimum_output = 0.0disables the floor entirely.
Sticks ([stick.left] / [stick.right])
Three modes:
"gamepad"(default) — pass through as normal gamepad axes"mouse"— stick controls the cursor"scroll"— stick controls scroll wheel
[stick.right]
mode = "mouse"
sensitivity = 2.5
deadzone = 100
suppress_gamepad = true # prevent duplicate gamepad axis events
Use suppress_gamepad = true with "mouse" or "scroll" to avoid sending both gamepad axes and mouse/scroll events simultaneously.
D-pad ([dpad])
[dpad]
mode = "arrows" # emit arrow key events
suppress_gamepad = true
Default is "gamepad". Set to "arrows" to make the d-pad behave as arrow keys (useful for desktop navigation).
Layers ([[layer]])
Layers are the most powerful feature. A layer is a context-sensitive override: while active, its remap/gyro/stick/dpad settings replace the base config.
Three activation modes:
"hold"— active while the trigger button is held"toggle"— press once to enter, press again to exit"hold_toggle"— short press firestap; holding pasthold_timeouttoggles the layer on or off
The tap + hold_timeout combination lets a button do double duty: if released before hold_timeout ms, it fires tap instead of activating the layer.
The optional hold field emits a chosen output continuously while the layer is active — the trigger does double duty as both a layer switch and a passthrough button. It applies uniformly to all three activation modes, and fires only after the layer activates (a short press that resolves to tap emits no hold output). Released automatically on every exit path. hold takes a single ButtonId, KEY_*, mouse_*, or BTN_* target (not macro:<name>).
# Witcher-3 "Sense": hold LB to enter a layer AND still emit LB.
[[layer]]
name = "sense"
trigger = "LB"
activation = "hold"
hold = "LB"
hold_timeout = 200
[layer.remap]
A = "KEY_Q"
# Keyboard modifier held while a toggle layer is latched on.
[[layer]]
name = "fn"
trigger = "Select"
activation = "toggle"
hold = "KEY_LEFTSHIFT"
[layer.remap]
A = "KEY_F1"
# "aim" layer: hold LM to enable gyro + mouse aim
[[layer]]
name = "aim"
trigger = "LM"
activation = "hold"
hold_timeout = 200 # ms; short press fires tap action
tap = "mouse_side"
[layer.gyro]
mode = "mouse"
sensitivity = 2.0
smoothing = 0.3
[layer.stick_right]
mode = "mouse"
sensitivity = 1.0
suppress_gamepad = true
[layer.remap]
RB = "mouse_left"
RT = "mouse_right"
Layer sub-configs can also be written inline (equivalent):
[[layer]]
name = "aim"
trigger = "LM"
activation = "hold"
hold_timeout = 200
tap = "mouse_side"
gyro = { mode = "mouse", sensitivity = 2.0, smoothing = 0.3 }
stick_right = { mode = "mouse", sensitivity = 1.0, suppress_gamepad = true }
remap = { RB = "mouse_left", RT = "mouse_right" }
Toggle example — F-key row on Select:
[[layer]]
name = "fn"
trigger = "Select"
activation = "toggle"
[layer.remap]
A = "KEY_F1"
B = "KEY_F2"
X = "KEY_F3"
Y = "KEY_F4"
Hold-to-toggle example — hold LM to keep a racing layer enabled, hold it again
to disable it. A short press still emits tap:
[[layer]]
name = "race"
trigger = "LM"
activation = "hold_toggle"
hold_timeout = 300
tap = "LM"
[layer.gyro]
mode = "joystick"
response = "tilt"
target = "left_stick"
axis_x = "roll"
axis_y = "none"
degrees_full = 35.0
Layers are evaluated in declaration order. Only one layer is active at a time.
Macros ([[macro]])
Named sequences bound via macro:<name> in remap values.
[[macro]]
name = "dodge_roll"
steps = [
{ tap = "B" },
{ delay = 50 },
{ tap = "LEFT" },
]
[[macro]]
name = "shift_hold"
steps = [
{ down = "KEY_LEFTSHIFT" },
"pause_for_release",
{ up = "KEY_LEFTSHIFT" },
]
| Step | Description |
|---|---|
{ tap = "KEY" } | Press and release |
{ down = "KEY" } | Press and hold |
{ up = "KEY" } | Release |
{ delay = N } | Wait N milliseconds |
"pause_for_release" | Wait until the trigger button is released |
Bind in remap: M1 = "macro:dodge_roll"
Warning:
hold_timeoutbelongs on the[[layer]]block, not[[macro]]. Ahold_timeoutvalue under[[macro]]is not recognized and the schema linter will warn about it; the layer continues to use the default 200 ms. To make a hold layer fire quickly, puthold_timeout = 50(or your chosen value, 1–5000 ms) on the[[layer]]block itself.
Repeat-while-held — turbo / combo (repeat_delay_ms)
Add repeat_delay_ms = N to a [[macro]] block to make the macro restart while
the trigger button is held. Releasing the trigger lets the current iteration
finish naturally and stops further restarts. Omit the field for legacy
single-shot behaviour.
# Spam A while RM held: tap, wait 50 ms, tap again, ...
[[macro]]
name = "spam_a"
repeat_delay_ms = 50
steps = [{ tap = "A" }]
[remap]
RM = "macro:spam_a"
Trigger Threshold — analog LT / RT as digital buttons
Warning:
trigger_thresholdmust be at the top level of the mapping file. Placing it inside[[layer]]is silently ignored. To useLT/RTas remap source keys or layer triggers, set this field once at the top of your mapping file, outside any layer block.
LT / RT are analog axes by default and cannot be used directly as [remap] source keys. Once trigger_threshold is declared, padctl synthesizes digital button events from the axis values each frame, making them available for [remap] and layer triggers:
trigger_threshold = 100 # 0–255, shared by LT and RT
[remap]
LT = "KEY_LEFTSHIFT" # axis > 100 → synthesize LT press → emit Shift
RT = "mouse_right" # axis > 100 → synthesize RT press → emit right click
See Mapping Config Reference — trigger_threshold for the full field description.
LT / RT also work as down / up targets inside [[macro]] — press and release the virtual trigger from any macro step:
[remap]
M1 = "macro:aim_burst"
[[macro]]
name = "aim_burst"
steps = [
{ down = "LT" },
{ delay = 80 },
{ up = "LT" },
]
Adaptive Trigger ([adaptive_trigger]) — DualSense only
Configures the resistance profile of the DualSense L2/R2 triggers. See Mapping Config Reference for full field tables.
In-controller Chord Switch ([chord_switch])
Hold a set of modifier buttons, then tap a selector button to switch the active mapping without touching a CLI. This is configured in ~/.config/padctl/config.toml (not in a mapping file), and each selectable mapping file declares chord_index = N:
# ~/.config/padctl/config.toml
[chord_switch]
modifier = ["LM", "RM"]
selectors = ["A", "B", "X", "Y"]
hold_ms = 120
# ~/.config/padctl/mappings/desktop.toml
chord_index = 1 # LM+RM → tap A → switch to this mapping
See Mapping Config Reference — chord_switch for full field tables and a runnable example.
Full Example
A copy-paste-ready example covering every major feature is included in the repository at examples/mappings/comprehensive.toml. It covers base remaps, hold/toggle/hold-toggle layers, macros, stick modes, and gyro.
Reference
Full field tables and all accepted values: Mapping Config Reference
Diagnostic Logging
padctl ships with a general-purpose, togglable file logger. It is designed to be the single mechanism you reach for when diagnosing any class of bug — stuck rumble, input drops, mapping misses, hotplug oddities, daemon crashes — so that reports come with structured evidence instead of guesswork.
It is off by default and has no hot-path cost when disabled. The current build already emits a very detailed trace of the force-feedback pipeline; other subsystems (input routing, layer/remap decisions, hotplug, config reload, …) will be instrumented behind the same switch over time. The user-facing contract — enable, reproduce, export, attach — stays the same as more coverage is added.
Quick workflow
padctl dump enable # turn logging on (survives reboot)
# ... reproduce the issue by playing ...
padctl dump export --period 30m -o bug.log # capture the last 30 minutes
padctl dump disable # turn it off again
Attach bug.log to your issue report.
Commands
| Command | Description |
|---|---|
padctl dump enable | Turn diagnostic logging on. Persists across restarts by writing [diagnostics].dump = true to the user config (and /etc/padctl/config.toml via sudo when available). Also sends a live IPC to any running daemon so the change takes effect immediately. |
padctl dump disable | Turn diagnostic logging off (default state). Same persistence semantics as enable. |
padctl dump status | Print current state (enabled / disabled), the active log path, log file size, oldest/newest entry timestamps, and the rotated backup size if present. |
padctl dump export --period <N>m|<N>h|<N>d [-o path] | Export the window of log lines newer than the given duration. -o writes to a file; omit it to print to stdout. Default window: 1d. |
padctl dump clear | Delete the live log and any rotated backups. Asks for confirmation. Falls back to sudo rm for root-owned logs when the CLI user can't unlink them directly. |
Period syntax
--period accepts Nm (minutes), Nh (hours), or Nd (days). Examples: --period 15m, --period 2h, --period 7d.
Log file location
padctl picks the first entry whose env var is set / parent dir is reachable, going top-to-bottom:
| Priority | Path | Source |
|---|---|---|
| 1 | $STATE_DIRECTORY/padctl.log | Set by systemd when the unit declares StateDirectory=padctl. Resolves to $XDG_STATE_HOME/padctl/ on the user service (default ~/.local/state/padctl/) and /var/lib/padctl/ on a system service. |
| 2 | $XDG_STATE_HOME/padctl/padctl.log | Non-systemd invocations (e.g. the CLI running in the user's shell) with $XDG_STATE_HOME set. |
| 3 | ~/.local/state/padctl/padctl.log | XDG fallback when $XDG_STATE_HOME is unset but $HOME is. |
| 4 | /var/log/padctl/padctl.log | Last-resort fallback when neither $HOME nor $XDG_STATE_HOME is available. |
On a default Bazzite install (user-service + StateDirectory=padctl in
the unit file) the daemon and CLI both converge on
~/.local/state/padctl/padctl.log.
padctl dump status prints the path currently in use. If both the
current-session path and a legacy location contain padctl.log, the
command picks whichever file was most recently modified (mtime-based)
so you always see the active one.
Config file
Diagnostic logging is driven by a dedicated section in config.toml:
[diagnostics]
dump = false # master switch; padctl dump enable/disable flips this
max_log_size_mb = 100 # rotation threshold (default 100 MB)
padctl dump enable and padctl dump disable are just a convenience front-end for toggling dump and forwarding the change to the running daemon — you can also edit this section by hand and send SIGHUP (padctl reload) instead.
⚠️ Rewrite behavior.
padctl dump enable/disableparsesconfig.toml, rewrites it from the known schema, and atomically renames the result into place. Anything outside the documented schema — unknown sections, undocumented keys, hand-written comments — is not preserved. If you hand-editconfig.tomlwith content that matters (e.g. a forward-looking[experimental]block, inline comments documenting a choice), keep it in a sibling file, or drive padctl viaSIGHUPafter the edit instead of using thedumpsubcommand. The current known schema isversion,[diagnostics](dump,max_log_size_mb),[supervisor](suspend_grace_sec),[chord_switch](modifier,selectors,hold_ms), and[[device]]entries (name,default_mapping).
Supervisor tunables
config.toml may also include a [supervisor] section to tune hot-plug suspend behavior:
| Field | Type | Default | Description |
|---|---|---|---|
suspend_grace_sec | i64 | 15 | Seconds to keep a suspended device alive before transactional rebind, allowing transient disconnects to recover without re-grabbing |
The suspend_grace_sec value is preserved across padctl dump enable/disable and padctl switch; comments and unknown keys inside [supervisor] follow the same rewrite caveat as the rest of config.toml.
Chord switch (issue #183)
Set up an in-controller mapping switch so you can change profiles without leaving Big Picture mode. Add a [chord_switch] section to ~/.config/padctl/config.toml and a chord_index to each mapping you want to be selectable:
# ~/.config/padctl/config.toml
[chord_switch]
modifier = ["LM", "RM"] # held simultaneously to arm the chord
selectors = ["A", "B", "X", "Y"] # each maps to chord_index 1..N by position
hold_ms = 80 # debounce window — selector edges in this window are ignored
# ~/.config/padctl/mappings/fps.toml
name = "fps"
chord_index = 1 # press A while holding modifier → switch to this mapping
# ~/.config/padctl/mappings/racing.toml
name = "racing"
chord_index = 2 # press B while holding modifier → switch to this mapping
While the modifier is held, selector buttons are suppressed from the virtual gamepad output so the in-game UI does not see them. If no mapping declares a matching chord_index, the daemon logs a warning and does nothing. The standard padctl switch <name> CLI still works alongside the chord. [chord_switch] is preserved across padctl dump enable/disable's rewrite. Comments inside the section follow the same rewrite caveat as the rest of config.toml.
Rotation
On every daemon startup and on every fresh file-open, padctl stats the existing log. If it exceeds max_log_size_mb, the file is renamed to padctl.log.1 (overwriting any previous backup) and a new empty padctl.log is created. There is only ever one rotated backup.
This keeps disk usage bounded to roughly 2 * max_log_size_mb without needing logrotate or any external tooling.
What gets logged
When dump = false (the default), only warnings and errors are written, and only lazily on the first occurrence.
When dump = true, padctl adds verbose tracing on top. The coverage today is deepest in the force-feedback pipeline — that is the area where the logger was needed first — and is being expanded to other subsystems as issues surface. Current coverage:
- Session lifecycle — daemon start, config loaded, devices attached/detached
- FF_UPLOAD / FF_ERASE kernel requests with effect IDs, rumble magnitudes, and replay durations
- EV_FF PLAY / STOP events with scheduler decisions (forwarded, throttled, auto-stop timer armed, etc.)
- HID rumble frames written to the physical device, with the first 16 bytes hex-dumped so post-checksum data can be inspected
- Scheduler slot state (all 16 effect slots) before and after every mutation
Planned areas (no promised order): input-report parsing, layer/remap resolution, hotplug/netlink events, config reload, IPC commands. You can track progress on these in the repo issue tracker.
Reporting issues
When opening an issue that needs diagnostic data, the recommended flow is:
padctl dump enable
# reproduce the bug (play the game, press the button, wait for the glitch)
padctl dump export --period 1h -o issue.log
padctl dump disable
Attach issue.log. Sensitive information in the logs is limited to device names, USB identifiers, and input report bytes — there is no keystroke capture or payload from other applications.
Troubleshooting
Run padctl doctor first and paste its output in bug reports. It checks the
daemon socket, the systemd service state, kernel driver bindings, and every
supported device in one pass, and prints next-step hints. The manual flows
below remain as fallback when doctor is unavailable or inconclusive.
Common runtime failures that have generated repeat issue reports, with diagnostics and workarounds.
padctl status says it cannot connect to the daemon
Symptoms:
padctl statusexits non-zero or reports that the daemon socket is unreachable.padctl switch <name>cannot apply a mapping.
Check the user service:
systemctl --user status padctl.service
journalctl --user -u padctl.service -n 80
Common causes:
padctl installwas run with--no-enableor--no-start.padctl installwas run as root withoutSUDO_USER, so it could not locate the real user's systemd user manager.- The user has not logged in since install, or headless boot needs linger.
Fix:
systemctl --user daemon-reload
systemctl --user enable --now padctl.service
For headless setups or boot-before-login:
sudo loginctl enable-linger $USER
Permission denied opening hidraw, uinput, or uhid
Symptoms:
- The daemon starts but logs
PermissionDenied,AccessDenied, or open failures for/dev/hidraw*,/dev/uinput, or/dev/uhid. padctl scansees the controller but the daemon cannot manage it.
Fix:
sudo udevadm control --reload-rules
sudo udevadm trigger
systemctl --user restart padctl.service
Then unplug and replug the controller. Graphical sessions normally receive access
through TAG+="uaccess" ACLs. SSH/headless sessions may also need input-group
membership:
sudo usermod -aG input $USER
Log out and back in after changing groups.
Controller is visible but no matching config is found
Symptoms:
padctl scanlists the HID device but says no config matched.- The daemon log says no devices were found in config dirs.
Check installed configs:
padctl doctor resolves the daemon's real device config directories and lists
every device TOML it finds — prefer it over a hardcoded path, because the
install prefix differs by platform (ostree/immutable distros install under
/usr/local/share/padctl/devices, not /usr/share/padctl/devices):
padctl doctor
To inspect a specific config manually, validate it from the directory doctor reported, e.g.:
padctl --validate /usr/share/padctl/devices/sony/dualsense.toml
If doctor finds zero device TOMLs, reinstall the current package. If your device is not listed, capture it and open a device-config contribution.
Kernel driver or another mapper still owns the controller
Symptoms:
- The physical controller continues to appear directly in games while padctl is running.
- padctl cannot exclusively grab the device, or duplicate inputs appear.
- Xbox-compatible devices still bind to
xpadeven though their device TOML setsblock_kernel_drivers.
Package-manager install fix:
systemctl --user daemon-reload
systemctl --user enable --now padctl.service
sudo udevadm control --reload-rules
Then unplug and replug the controller. The driver-block udev rule unbinds the kernel driver only while a padctl daemon is running (its control socket exists), so no extra setup step is needed — and a stopped daemon means the kernel driver keeps the device.
Source install fix:
sudo padctl install
systemctl --user restart padctl.service
Then unplug and replug the controller. For devices with block_kernel_drivers,
the source installer installs the udev rules and also tries to unbind already
attached matching devices during install. Do not use sudo padctl install as
the normal fix for AUR or .deb installs because it rewrites files that the
package manager owns.
padctl install warns "source 'devices/' directory not found"
Symptoms:
padctl installprints a warning about a missingdevices/directory.- After install,
padctl scanor the daemon log reports "no devices found in config dirs". - The daemon starts but no controller is recognized.
Root cause: pre-v0.1.5 .deb packages stripped one directory level from the devices/ tree
during packaging, leaving device TOML files absent from the installed prefix (issue #216).
Fixed in v0.1.5+.
Workaround: upgrade to v0.1.5 or later.
Verify the fix:
dpkg -L padctl | grep 'devices/'
The output should list multiple .toml files under /usr/share/padctl/devices/<vendor>/.
If the list is empty, the old package is still installed — re-download and reinstall.
User service exits with status=218/CAPABILITIES on Ubuntu 26.04 / systemd 257+
Fixed in v0.1.6. If you are running an older release, use the workaround below.
Symptoms:
systemctl --user status padctl.serviceshows:Failed at step CAPABILITIES spawning /usr/bin/padctl: Operation not permitted Main process exited, code=exited, status=218/CAPABILITIES- The daemon never starts;
padctl statusreturnscannot connect to padctl daemon. - The restart counter climbs in
journalctl --user -u padctl.service.
Root cause (pre-v0.1.6): the user service unit declared LockPersonality=true,
ProtectClock=true, and NoNewPrivileges=true. systemd 257+ enforces these options more
strictly on user instances; the kernel rejects the capability adjustments required to apply
them, killing the process before it starts.
Workaround (pre-v0.1.6 only): install a drop-in that clears the three offending directives:
mkdir -p ~/.config/systemd/user/padctl.service.d
cat > ~/.config/systemd/user/padctl.service.d/no-cap-lockdown.conf <<'EOF'
[Service]
LockPersonality=
ProtectClock=
NoNewPrivileges=
EOF
systemctl --user daemon-reload
systemctl --user restart padctl
Assigning an empty value to a systemd directive resets it to the default (unset). The functional and security impact is small: the daemon runs as your user with no privileged operations, so removing these three flags does not expand what it can do.
Reference: issue #216
Build fails on Arch Linux: relocation R_X86_64_PC64 against symbol ...
Symptoms:
zig buildfails during linking:
or similarrelocation R_X86_64_PC64 against symbol '__libc_start_main' can not be used when making a PIE objectR_X86_64_PC64/.sframe section is unsupportederrors.- Affects Arch Linux with glibc 2.43 or later.
Root cause: glibc 2.43+ adds .sframe sections to crt1.o startup objects that Zig 0.15.x's
linker does not handle. This is an upstream Zig limitation, not a padctl bug.
Workaround: build against the musl static target:
zig build -Doptimize=ReleaseSafe -Dtarget=x86_64-linux-musl
The resulting binary is fully static and works on any Linux distribution regardless of glibc version. This is the same target used for official padctl release tarballs.
Alternatively, build inside the canonical Docker image (./scripts/padctl-docker build,
Debian bookworm + glibc 2.36) for a reproducible build environment.
Reference: issue #147
Device Config Reference
Device configs are TOML files in devices/<vendor>/<model>.toml.
[device]
| Field | Type | Required | Description |
|---|---|---|---|
name | string | yes | Human-readable device name |
vid | integer | yes | USB vendor ID (hex literal ok: 0x054c) |
pid | integer | yes | USB product ID |
mode | string | no | Device mode identifier |
block_kernel_drivers | string[] | no | Kernel driver names to unbind via udev, e.g. block_kernel_drivers = ["xpad"]. The generated rule only detaches the kernel driver while a padctl daemon is running (its control socket exists), so a stopped daemon leaves the device to the kernel driver. When padctl install runs as root, it also walks /sys/bus/usb/drivers/<driver>/unbind for matching VID:PID pairs immediately. |
[[device.interface]]
| Field | Type | Required | Description |
|---|---|---|---|
id | integer | yes | USB interface number |
class | string | yes | "hid" or "vendor" |
ep_in | integer | no | IN endpoint number |
ep_out | integer | no | OUT endpoint number |
[device.init]
Optional initialization sequence sent after device open.
| Field | Type | Required | Description |
|---|---|---|---|
commands | string[] | yes | Hex byte strings sent in order |
response_prefix | integer[] | yes | Expected response prefix bytes |
enable | string | no | Hex byte string sent to activate extended mode (e.g. BT mode switch) |
disable | string | no | Hex byte string sent on shutdown |
interface | integer | no | Interface to send init commands on |
report_size | integer | no | Expected report size after init |
require_response | bool | no | When true, a missing response_prefix ACK fails init/re-init instead of continuing. Use for devices whose input reports are valid only after an acknowledged mode switch. |
feature_report | integer[] | no | HID feature report sent via HIDIOCSFEATURE immediately after commands. Encoded as a list of byte values (0–255); byte[0] is the report ID. |
[[report]]
Describes one incoming HID report.
| Field | Type | Required | Description |
|---|---|---|---|
name | string | yes | Report name (unique within device) |
interface | integer | yes | Which interface this report arrives on |
size | integer | yes | Report byte length |
[report.match]
Disambiguates reports when multiple share an interface.
| Field | Type | Description |
|---|---|---|
offset | integer | Byte position to inspect |
expect | integer[] | Expected bytes at that offset |
[report.fields]
Inline table mapping field names to their layout:
[report.fields]
left_x = { offset = 1, type = "u8", transform = "scale(-32768, 32767)" }
gyro_x = { offset = 16, type = "i16le" }
battery_level = { bits = [53, 0, 4] }
| Field | Type | Description |
|---|---|---|
offset | integer | Byte offset in report |
type | string | Data type (see below) |
bits | integer[3] | Sub-byte extraction: [byte_offset, bit_offset, bit_count] |
transform | string | Comma-separated transform chain |
Use offset + type for whole-byte fields. Use bits for sub-byte bit extraction (e.g. a 4-bit battery level packed within a byte).
Note: When using
bits, thetypefield must benull,"unsigned", or"signed"— standard type strings like"u8"or"i16le"are not valid.
Data Types
u8 i8 u16le i16le u16be i16be u32le i32le u32be i32be
Transform DSL
Transforms are applied left-to-right as a comma-separated chain:
| Transform | Description |
|---|---|
scale(min, max) | Linearly scale the raw value to the target range |
negate | Negate the value (multiply by -1) |
abs | Take the absolute value |
clamp | Clamp to the output axis range |
deadzone | Apply deadzone filtering |
Example: transform = "scale(-32768, 32767), negate" — scales a u8 (0–255) to -32768..32767, then negates the result.
[report.button_group]
Maps a contiguous byte range to named buttons via bit index.
[report.button_group]
source = { offset = 8, size = 3 }
map = { A = 5, B = 6, X = 4, Y = 7, LB = 8, RB = 9 }
| Field | Type | Description |
|---|---|---|
source.offset | integer | Starting byte offset within the report |
source.size | integer | Group width in bytes; must be 1..=8 (the interpreter packs the group into a u64; values above 8 are skipped at parse time with a warning logged to stderr and the report group falls back to all buttons unmapped) |
map | table | Button = bit_index. Bit indexes must satisfy 0 <= bit_index < size * 8. |
Button names must be valid ButtonId values:
A B X Y LB RB LT RT Start Select Home Capture LS RS DPadUp DPadDown DPadLeft DPadRight M1 M2 M3 M4 Paddle1 Paddle2 Paddle3 Paddle4 TouchPad Mic C Z LM RM O
[report.checksum]
Optional integrity check on the report.
| Field | Type | Description |
|---|---|---|
algo | string | crc32 sum8 xor |
range | integer[2] | [start, end] byte range to checksum |
seed | integer | Initial seed value prepended to CRC calculation (e.g. 0xa1 for DualSense BT) |
expect.offset | integer | Where the checksum is stored in the report |
expect.type | string | Storage type of the checksum field |
[commands.<name>]
Output command templates (rumble, LED, adaptive triggers, etc.). Template placeholders use {name:type} syntax.
[commands.rumble]
interface = 3
template = "02 01 00 {weak:u8} {strong:u8} 00 ..."
Adaptive Trigger Commands
DualSense-style adaptive triggers use a naming convention of adaptive_trigger_<mode>:
[commands.adaptive_trigger_off]
interface = 3
template = "02 0c 00 ..."
[commands.adaptive_trigger_feedback]
interface = 3
template = "02 0c 00 ... 01 {r_position:u8} {r_strength:u8} ... 01 {l_position:u8} {l_strength:u8} ..."
[commands.adaptive_trigger_weapon]
interface = 3
template = "02 0c 00 ... 02 {r_start:u8} {r_end:u8} {r_strength:u8} ... 02 {l_start:u8} {l_end:u8} {l_strength:u8} ..."
[commands.adaptive_trigger_vibration]
interface = 3
template = "02 0c 00 ... 06 {r_position:u8} {r_amplitude:u8} {r_frequency:u8} ... 06 {l_position:u8} {l_amplitude:u8} {l_frequency:u8} ..."
[output]
Declares the uinput device emitted by padctl.
| Field | Type | Description |
|---|---|---|
emulate | string | Preset emulation profile |
name | string | uinput device name |
vid | integer | Emulated vendor ID |
pid | integer | Emulated product ID |
[output.axes]
[output.axes]
left_x = { code = "ABS_X", min = -32768, max = 32767, fuzz = 16, flat = 128 }
[output.buttons]
[output.buttons]
A = "BTN_SOUTH"
B = "BTN_EAST"
[output.dpad]
[output.dpad]
type = "hat" # or "buttons"
[output.force_feedback]
Two backends are supported: legacy rumble via uinput (default), and HID PID passthrough via UHID for devices whose firmware speaks the USB HID PID class spec directly (most racing wheels).
Rumble (uinput, default)
[output.force_feedback]
type = "rumble"
max_effects = 16
auto_stop = true # default; set false to disable userspace auto-stop
| Field | Type | Default | Description |
|---|---|---|---|
type | string | "rumble" | Force-feedback type. "rumble" is the only legacy value. |
max_effects | int | 16 | Maximum number of concurrent FF effect slots |
auto_stop | bool | true | Enable userspace rumble auto-stop. When true, padctl emits a stop frame to the HID device after each effect's replay.length elapses — compensating for the fact that uinput does not use the kernel's ff-memless auto-stop timer. Set to false only for devices whose firmware handles auto-stop internally. |
backend | string | "uinput" | "uinput" (rumble) or "uhid" (PID passthrough — see below). |
kind | string | "rumble" | "rumble" or "pid". |
HID PID passthrough (UHID, racing wheels)
For devices that already implement HID PID effects in firmware (constant
force, spring, damper, friction, sine periodic, etc.), padctl can publish a
UHID node with the device's own PID descriptor and forward UHID_OUTPUT
events back to the physical wheel. The kernel's hid-pidff driver then
exposes the standard evdev FF interface to games and SDL — no userspace
effect synthesis.
Phase 13 Wave 6 introduced this path; closes issue #82 (Moza, Logitech G-series, Thrustmaster T-series, Fanatec ClubSport).
[output.force_feedback]
backend = "uhid"
kind = "pid"
clone_vid_pid = true # publish the UHID node with the wheel's real VID/PID
| Field | Type | Default | Description |
|---|---|---|---|
backend | string | "uinput" | Set to "uhid" for PID passthrough. |
kind | string | "rumble" | Set to "pid" for PID passthrough. |
clone_vid_pid | bool | false | When true, the emitted UHID node uses [device].vid / [device].pid so games and hid-pidff recognize the wheel by its real identifiers. Requires non-zero VID and PID in [device]. |
Validation matrix — the parser rejects illegal combinations at config load:
backend | kind | Result |
|---|---|---|
"uinput" | "rumble" | OK (default; legacy uinput rumble) |
"uinput" | "pid" | rejected |
"uhid" | "rumble" | rejected |
"uhid" | "pid" | OK — also requires [output.imu] to be declared (UHID routing gate) |
clone_vid_pid = true requires [device].vid and [device].pid to be non-zero.
Kernel requirement: the
hid-pidffdriver must be loaded, and thehid-universal-pidffquirk module is recommended for non-Logitech wheels. See the Bazzite / Immutable Distros guide for distro-specific notes.
[output.aux]
Auxiliary output device (mouse or keyboard).
| Field | Type | Description |
|---|---|---|
type | string | "mouse" or "keyboard" |
name | string | uinput device name |
keyboard | bool | Create keyboard capability |
buttons | table | Button-to-event mapping |
[output.touchpad]
Touchpad output device.
| Field | Type | Description |
|---|---|---|
name | string | uinput device name |
x_min / x_max | integer | X axis range |
y_min / y_max | integer | Y axis range |
max_slots | integer | Maximum multitouch slots |
[output.imu]
IMU (accelerometer + gyroscope) output via a separate UHID node. When declared, padctl creates a second UHID device that shares the same uniq as the primary gamepad output, enabling SDL3 to pair the IMU sensor with the controller automatically (ADR-015 UHID IMU migration; see PR #159).
Validation rule: when
[output.imu]is present,backendmust be"uhid". The validator rejects"uinput"per ADR-015 — UHID is the only supported backend for IMU output. Omitting[output.imu]entirely keeps the legacy uinput-primary path.
Distinction from
[output.aux]:[output.imu]is the gamepad's accelerometer/gyroscope UHID node;[output.aux]is a secondary uinput device for mouse/keyboard remapping. They serve different purposes and can coexist.
See ADR-015 for the design rationale. This section enables SDL3-visible sensor pairing on Steam games.
Force feedback limitation: enabling
[output.imu] backend="uhid"places the primary gamepad on the UHID backend. On current kernels, evdev force feedback is unavailable for UHID devices unless the kernel'shid-universal-pidffmodule recognises the device VID/PID — generic UHID nodes getcapabilities/ff=0. Wine/Lutris evdev rumble will not work in this mode. Rumble via SDL or Steam (HIDAPI path) is unaffected.
| Field | Type | Required | Default | Description |
|---|---|---|---|---|
backend | string | no | "uhid" | Must be "uhid"; only legal value (validator rejects "uinput") |
name | string | no | — | UHID device name shown to userspace |
vid | integer | no | inherits from [device].vid | Emulated vendor ID |
pid | integer | no | inherits from [device].pid | Emulated product ID |
accel_range | int[2] | no | [-32768, 32767] | Accelerometer output range [min, max] |
gyro_range | int[2] | no | [-32768, 32767] | Gyroscope output range [min, max] |
Example:
[output.imu]
backend = "uhid"
name = "vader5_imu"
vid = 0x11ff
pid = 0x1211
accel_range = [-16384, 16384]
gyro_range = [-32768, 32767]
[wasm]
WASM plugin for stateful/custom protocols (Phase 4+).
| Field | Type | Description |
|---|---|---|
plugin | string | Path to .wasm plugin file |
[wasm.overrides]
| Field | Type | Description |
|---|---|---|
process_report | bool | Plugin handles report processing |
Mapping Config Reference
An optional --mapping TOML file overrides the default button/axis pass-through with remapping, gyro mouse, stick modes, layers, and macros.
Top-level Fields
name = "fps"
trigger_threshold = 100
| Field | Type | Default | Description |
|---|---|---|---|
name | string | — | Mapping profile name. Used by padctl switch <name> and default_mapping in user config to identify this profile. |
trigger_threshold | integer (0–255) | null | Threshold for synthesizing digital LT / RT button events from the analog trigger axes. Top-level only — placing this inside [[layer]] is silently ignored. See below. |
chord_index | integer (0–255) | null | Selector index used by the in-controller [chord_switch] quick-switch. The value is matched against the position of [chord_switch].selectors: chord_index = i+1 activates when selectors[i] is pressed. Set chord_index = 0 (or omit) to leave a mapping unselectable via chord. See Diagnostic Logging — Chord switch for the full setup. |
Validation behaviour
padctl daemon runs a post-parse linter on every mapping TOML file at startup. Unknown keys produce warnings to stderr with line numbers and section context:
config: unknown key 'trigger_threshold' inside [layer] (line 42) — typo or misplaced field?
config: unknown key 'typo_field' at top-level (line 7) — typo or misplaced field?
The linter is fail-open: warnings only, the daemon still starts. This surfaces common mistakes such as placing trigger_threshold inside a [[layer]] block instead of at the top level (also surfaces preceding silent rewrites — see Diagnostic logging).
trigger_threshold — analog triggers as digital buttons
padctl models LT and RT as analog axes (ABS_Z / ABS_RZ) by default. To bind them to keys or mouse buttons in [remap], declare a threshold:
trigger_threshold = 100 # 0–255, shared by both LT and RT
[remap]
LT = "KEY_LEFTSHIFT"
RT = "mouse_right"
Axis value above threshold → synthesizes LT / RT button press. Value at or below threshold → release. Once declared, LT and RT behave like any other face button for [remap] sources and [[layer]] trigger fields.
Threshold tuning:
| Value | Feel |
|---|---|
| 50–80 | Light touch triggers |
| 100–120 | Click-like feel (recommended starting point) |
| 160+ | Deliberate press only |
Use padctl dump enable to observe raw LT / RT axis readings and dial in the threshold. See Diagnostic Logging.
Jitter: If the axis hovers around the threshold and produces rapid press/release bursts, raise the threshold by 10–20.
Without trigger_threshold, LT / RT emit analog axis events only and do not participate in [remap] or layer trigger matching.
[remap]
Top-level button remapping (active when no layer overrides). Keys are ButtonId names, values are target button names, KEY_* codes, mouse_left/mouse_right/mouse_middle/mouse_side/mouse_extra/mouse_forward/mouse_back, disabled, or macro:<name>.
LT / RT as targets emit the digital trigger press plus a full analog pull (ABS_Z / ABS_RZ = 255) while the source button is held. Tap/double gesture legs targeting LT / RT emit only the digital press; use a plain remap or a hold leg for an analog pull.
[remap]
M1 = "RT" # full analog pull (255) while M1 is held
M2 = "LT"
Note:
BTN_*values (e.g."BTN_SOUTH") are routed to the virtual mouse device, not the gamepad. To target a gamepad button use a friendlyButtonIdname ("A","Select", etc.) instead.
[remap]
M1 = "KEY_F13"
M2 = "mouse_side"
M3 = "disabled"
A = "B"
M4 = "macro:dodge_roll"
Array values (e.g. M1 = ["KEY_LEFTMETA", "KEY_1"]) are parsed and resolved as chord targets (2–4 keys) but are not yet dispatched — chord output is planned for a future release.
Gesture bindings (tap / hold / double-press)
A [remap] value may also be an inline table that binds different actions to
short press, long press, and double press of the same button:
[remap]
A = { tap = "KEY_X", hold = "KEY_Y", double = "KEY_Z" }
B = { tap = "B", hold = "KEY_LEFTSHIFT" }
Y = { tap = "Y", double = "KEY_F" }
RB = { tap = "RB", hold = "KEY_TAB", hold_ms = 400, double_ms = 200 }
| Key | Type | Default | Meaning |
|---|---|---|---|
tap | string | — | Action for a short press (fired on release). |
hold | string | — | Action fired once the button is held past hold_ms. |
double | string | — | Action fired when a second press starts within double_ms of the first release. |
hold_ms | integer (1–5000) | 300 | Hold threshold in milliseconds. |
double_ms | integer (1–5000) | 250 | Double-press window in milliseconds. |
At least one of tap / hold / double must be set. Each leg is a single
target (ButtonId, KEY_*, mouse_*, or disabled); macro:<name> and chord
arrays are not allowed inside a gesture. An empty table {} or an unknown key
is rejected at parse time; out-of-range thresholds and a base-[remap] gesture
key that collides with a [[layer]] trigger are rejected at validate time.
Absent legs simply do nothing.
Latency trade-off: when double is set, tap cannot fire until the
double-press window has elapsed (the engine must wait to see whether a second
press arrives). Without double, tap fires immediately on release with zero
added latency. Plain string and chord-array remap forms are unaffected and
incur no extra latency.
[gyro]
Global gyro-to-mouse configuration.
[gyro]
mode = "mouse"
activate = "LS"
sensitivity = 2.0
deadzone = 300
smoothing = 0.4
curve = 1.0
invert_y = true
| Field | Type | Default | Description |
|---|---|---|---|
mode | string | "off" | "off", "mouse", or "joystick". In "joystick" mode the processed gyro signal is routed to a virtual stick axis instead of mouse REL_X/Y events. |
target | string | "right_stick" | "right_stick" or "left_stick". Selects which stick axis receives the gyro output. Only used when mode = "joystick". |
response | string | "rate" | "rate" keeps the existing gyro-rate behavior. "tilt" maps controller tilt to an absolute virtual stick position and is valid only with mode = "joystick". |
axis_x | string | "yaw" / "roll" | Source for virtual stick X: "yaw", "pitch", "roll", or "none". Defaults to "yaw" in "rate" response and "roll" in "tilt" response. In "tilt" response, roll and pitch are estimated from accelerometer data; yaw resolves to neutral. |
axis_y | string | "pitch" | Source for virtual stick Y: "yaw", "pitch", "roll", or "none". |
degrees_full | float | 35.0 | In "tilt" response, the absolute tilt angle that maps to full stick deflection. Must be greater than 0 and no more than 180. |
activate | string | — | Gate button: bare name ("LS") or hold_<BTN> form ("hold_RB") — both are equivalent. For analog triggers (LT/RT), also set trigger_threshold. Omit for always-active. |
sensitivity | float | — | Overall sensitivity multiplier. In "mouse" mode it scales relative cursor motion; in "joystick"/"rate" mode it scales an absolute stick deflection and typically needs a much larger value (≈50–150) — see the gyro joystick guide. |
sensitivity_x | float | — | X-axis sensitivity override |
sensitivity_y | float | — | Y-axis sensitivity override |
deadzone | integer | — | Raw gyro deadzone threshold in "rate" response. In "tilt" response this is an output stick deadzone after angle conversion. |
smoothing | float | — | Smoothing factor (0–1) |
curve | float | — | Acceleration curve exponent |
max_val | float | 32767 | Input normalization ceiling: the raw gyro rate that normalizes to 1.0, before curve, sensitivity, and output scaling are applied. Lowering it (e.g. 2000) raises the normalized signal, so a moderate motion drives a larger output — equivalent to raising sensitivity, and useful in "joystick" mode to keep sensitivity numbers small. |
invert_x | bool | — | Invert X axis |
invert_y | bool | — | Invert Y axis |
blend_stick | bool | false | When true, gyro joystick output is added to the physical stick value (clamp(physical + gyro, -32767..32767)) instead of replacing it. Only applies when mode = "joystick". Ignored for mode = "mouse". |
minimum_output | float | 0.0 | Minimum stick deflection magnitude while gyro is active, as a fraction of full scale (clamped to 0.0–1.0, where 1.0 ≈ 20000 stick units). When the computed output magnitude is non-zero but below this floor, it is scaled up to exactly minimum_output while preserving direction. 0.0 disables the floor (default, no-op). A still controller stays at zero, and deadzone always wins (input absorbed by the deadzone produces zero output, never resurrected by the floor). Only applies when mode = "joystick"; ignored for mode = "mouse". Useful for escaping an in-game stick deadzone so small gyro motion still registers. |
[stick.left] / [stick.right]
Per-stick mode configuration.
[stick.left]
mode = "gamepad"
deadzone = 128
sensitivity = 1.0
[stick.right]
mode = "mouse"
sensitivity = 2.5
deadzone = 100
suppress_gamepad = true
| Field | Type | Default | Description |
|---|---|---|---|
mode | string | "gamepad" | "gamepad", "mouse", or "scroll" |
deadzone | integer | — | Stick deadzone threshold |
sensitivity | float | — | Sensitivity multiplier |
suppress_gamepad | bool | — | Suppress gamepad axis output when in mouse/scroll mode |
[dpad]
D-pad mode configuration.
[dpad]
mode = "gamepad"
| Field | Type | Default | Description |
|---|---|---|---|
mode | string | "gamepad" | "gamepad" or "arrows" (emits arrow keys) |
suppress_gamepad | bool | — | Suppress gamepad d-pad output when in arrows mode |
[[layer]]
Each layer defines an activation condition and overrides for remap, gyro, sticks, and d-pad. Layers are evaluated in declaration order.
[[layer]]
name = "fps"
trigger = "LM"
activation = "hold"
tap = "mouse_side"
hold = "RB"
hold_timeout = 200
| Field | Type | Required | Description |
|---|---|---|---|
name | string | yes | Unique layer identifier |
trigger | string | yes | Button name that activates this layer |
activation | string | no | "hold" (default), "toggle", or "hold_toggle". hold_toggle starts like hold, but holding past hold_timeout toggles the layer sticky on/off instead of making it momentary. |
tap | string | no | Button/key emitted on short press (when using hold or hold_toggle activation). May be a ButtonId, KEY_*, mouse_*, or disabled. Cannot be macro:<name> — the layer tap dispatch path does not run macros, so tap = "macro:foo" is rejected at validate time (error.LayerTapCannotBeMacro). Use macro:<name> from [remap] / [layer.remap] instead. |
hold | string | no | Passthrough output emitted continuously while the layer is active, for every activation mode (hold / hold_toggle / toggle). A single ButtonId, KEY_*, mouse_*, or BTN_* target. Fires only after the layer activates — never on a short tap (tap still fires for the short press). Cannot be macro:<name> (error.LayerHoldCannotBeMacro). Released on every exit path (trigger release, layer/mapping switch, reset). |
hold_timeout | integer | no | Hold detection threshold in ms (1–5000); default 200 |
[layer.remap]
Per-layer button remapping. Same syntax as top-level [remap].
[layer.remap]
RT = "mouse_left"
A = "KEY_R"
[layer.gyro]
Per-layer gyro override. Same fields as [gyro].
[layer.gyro]
mode = "mouse"
sensitivity = 8.0
deadzone = 40
smoothing = 0.4
invert_y = true
[layer.stick_left] / [layer.stick_right]
Per-layer stick overrides. Same fields as [stick.left]/[stick.right].
[layer.stick_right]
mode = "mouse"
sensitivity = 2.5
deadzone = 100
suppress_gamepad = true
[layer.dpad]
Per-layer d-pad override. Same fields as [dpad].
[layer.dpad]
mode = "arrows"
suppress_gamepad = true
[layer.adaptive_trigger]
Per-layer adaptive trigger override. Same fields as top-level [adaptive_trigger].
[layer.adaptive_trigger]
mode = "weapon"
[layer.adaptive_trigger.left]
start = 30
end = 120
strength = 200
[adaptive_trigger]
DualSense adaptive trigger configuration.
[adaptive_trigger]
mode = "feedback"
[adaptive_trigger.left]
position = 70
strength = 200
[adaptive_trigger.right]
position = 40
strength = 180
| Field | Type | Default | Description |
|---|---|---|---|
mode | string | "off" | "off", "feedback", "weapon", or "vibration" |
command_prefix | string | "adaptive_trigger_" | Command template prefix in device config |
[adaptive_trigger.left] / [adaptive_trigger.right]
| Field | Type | Description |
|---|---|---|
position | integer | Trigger position threshold |
strength | integer | Resistance strength |
start | integer | Start position (weapon mode) |
end | integer | End position (weapon mode) |
amplitude | integer | Vibration amplitude |
frequency | integer | Vibration frequency |
[[macro]]
Named sequences of input steps bound via macro:<name> in remap values.
[[macro]]
name = "dodge_roll"
steps = [
{ tap = "B" },
{ delay = 50 },
{ tap = "LEFT" },
]
[[macro]]
name = "shift_hold"
steps = [
{ down = "KEY_LEFTSHIFT" },
"pause_for_release",
{ up = "KEY_LEFTSHIFT" },
]
Step types:
| Step | Description |
|---|---|
{ tap = "KEY" } | Press and release a key |
{ down = "KEY" } | Press and hold a key |
{ up = "KEY" } | Release a key |
{ delay = N } | Wait N milliseconds |
"pause_for_release" | Wait until the trigger button is released |
{ press = "KEY" } | Sugar: emits down here and appends up at macro end (LIFO if multiple). Cannot be mixed with explicit down/up of the same button. |
Macro fields:
| Field | Description |
|---|---|
name | Identifier referenced from remap as macro:<name> |
steps | Ordered step list |
repeat_delay_ms | Optional. While the trigger button is held, restart the macro N ms after the previous run finishes. Releasing the trigger lets the current iteration finish naturally and stops further restarts. Omit for single-shot (legacy) behaviour. |
step_delay | Optional. Per-macro implicit delay (ms) inserted between adjacent emitting steps (tap/down/up). Overrides the top-level macro_step_delay. Explicit delay steps and pause_for_release neighbours suppress insertion. 0 disables (explicit zero wins over the global default). |
Implicit step delays
The top-level macro_step_delay = N (ms) sets a global default that is inserted between every pair of adjacent emitting steps (tap / down / up) in every macro. A per-macro step_delay overrides the global. Both default to 0 (no insertion, byte-identical to legacy behaviour).
Insertion rules:
- Only inserted between two adjacent emitting steps (
tap/down/up). - Never inserted adjacent to an explicit
{ delay = N }step (it already is a delay). - Never inserted adjacent to
"pause_for_release"(treat it as a synchronization point). - Per-macro
step_delay(including explicit0) wins over globalmacro_step_delay.
macro_step_delay = 50 # global default for every macro
[[macro]]
name = "unarmed"
# step_delay omitted → inherits global 50
steps = [
{ down = "RB" },
{ down = "X" },
"pause_for_release",
{ up = "X" },
{ up = "RB" },
]
# Effective after parse:
# down RB, delay 50, down X, pause_for_release, up X, delay 50, up RB
Note:
hold_timeoutis a[[layer]]field (see above, default 200 ms). It is not a[[macro]]field — placing it under[[macro]]has no effect (the schema linter warns about it). To make a hold-activated layer respond faster, sethold_timeouton the[[layer]]block, e.g.hold_timeout = 50.
# Turbo: spam A while RM is held, 50 ms between presses.
[[macro]]
name = "spam_a"
repeat_delay_ms = 50
steps = [{ tap = "A" }]
# Combo: XYX every 100 ms while held.
[[macro]]
name = "xyx_combo"
repeat_delay_ms = 100
steps = [
{ tap = "X" },
{ delay = 30 },
{ tap = "Y" },
{ delay = 30 },
{ tap = "X" },
]
Bind a macro in remap: M1 = "macro:dodge_roll"
[chord_switch] — in-controller mapping switch
[chord_switch] lives in ~/.config/padctl/config.toml (the user config), not in a mapping file. It lets you switch the active mapping without touching a CLI: hold a modifier combination, then tap a selector button.
# ~/.config/padctl/config.toml
version = 1
[chord_switch]
modifier = ["LM", "RM"] # hold ALL of these to arm
selectors = ["A", "B", "X", "Y"] # tap one (while modifier held) to switch
hold_ms = 120 # debounce window in ms (default 80)
| Field | Type | Default | Description |
|---|---|---|---|
modifier | array of ButtonId | — | All listed buttons must be held simultaneously to arm chord-switch mode. Missing or empty disables the feature. |
selectors | array of ButtonId | — | Selector at index i (0-based) activates the mapping that declares chord_index = i+1. Missing or empty disables the feature. |
hold_ms | integer | 80 | Debounce window in milliseconds. Selector edges received within this window after the modifier first becomes fully held are ignored. Raise if you get accidental switches when pressing modifier and selector nearly simultaneously. |
chord_index is declared per mapping file (not in config.toml):
# ~/.config/padctl/mappings/desktop.toml
chord_index = 1 # tap A (selectors[0]) while holding LM+RM → switch here
Selector selectors[i] maps to chord_index = i+1. Mappings that do not declare chord_index are not reachable via chord. Range is 1–255; duplicate chord_index values across files are resolved by lexicographic mapping name order (first match wins).
While the modifier is held, all selector buttons are suppressed from the output device so they do not fire their remapped actions.
A runnable example is at examples/configs/chord-switch.toml.
Device Reference
Device reference pages are generated from production TOML configs via
padctl --doc-gen. This section lists supported device families; compatibility
variants may share one generated page, and fixtures under devices/example/ are
for parser/CI coverage rather than user support claims.
Run padctl --doc-gen devices/**/*.toml to regenerate all pages.
8BitDo Ultimate Controller
VID:PID 0x2dc8:0x6003
Vendor 8bitdo
Interfaces
| ID | Class | EP IN | EP OUT |
|---|---|---|---|
| 0 | hid | — | — |
Report: usb (64 bytes, interface 0)
Match: byte[0] = 0x01
Fields
| Name | Offset | Type | Transform |
|---|---|---|---|
lt | 9 | u8 | — |
left_x | 1 | i16le | — |
rt | 10 | u8 | — |
right_x | 5 | i16le | — |
left_y | 3 | i16le | negate |
right_y | 7 | i16le | negate |
Button Map
Source: offset 11, size 2 byte(s)
| Button | Bit Index |
|---|---|
LS | 9 |
RS | 10 |
X | 2 |
LB | 4 |
RB | 5 |
A | 0 |
Select | 6 |
Home | 8 |
Start | 7 |
Y | 3 |
B | 1 |
Output Capabilities
uinput device name: 8BitDo Ultimate Controller | VID 0x2dc8 | PID 0x6003
Axes
| Field | Code | Min | Max | Fuzz | Flat |
|---|---|---|---|---|---|
lt | ABS_Z | 0 | 255 | 0 | 0 |
left_x | ABS_X | -32768 | 32767 | 16 | 128 |
rt | ABS_RZ | 0 | 255 | 0 | 0 |
right_x | ABS_RX | -32768 | 32767 | 16 | 128 |
left_y | ABS_Y | -32768 | 32767 | 16 | 128 |
right_y | ABS_RY | -32768 | 32767 | 16 | 128 |
Buttons
| Button | Event Code |
|---|---|
LS | BTN_THUMBL |
RS | BTN_THUMBR |
X | BTN_WEST |
LB | BTN_TL |
RB | BTN_TR |
A | BTN_SOUTH |
Select | BTN_SELECT |
Home | BTN_MODE |
Start | BTN_START |
Y | BTN_NORTH |
B | BTN_EAST |
Flydigi Vader 4 Pro
VID:PID 0x37d7:0x3001
Vendor flydigi
Interfaces
| ID | Class | EP IN | EP OUT |
|---|---|---|---|
| 0 | hid | — | — |
Report: extended (32 bytes, interface 0)
Match: byte[0] = 0x04
Fields
| Name | Offset | Type | Transform |
|---|---|---|---|
right_y | 22 | u8 | scale(-32768, 32767), negate |
accel_y | 13 | i16le | — |
gyro_z | 29 | i16le | — |
gyro_x | 26 | i16le | — |
accel_z | 15 | i16le | — |
accel_x | 11 | i16le | — |
left_x | 17 | u8 | scale(-32768, 32767) |
rt | 24 | u8 | — |
right_x | 21 | u8 | scale(-32768, 32767) |
lt | 23 | u8 | — |
left_y | 19 | u8 | scale(-32768, 32767), negate |
Button Map
Source: offset 7, size 4 byte(s)
| Button | Bit Index |
|---|---|
M2 | 3 |
M1 | 2 |
LT | 27 |
RT | 26 |
B | 18 |
LS | 25 |
RS | 24 |
X | 31 |
LB | 29 |
Home | 8 |
Select | 9 |
A | 19 |
RB | 28 |
M4 | 1 |
M3 | 0 |
Y | 16 |
Start | 30 |
Output Capabilities
uinput device name: Flydigi Vader 4 Pro | VID 0x37d7 | PID 0x3001
Axes
| Field | Code | Min | Max | Fuzz | Flat |
|---|---|---|---|---|---|
lt | ABS_Z | 0 | 255 | 0 | 0 |
left_x | ABS_X | -32768 | 32767 | 16 | 128 |
rt | ABS_RZ | 0 | 255 | 0 | 0 |
right_x | ABS_RX | -32768 | 32767 | 16 | 128 |
left_y | ABS_Y | -32768 | 32767 | 16 | 128 |
right_y | ABS_RY | -32768 | 32767 | 16 | 128 |
Buttons
| Button | Event Code |
|---|---|
M2 | BTN_TRIGGER_HAPPY2 |
LT | BTN_TL2 |
M1 | BTN_TRIGGER_HAPPY1 |
RT | BTN_TR2 |
B | BTN_EAST |
LS | BTN_THUMBL |
RS | BTN_THUMBR |
X | BTN_WEST |
LB | BTN_TL |
RB | BTN_TR |
A | BTN_SOUTH |
Select | BTN_SELECT |
Home | BTN_MODE |
Start | BTN_START |
Y | BTN_NORTH |
M3 | BTN_TRIGGER_HAPPY3 |
M4 | BTN_TRIGGER_HAPPY4 |
Flydigi Vader 5 Pro
VID:PID 0x37d7:0x2401
Vendor flydigi
Interfaces
| ID | Class | EP IN | EP OUT |
|---|---|---|---|
| 1 | hid | — | — |
Report: extended (32 bytes, interface 1)
Match: byte[0] = 0x5a, 0xa5, 0xef
Fields
| Name | Offset | Type | Transform |
|---|---|---|---|
left_y | 5 | i16le | negate |
right_y | 9 | i16le | negate |
accel_x | 23 | i16le | — |
gyro_z | 21 | i16le | — |
gyro_x | 17 | i16le | — |
gyro_y | 19 | i16le | — |
accel_z | 27 | i16le | — |
accel_y | 25 | i16le | — |
left_x | 3 | i16le | — |
rt | 16 | u8 | — |
lt | 15 | u8 | — |
right_x | 7 | i16le | — |
Button Map
Source: offset 11, size 4 byte(s)
| Button | Bit Index |
|---|---|
M2 | 19 |
M4 | 21 |
M1 | 18 |
DPadUp | 0 |
DPadRight | 1 |
RM | 23 |
LM | 22 |
B | 5 |
DPadDown | 2 |
DPadLeft | 3 |
X | 7 |
LS | 14 |
RS | 15 |
LB | 10 |
A | 4 |
Select | 6 |
RB | 11 |
C | 16 |
Z | 17 |
Home | 27 |
Start | 9 |
O | 24 |
Y | 8 |
M3 | 20 |
Commands
| Name | Interface | Template |
|---|---|---|
rumble | 1 | 5aa5 1206 {strong:u8} {weak:u8} 0000 0000... |
Output Capabilities
uinput device name: Xbox Elite Series 2 | VID 0x045e | PID 0x0b00
Axes
| Field | Code | Min | Max | Fuzz | Flat |
|---|---|---|---|---|---|
lt | ABS_Z | 0 | 255 | 0 | 0 |
left_x | ABS_X | -32768 | 32767 | 16 | 128 |
rt | ABS_RZ | 0 | 255 | 0 | 0 |
right_x | ABS_RX | -32768 | 32767 | 16 | 128 |
left_y | ABS_Y | -32768 | 32767 | 16 | 128 |
right_y | ABS_RY | -32768 | 32767 | 16 | 128 |
Buttons
| Button | Event Code |
|---|---|
M2 | BTN_TRIGGER_HAPPY2 |
M4 | BTN_TRIGGER_HAPPY4 |
M1 | BTN_TRIGGER_HAPPY1 |
RM | BTN_TRIGGER_HAPPY8 |
LM | BTN_TRIGGER_HAPPY7 |
B | BTN_EAST |
LS | BTN_THUMBL |
RS | BTN_THUMBR |
X | BTN_NORTH |
Z | BTN_TRIGGER_HAPPY6 |
LB | BTN_TL |
RB | BTN_TR |
A | BTN_SOUTH |
Select | BTN_SELECT |
Home | BTN_MODE |
C | BTN_TRIGGER_HAPPY5 |
Start | BTN_START |
O | BTN_TRIGGER_HAPPY9 |
Y | BTN_WEST |
M3 | BTN_TRIGGER_HAPPY3 |
Force feedback: type=rumble, max_effects=16
HORI Horipad Steam
VID:PID 0x0f0d:0x00c5
Vendor hori
Interfaces
| ID | Class | EP IN | EP OUT |
|---|---|---|---|
| 0 | hid | — | — |
Report: bt (287 bytes, interface 0)
Match: byte[0] = 0x07
Fields
| Name | Offset | Type | Transform |
|---|---|---|---|
left_y | 2 | u8 | scale(-32768, 32767), negate |
right_y | 4 | u8 | scale(-32768, 32767), negate |
accel_x | 22 | i16le | — |
gyro_z | 16 | i16le | — |
gyro_x | 12 | i16le | — |
gyro_y | 14 | i16le | — |
accel_z | 18 | i16le | — |
accel_y | 20 | i16le | — |
left_x | 1 | u8 | scale(-32768, 32767) |
rt | 8 | u8 | — |
lt | 9 | u8 | — |
right_x | 3 | u8 | scale(-32768, 32767) |
Button Map
Source: offset 5, size 3 byte(s)
| Button | Bit Index |
|---|---|
M2 | 20 |
LT | 11 |
M1 | 14 |
RT | 10 |
B | 2 |
LS | 22 |
RS | 21 |
X | 0 |
LB | 13 |
A | 3 |
Select | 9 |
RB | 12 |
Home | 23 |
Start | 8 |
Y | 15 |
M4 | 16 |
M3 | 17 |
Output Capabilities
uinput device name: HORI Horipad Steam | VID 0x0f0d | PID 0x00c5
Axes
| Field | Code | Min | Max | Fuzz | Flat |
|---|---|---|---|---|---|
lt | ABS_Z | 0 | 255 | 0 | 0 |
left_x | ABS_X | -32768 | 32767 | 16 | 128 |
rt | ABS_RZ | 0 | 255 | 0 | 0 |
right_x | ABS_RX | -32768 | 32767 | 16 | 128 |
left_y | ABS_Y | -32768 | 32767 | 16 | 128 |
right_y | ABS_RY | -32768 | 32767 | 16 | 128 |
Buttons
| Button | Event Code |
|---|---|
M2 | BTN_TRIGGER_HAPPY2 |
LT | BTN_TL2 |
M1 | BTN_TRIGGER_HAPPY1 |
RT | BTN_TR2 |
B | BTN_EAST |
LS | BTN_THUMBL |
RS | BTN_THUMBR |
X | BTN_WEST |
LB | BTN_TL |
RB | BTN_TR |
A | BTN_SOUTH |
Select | BTN_SELECT |
Home | BTN_MODE |
Start | BTN_START |
Y | BTN_NORTH |
M3 | BTN_TRIGGER_HAPPY3 |
M4 | BTN_TRIGGER_HAPPY4 |
Lenovo Legion Go
VID:PID 0x17ef:0x6182
Vendor lenovo
Interfaces
| ID | Class | EP IN | EP OUT |
|---|---|---|---|
| 0 | hid | — | — |
Report: xinput (60 bytes, interface 0)
Match: byte[0] = 0x04
Fields
| Name | Offset | Type | Transform |
|---|---|---|---|
lt | 22 | u8 | — |
left_x | 14 | u8 | scale(-32768, 32767) |
rt | 23 | u8 | — |
right_x | 16 | u8 | scale(-32768, 32767) |
left_y | 15 | u8 | scale(-32768, 32767), negate |
right_y | 17 | u8 | scale(-32768, 32767), negate |
Button Map
Source: offset 18, size 3 byte(s)
| Button | Bit Index |
|---|---|
M2 | 20 |
M1 | 1 |
LT | 14 |
DPadUp | 4 |
DPadRight | 7 |
RT | 15 |
B | 9 |
LS | 2 |
RS | 3 |
DPadDown | 5 |
DPadLeft | 6 |
X | 10 |
LB | 12 |
Home | 0 |
A | 8 |
RB | 13 |
Select | 22 |
Start | 23 |
Y | 11 |
M3 | 21 |
Output Capabilities
uinput device name: Lenovo Legion Go | VID 0x17ef | PID 0x6182
Axes
| Field | Code | Min | Max | Fuzz | Flat |
|---|---|---|---|---|---|
lt | ABS_Z | 0 | 255 | 0 | 0 |
left_x | ABS_X | -32768 | 32767 | 16 | 128 |
rt | ABS_RZ | 0 | 255 | 0 | 0 |
right_x | ABS_RX | -32768 | 32767 | 16 | 128 |
left_y | ABS_Y | -32768 | 32767 | 16 | 128 |
right_y | ABS_RY | -32768 | 32767 | 16 | 128 |
Buttons
| Button | Event Code |
|---|---|
M2 | BTN_TRIGGER_HAPPY2 |
LT | BTN_TL2 |
M1 | BTN_TRIGGER_HAPPY1 |
RT | BTN_TR2 |
B | BTN_EAST |
LS | BTN_THUMBL |
RS | BTN_THUMBR |
X | BTN_WEST |
LB | BTN_TL |
RB | BTN_TR |
A | BTN_SOUTH |
Select | BTN_SELECT |
Home | BTN_MODE |
Start | BTN_START |
Y | BTN_NORTH |
M3 | BTN_TRIGGER_HAPPY3 |
Lenovo Legion Go S
VID:PID 0x1a86:0xe310
Vendor lenovo
Interfaces
| ID | Class | EP IN | EP OUT |
|---|---|---|---|
| 6 | hid | — | — |
Report: gamepad (32 bytes, interface 6)
Match: byte[0] = 0x06
Fields
| Name | Offset | Type | Transform |
|---|---|---|---|
lt | 12 | u8 | — |
left_x | 4 | i8 | scale(-32768, 32767) |
rt | 13 | u8 | — |
right_x | 6 | i8 | scale(-32768, 32767) |
left_y | 5 | i8 | scale(-32768, 32767), negate |
right_y | 7 | i8 | scale(-32768, 32767), negate |
Button Map
Source: offset 0, size 4 byte(s)
| Button | Bit Index |
|---|---|
M2 | 20 |
M1 | 1 |
LT | 9 |
DPadUp | 4 |
DPadRight | 7 |
RT | 8 |
B | 14 |
LS | 3 |
RS | 2 |
DPadDown | 5 |
DPadLeft | 6 |
X | 13 |
LB | 11 |
Home | 0 |
RB | 10 |
A | 15 |
Select | 23 |
Start | 22 |
Y | 12 |
M3 | 21 |
Commands
| Name | Interface | Template |
|---|---|---|
rumble | 6 | 04 00 08 00 {strong:u8} {weak:u8} 00 00 00... |
Output Capabilities
uinput device name: Xbox Elite Series 2 | VID 0x045e | PID 0x0b00
Axes
| Field | Code | Min | Max | Fuzz | Flat |
|---|---|---|---|---|---|
lt | ABS_Z | 0 | 255 | 0 | 0 |
left_x | ABS_X | -32768 | 32767 | 16 | 128 |
rt | ABS_RZ | 0 | 255 | 0 | 0 |
right_x | ABS_RX | -32768 | 32767 | 16 | 128 |
left_y | ABS_Y | -32768 | 32767 | 16 | 128 |
right_y | ABS_RY | -32768 | 32767 | 16 | 128 |
Buttons
| Button | Event Code |
|---|---|
M2 | BTN_TRIGGER_HAPPY2 |
M1 | BTN_TRIGGER_HAPPY1 |
B | BTN_EAST |
LS | BTN_THUMBL |
RS | BTN_THUMBR |
X | BTN_NORTH |
LB | BTN_TL |
RB | BTN_TR |
A | BTN_SOUTH |
Select | BTN_SELECT |
Home | BTN_MODE |
Start | BTN_START |
Y | BTN_WEST |
M3 | BTN_TRIGGER_HAPPY3 |
Force feedback: type=rumble, max_effects=16
Xbox Elite Series 2
VID:PID 0x045e:0x0b00
Vendor microsoft
Interfaces
| ID | Class | EP IN | EP OUT |
|---|---|---|---|
| 0 | hid | — | — |
Report: usb (64 bytes, interface 0)
Match: byte[0] = 0x01
Fields
| Name | Offset | Type | Transform |
|---|---|---|---|
lt | 9 | u16le | scale(0, 255) |
left_x | 1 | i16le | — |
rt | 11 | u16le | scale(0, 255) |
right_x | 5 | i16le | — |
left_y | 3 | i16le | negate |
right_y | 7 | i16le | negate |
Button Map
Source: offset 13, size 3 byte(s)
| Button | Bit Index |
|---|---|
M2 | 1 |
M1 | 0 |
B | 9 |
LS | 16 |
RS | 17 |
X | 10 |
LB | 12 |
A | 8 |
RB | 13 |
Select | 14 |
Home | 18 |
Start | 15 |
M4 | 3 |
M3 | 2 |
Y | 11 |
Commands
| Name | Interface | Template |
|---|---|---|
rumble | 0 | 09 00 00 09 00 0f {strong:u8} {weak:u8} {left_trigger:u8} {r... |
Output Capabilities
uinput device name: Xbox Elite Series 2 | VID 0x045e | PID 0x0b00
Axes
| Field | Code | Min | Max | Fuzz | Flat |
|---|---|---|---|---|---|
lt | ABS_Z | 0 | 255 | 0 | 0 |
left_x | ABS_X | -32768 | 32767 | 16 | 128 |
rt | ABS_RZ | 0 | 255 | 0 | 0 |
right_x | ABS_RX | -32768 | 32767 | 16 | 128 |
left_y | ABS_Y | -32768 | 32767 | 16 | 128 |
right_y | ABS_RY | -32768 | 32767 | 16 | 128 |
Buttons
| Button | Event Code |
|---|---|
M2 | BTN_TRIGGER_HAPPY2 |
M1 | BTN_TRIGGER_HAPPY1 |
B | BTN_EAST |
LS | BTN_THUMBL |
RS | BTN_THUMBR |
X | BTN_WEST |
LB | BTN_TL |
RB | BTN_TR |
A | BTN_SOUTH |
Select | BTN_SELECT |
Home | BTN_MODE |
Start | BTN_START |
Y | BTN_NORTH |
M3 | BTN_TRIGGER_HAPPY3 |
M4 | BTN_TRIGGER_HAPPY4 |
Force feedback: type=rumble, max_effects=4
Nintendo Switch Pro Controller
VID:PID 0x057e:0x2009
Vendor nintendo
Interfaces
| ID | Class | EP IN | EP OUT |
|---|---|---|---|
| 0 | hid | — | — |
Report: bt_standard (49 bytes, interface 0)
Match: byte[0] = 0x30
Fields
| Name | Offset | Type | Transform |
|---|---|---|---|
left_y_raw | 7 | u8 | — |
right_x_raw | 9 | u8 | — |
right_y_raw | 10 | u8 | — |
left_x_raw | 6 | u8 | — |
Button Map
Source: offset 3, size 3 byte(s)
| Button | Bit Index |
|---|---|
Capture | 13 |
LT | 23 |
DPadUp | 17 |
RT | 7 |
DPadRight | 18 |
B | 2 |
LS | 11 |
RS | 10 |
X | 1 |
DPadDown | 16 |
DPadLeft | 19 |
LB | 22 |
RB | 6 |
A | 3 |
Select | 8 |
Home | 12 |
Start | 9 |
Y | 0 |
Output Capabilities
uinput device name: Nintendo Switch Pro Controller | VID 0x057e | PID 0x2009
Axes
| Field | Code | Min | Max | Fuzz | Flat |
|---|---|---|---|---|---|
left_x | ABS_X | -32768 | 32767 | 16 | 128 |
right_x | ABS_RX | -32768 | 32767 | 16 | 128 |
left_y | ABS_Y | -32768 | 32767 | 16 | 128 |
right_y | ABS_RY | -32768 | 32767 | 16 | 128 |
Buttons
| Button | Event Code |
|---|---|
Capture | BTN_MISC |
LT | BTN_TL2 |
RT | BTN_TR2 |
B | BTN_SOUTH |
LS | BTN_THUMBL |
RS | BTN_THUMBR |
X | BTN_NORTH |
LB | BTN_TL |
RB | BTN_TR |
A | BTN_EAST |
Select | BTN_SELECT |
Home | BTN_MODE |
Start | BTN_START |
Y | BTN_WEST |
Sony DualSense
VID:PID 0x054c:0x0ce6
Vendor sony
Interfaces
| ID | Class | EP IN | EP OUT |
|---|---|---|---|
| 3 | hid | — | — |
Report: usb (64 bytes, interface 3)
Match: byte[0] = 0x01
Fields
| Name | Offset | Type | Transform |
|---|---|---|---|
touch1_contact | 37 | u8 | — |
accel_x | 22 | i16le | — |
sensor_timestamp | 28 | u32le | — |
left_x | 1 | u8 | scale(-32768, 32767) |
touch0_contact | 33 | u8 | — |
lt | 5 | u8 | — |
accel_y | 24 | i16le | — |
accel_z | 26 | i16le | — |
gyro_z | 20 | i16le | — |
gyro_y | 18 | i16le | — |
battery_level | bits[53,0,4] | unsigned | — |
right_x | 3 | u8 | scale(-32768, 32767) |
gyro_x | 16 | i16le | — |
rt | 6 | u8 | — |
left_y | 2 | u8 | scale(-32768, 32767), negate |
right_y | 4 | u8 | scale(-32768, 32767), negate |
Button Map
Source: offset 8, size 3 byte(s)
| Button | Bit Index |
|---|---|
LT | 10 |
Mic | 18 |
RT | 11 |
B | 6 |
LS | 14 |
RS | 15 |
X | 4 |
LB | 8 |
RB | 9 |
A | 5 |
Select | 12 |
Home | 16 |
Start | 13 |
Y | 7 |
TouchPad | 17 |
Report: bt (78 bytes, interface 3)
Match: byte[0] = 0x31
Fields
| Name | Offset | Type | Transform |
|---|---|---|---|
touch1_contact | 38 | u8 | — |
accel_x | 23 | i16le | — |
sensor_timestamp | 29 | u32le | — |
left_x | 2 | u8 | scale(-32768, 32767) |
touch0_contact | 34 | u8 | — |
lt | 6 | u8 | — |
accel_y | 25 | i16le | — |
accel_z | 27 | i16le | — |
gyro_z | 21 | i16le | — |
gyro_y | 19 | i16le | — |
battery_level | bits[54,0,4] | unsigned | — |
right_x | 4 | u8 | scale(-32768, 32767) |
gyro_x | 17 | i16le | — |
rt | 7 | u8 | — |
left_y | 3 | u8 | scale(-32768, 32767), negate |
right_y | 5 | u8 | scale(-32768, 32767), negate |
Button Map
Source: offset 9, size 3 byte(s)
| Button | Bit Index |
|---|---|
LT | 10 |
Mic | 18 |
RT | 11 |
B | 6 |
LS | 14 |
RS | 15 |
X | 4 |
LB | 8 |
RB | 9 |
A | 5 |
Select | 12 |
Home | 16 |
Start | 13 |
Y | 7 |
TouchPad | 17 |
Commands
| Name | Interface | Template |
|---|---|---|
led | 3 | 02 00 04 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 ... |
rumble | 3 | 02 01 00 {weak:u8} {strong:u8} 00 00 00 00 00 00 00 00 00 00... |
adaptive_trigger_feedback | 3 | 02 0c 00 00 00 00 00 00 00 00 00 01 {r_position:u8} {r_stren... |
adaptive_trigger_vibration | 3 | 02 0c 00 00 00 00 00 00 00 00 00 06 {r_position:u8} {r_ampli... |
adaptive_trigger_weapon | 3 | 02 0c 00 00 00 00 00 00 00 00 00 02 {r_start:u8} {r_end:u8} ... |
adaptive_trigger_off | 3 | 02 0c 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 ... |
Output Capabilities
uinput device name: Sony DualSense | VID 0x054c | PID 0x0ce6
Axes
| Field | Code | Min | Max | Fuzz | Flat |
|---|---|---|---|---|---|
lt | ABS_Z | 0 | 255 | 0 | 0 |
left_x | ABS_X | -32768 | 32767 | 16 | 128 |
rt | ABS_RZ | 0 | 255 | 0 | 0 |
right_x | ABS_RX | -32768 | 32767 | 16 | 128 |
left_y | ABS_Y | -32768 | 32767 | 16 | 128 |
right_y | ABS_RY | -32768 | 32767 | 16 | 128 |
Buttons
| Button | Event Code |
|---|---|
Mic | BTN_MISC |
B | BTN_EAST |
LS | BTN_THUMBL |
RS | BTN_THUMBR |
X | BTN_WEST |
LB | BTN_TL |
RB | BTN_TR |
A | BTN_SOUTH |
Select | BTN_SELECT |
Home | BTN_MODE |
Start | BTN_START |
Y | BTN_NORTH |
TouchPad | BTN_TOUCH |
Force feedback: type=rumble, max_effects=16
Sony DualShock 4
VID:PID 0x054c:0x05c4
Vendor sony
Interfaces
| ID | Class | EP IN | EP OUT |
|---|---|---|---|
| 0 | hid | — | — |
Report: usb (64 bytes, interface 0)
Match: byte[0] = 0x01
Fields
| Name | Offset | Type | Transform |
|---|---|---|---|
touch1_contact | 37 | u8 | — |
accel_x | 20 | i16le | — |
left_x | 1 | u8 | scale(-32768, 32767) |
touch0_contact | 33 | u8 | — |
lt | 9 | u8 | — |
accel_y | 22 | i16le | — |
accel_z | 24 | i16le | — |
gyro_z | 18 | i16le | — |
gyro_x | 14 | i16le | — |
battery_level | bits[30,0,4] | unsigned | — |
right_x | 3 | u8 | scale(-32768, 32767) |
rt | 10 | u8 | — |
gyro_y | 16 | i16le | — |
left_y | 2 | u8 | scale(-32768, 32767), negate |
right_y | 4 | u8 | scale(-32768, 32767), negate |
Button Map
Source: offset 5, size 3 byte(s)
| Button | Bit Index |
|---|---|
LT | 10 |
RT | 11 |
B | 6 |
LS | 14 |
RS | 15 |
X | 4 |
LB | 8 |
RB | 9 |
A | 5 |
Select | 12 |
Home | 16 |
Start | 13 |
Y | 7 |
TouchPad | 17 |
Report: bt (78 bytes, interface 0)
Match: byte[0] = 0x11
Fields
| Name | Offset | Type | Transform |
|---|---|---|---|
touch1_contact | 39 | u8 | — |
accel_x | 22 | i16le | — |
left_x | 3 | u8 | scale(-32768, 32767) |
touch0_contact | 35 | u8 | — |
lt | 11 | u8 | — |
accel_y | 24 | i16le | — |
accel_z | 26 | i16le | — |
gyro_z | 20 | i16le | — |
gyro_x | 16 | i16le | — |
battery_level | bits[32,0,4] | unsigned | — |
right_x | 5 | u8 | scale(-32768, 32767) |
rt | 12 | u8 | — |
gyro_y | 18 | i16le | — |
left_y | 4 | u8 | scale(-32768, 32767), negate |
right_y | 6 | u8 | scale(-32768, 32767), negate |
Button Map
Source: offset 7, size 3 byte(s)
| Button | Bit Index |
|---|---|
LT | 10 |
RT | 11 |
B | 6 |
LS | 14 |
RS | 15 |
X | 4 |
LB | 8 |
RB | 9 |
A | 5 |
Select | 12 |
Home | 16 |
Start | 13 |
Y | 7 |
TouchPad | 17 |
Commands
| Name | Interface | Template |
|---|---|---|
led | 0 | 05 ff 00 00 00 00 {r:u8} {g:u8} {b:u8} 00 00 00 00 00 00 00 ... |
rumble | 0 | 05 ff 00 00 {weak:u8} {strong:u8} 00 00 00 00 00 00 00 00 00... |
Output Capabilities
uinput device name: Sony DualShock 4 | VID 0x054c | PID 0x05c4
Axes
| Field | Code | Min | Max | Fuzz | Flat |
|---|---|---|---|---|---|
lt | ABS_Z | 0 | 255 | 0 | 0 |
left_x | ABS_X | -32768 | 32767 | 16 | 128 |
rt | ABS_RZ | 0 | 255 | 0 | 0 |
right_x | ABS_RX | -32768 | 32767 | 16 | 128 |
left_y | ABS_Y | -32768 | 32767 | 16 | 128 |
right_y | ABS_RY | -32768 | 32767 | 16 | 128 |
Buttons
| Button | Event Code |
|---|---|
LS | BTN_THUMBL |
RS | BTN_THUMBR |
X | BTN_WEST |
B | BTN_EAST |
LB | BTN_TL |
RB | BTN_TR |
A | BTN_SOUTH |
Select | BTN_SELECT |
Home | BTN_MODE |
Start | BTN_START |
Y | BTN_NORTH |
TouchPad | BTN_TOUCH |
Force feedback: type=rumble, max_effects=16
Sony DualShock 4 v2
VID:PID 0x054c:0x09cc
Vendor sony
Interfaces
| ID | Class | EP IN | EP OUT |
|---|---|---|---|
| 0 | hid | — | — |
Report: usb (64 bytes, interface 0)
Match: byte[0] = 0x01
Fields
| Name | Offset | Type | Transform |
|---|---|---|---|
touch1_contact | 37 | u8 | — |
accel_x | 20 | i16le | — |
left_x | 1 | u8 | scale(-32768, 32767) |
touch0_contact | 33 | u8 | — |
lt | 9 | u8 | — |
accel_y | 22 | i16le | — |
accel_z | 24 | i16le | — |
gyro_z | 18 | i16le | — |
gyro_x | 14 | i16le | — |
battery_level | bits[30,0,4] | unsigned | — |
right_x | 3 | u8 | scale(-32768, 32767) |
rt | 10 | u8 | — |
gyro_y | 16 | i16le | — |
left_y | 2 | u8 | scale(-32768, 32767), negate |
right_y | 4 | u8 | scale(-32768, 32767), negate |
Button Map
Source: offset 5, size 3 byte(s)
| Button | Bit Index |
|---|---|
LT | 10 |
RT | 11 |
B | 6 |
LS | 14 |
RS | 15 |
X | 4 |
LB | 8 |
RB | 9 |
A | 5 |
Select | 12 |
Home | 16 |
Start | 13 |
Y | 7 |
TouchPad | 17 |
Report: bt (78 bytes, interface 0)
Match: byte[0] = 0x11
Fields
| Name | Offset | Type | Transform |
|---|---|---|---|
touch1_contact | 39 | u8 | — |
accel_x | 22 | i16le | — |
left_x | 3 | u8 | scale(-32768, 32767) |
touch0_contact | 35 | u8 | — |
lt | 11 | u8 | — |
accel_y | 24 | i16le | — |
accel_z | 26 | i16le | — |
gyro_z | 20 | i16le | — |
gyro_x | 16 | i16le | — |
battery_level | bits[32,0,4] | unsigned | — |
right_x | 5 | u8 | scale(-32768, 32767) |
rt | 12 | u8 | — |
gyro_y | 18 | i16le | — |
left_y | 4 | u8 | scale(-32768, 32767), negate |
right_y | 6 | u8 | scale(-32768, 32767), negate |
Button Map
Source: offset 7, size 3 byte(s)
| Button | Bit Index |
|---|---|
LT | 10 |
RT | 11 |
B | 6 |
LS | 14 |
RS | 15 |
X | 4 |
LB | 8 |
RB | 9 |
A | 5 |
Select | 12 |
Home | 16 |
Start | 13 |
Y | 7 |
TouchPad | 17 |
Commands
| Name | Interface | Template |
|---|---|---|
led | 0 | 05 ff 00 00 00 00 {r:u8} {g:u8} {b:u8} 00 00 00 00 00 00 00 ... |
rumble | 0 | 05 ff 00 00 {weak:u8} {strong:u8} 00 00 00 00 00 00 00 00 00... |
Output Capabilities
uinput device name: Sony DualShock 4 v2 | VID 0x054c | PID 0x09cc
Axes
| Field | Code | Min | Max | Fuzz | Flat |
|---|---|---|---|---|---|
lt | ABS_Z | 0 | 255 | 0 | 0 |
left_x | ABS_X | -32768 | 32767 | 16 | 128 |
rt | ABS_RZ | 0 | 255 | 0 | 0 |
right_x | ABS_RX | -32768 | 32767 | 16 | 128 |
left_y | ABS_Y | -32768 | 32767 | 16 | 128 |
right_y | ABS_RY | -32768 | 32767 | 16 | 128 |
Buttons
| Button | Event Code |
|---|---|
LS | BTN_THUMBL |
RS | BTN_THUMBR |
X | BTN_WEST |
B | BTN_EAST |
LB | BTN_TL |
RB | BTN_TR |
A | BTN_SOUTH |
Select | BTN_SELECT |
Home | BTN_MODE |
Start | BTN_START |
Y | BTN_NORTH |
TouchPad | BTN_TOUCH |
Force feedback: type=rumble, max_effects=16
Valve Steam Deck
VID:PID 0x28de:0x1205
Vendor valve
Interfaces
| ID | Class | EP IN | EP OUT |
|---|---|---|---|
| 0 | hid | — | — |
Report: main (64 bytes, interface 0)
Match: byte[1] = 0x09
Fields
| Name | Offset | Type | Transform |
|---|---|---|---|
touch1_y | 22 | i16le | — |
touch0_y | 18 | i16le | — |
accel_x | 24 | i16le | — |
touch0_x | 16 | i16le | — |
left_x | 48 | i16le | — |
lt | 44 | u16le | scale(0, 255) |
touch1_x | 20 | i16le | — |
touch1_active | bits[10,4,1] | unsigned | — |
touch0_active | bits[10,3,1] | unsigned | — |
accel_y | 26 | i16le | — |
accel_z | 28 | i16le | — |
gyro_z | 34 | i16le | — |
gyro_x | 30 | i16le | — |
gyro_y | 32 | i16le | — |
right_x | 52 | i16le | — |
rt | 46 | u16le | scale(0, 255) |
left_y | 50 | i16le | negate |
right_y | 54 | i16le | negate |
Button Map
Source: offset 8, size 4 byte(s)
| Button | Bit Index |
|---|---|
M2 | 13 |
LT | 1 |
M1 | 12 |
DPadUp | 8 |
RT | 0 |
DPadRight | 9 |
B | 5 |
LS | 3 |
RS | 2 |
X | 6 |
DPadLeft | 10 |
DPadDown | 11 |
A | 7 |
Home | 17 |
Select | 18 |
M4 | 15 |
Y | 4 |
M3 | 14 |
Start | 16 |
Commands
| Name | Interface | Template |
|---|---|---|
rumble | 0 | 8f 00 {strong:u8} 00 00 10 00 01 00... |
Output Capabilities
uinput device name: Valve Steam Deck | VID 0x28de | PID 0x1205
Axes
| Field | Code | Min | Max | Fuzz | Flat |
|---|---|---|---|---|---|
lt | ABS_Z | 0 | 255 | 0 | 0 |
left_x | ABS_X | -32768 | 32767 | 16 | 128 |
rt | ABS_RZ | 0 | 255 | 0 | 0 |
right_x | ABS_RX | -32768 | 32767 | 16 | 128 |
left_y | ABS_Y | -32768 | 32767 | 16 | 128 |
right_y | ABS_RY | -32768 | 32767 | 16 | 128 |
Buttons
| Button | Event Code |
|---|---|
M2 | BTN_TRIGGER_HAPPY2 |
LT | BTN_TL2 |
M1 | BTN_TRIGGER_HAPPY1 |
RT | BTN_TR2 |
B | BTN_EAST |
LS | BTN_THUMBL |
RS | BTN_THUMBR |
X | BTN_WEST |
A | BTN_SOUTH |
Select | BTN_SELECT |
Home | BTN_MODE |
Start | BTN_START |
Y | BTN_NORTH |
M3 | BTN_TRIGGER_HAPPY3 |
M4 | BTN_TRIGGER_HAPPY4 |
Force feedback: type=rumble, max_effects=4
Contributing
There are several ways to contribute to padctl:
Guides
- Device Config Guide — Write a device TOML config from capture data to working controller
- HID Reverse Engineering Guide — Identify, capture, and analyze a gamepad's HID protocol with Wireshark and raw hex tools
- Reference Tables — Type mapping, MSB0→LSB0 conversion, ButtonId enum, Linux event codes, transform DSL
- Code Contributions — Fork workflow, code style, test commands, build flags
- Device TOML from InputPlumber — Convert InputPlumber configs to padctl format
- Release Process — Version bump, tag, release workflow, artifact, and package checks
Device Config Guide
This guide covers writing a padctl TOML device config from your HID capture analysis. For how to capture and analyze HID reports, see the Reverse Engineering Guide.
Adding a new device requires only one file: devices/<vendor>/<device>.toml. No source code changes needed.
TOML Config Structure
With your analysis complete, translate it to padctl format:
[device]
name = "Acme Gamepad Pro"
vid = 0x1234
pid = 0x5678
[[device.interface]]
id = 0 # from HID_PHYS output
class = "hid"
[[report]]
name = "usb"
interface = 0
size = 64 # report byte count
[report.match]
offset = 0
expect = [0x01] # report ID
[report.fields]
left_x = { offset = 1, type = "u8", transform = "scale(-32768, 32767)" }
left_y = { offset = 2, type = "u8", transform = "scale(-32768, 32767), negate" }
right_x = { offset = 3, type = "u8", transform = "scale(-32768, 32767)" }
right_y = { offset = 4, type = "u8", transform = "scale(-32768, 32767), negate" }
lt = { offset = 5, type = "u8" }
rt = { offset = 6, type = "u8" }
[report.button_group]
source = { offset = 8, size = 3 }
map = { X = 4, A = 5, B = 6, Y = 7, LB = 8, RB = 9, LT = 10, RT = 11, Select = 12, Start = 13, LS = 14, RS = 15, Home = 16 }
[output]
name = "Acme Gamepad Pro"
vid = 0x1234
pid = 0x5678
[output.axes]
left_x = { code = "ABS_X", min = -32768, max = 32767, fuzz = 16, flat = 128 }
left_y = { code = "ABS_Y", min = -32768, max = 32767, fuzz = 16, flat = 128 }
right_x = { code = "ABS_RX", min = -32768, max = 32767, fuzz = 16, flat = 128 }
right_y = { code = "ABS_RY", min = -32768, max = 32767, fuzz = 16, flat = 128 }
lt = { code = "ABS_Z", min = 0, max = 255 }
rt = { code = "ABS_RZ", min = 0, max = 255 }
[output.buttons]
A = "BTN_SOUTH"
B = "BTN_EAST"
X = "BTN_WEST"
Y = "BTN_NORTH"
LB = "BTN_TL"
RB = "BTN_TR"
Select = "BTN_SELECT"
Start = "BTN_START"
Home = "BTN_MODE"
LS = "BTN_THUMBL"
RS = "BTN_THUMBR"
[output.dpad]
type = "hat"
Key Decisions
Y axis negate: HID reports almost always use +Y = down. padctl convention negates Y axes. Always add negate to Y axis transforms.
Axis type and transform:
| Raw type | Transform needed |
|---|---|
u8 centered at 0x80 | scale(-32768, 32767) |
i8 centered at 0 | scale(-32768, 32767) |
i16le centered at 0 | none (already full range) |
u8 trigger (0-255) | none |
Output emulation: For maximum game compatibility, emulate Xbox Elite Series 2 (vid = 0x045e, pid = 0x0b00). See devices/flydigi/vader5.toml for an example. If the device is well-known (like DualSense), use its real VID/PID.
Multiple Report Types
Some gamepads send different report IDs for different data:
- Report
0x01= buttons and axes - Report
0x02= touchpad data - Report
0x11= IMU data
Each needs its own [[report]] block. Use [report.match] to disambiguate:
[[report]]
name = "gamepad"
interface = 0
size = 32
[report.match]
offset = 0
expect = [0x01]
[[report]]
name = "imu"
interface = 0
size = 16
[report.match]
offset = 0
expect = [0x02]
Bluetooth vs USB
The same device often has different report formats over Bluetooth:
- Extra header byte(s): all USB offsets shift by 1 or 2 (see DualSense BT: +1 offset)
- Different report ID: DualSense USB =
0x01, BT extended =0x31 - Checksum appended: DualSense BT has CRC32 at the end, USB does not
- Different report size: DualSense USB = 64 bytes, BT = 78 bytes
You need separate [[report]] blocks for each. See devices/sony/dualsense.toml for a dual USB/BT config.
Test and Iterate
# Parse check — does the config load without errors?
padctl-debug --config devices/vendor/model.toml
# Live test — run padctl and verify with evtest
padctl --config devices/vendor/model.toml &
evtest /dev/input/eventNN
What to verify:
- Each axis moves full range (min to max) and centers correctly
- No axis is inverted (push right = positive value)
- Every button triggers the correct event
- D-pad works in all 8 directions
- Triggers ramp smoothly from 0 to max
Common issues:
- Axis inverted: add or remove
negatein the transform - Axis stuck at 0: wrong offset — recheck your capture analysis
- Wrong buttons fire: bit index is off — recount from the button_group source offset
- Garbage data: wrong report ID or wrong interface
Validation and Submission
-
Validate locally:
zig build && ./zig-out/bin/padctl --validate devices/<vendor>/<model>.tomlExit 0 = valid. Exit 1 = validation errors. Exit 2 = file not found or parse failure.
-
Test: Run
zig build test— the test framework auto-discovers all.tomlfiles indevices/. -
Submit: Open a pull request. CI runs the same auto-discovery tests automatically.
Directory Layout
devices/
├── 8bitdo/ 8BitDo (Ultimate Controller)
├── flydigi/ Flydigi (Vader 4 Pro, Vader 5 Pro)
├── hori/ HORI (Horipad Steam)
├── lenovo/ Lenovo (Legion Go, Legion Go S)
├── microsoft/ Microsoft (Xbox Elite Series 2)
├── nintendo/ Nintendo (Switch Pro Controller)
├── sony/ Sony (DualSense, DualShock 4, DualShock 4 v2)
└── valve/ Valve (Steam Deck)
Add a new vendor directory if the manufacturer is not listed.
HID Reverse Engineering Guide
This guide walks through reverse engineering a gamepad's HID protocol from scratch. No prior HID experience needed — just basic hex literacy. Once you have identified all fields, proceed to the Device Config Guide to write the TOML config.
Prerequisites
Install these tools before starting:
# Wireshark + USB monitor kernel module
sudo pacman -S wireshark-qt # or apt install wireshark
sudo modprobe usbmon
# Raw hex tools (usually pre-installed)
which xxd hexdump
# Input device testing
sudo pacman -S evtest # or apt install evtest
# padctl's own capture tool
padctl-capture --help
You need read access to /dev/hidraw* and /dev/usbmon*. Either run as root or add your user to the appropriate groups:
sudo usermod -aG input $USER # for hidraw
sudo usermod -aG wireshark $USER
Step 1: Identify the Device
Plug in your gamepad and find it:
$ lsusb
Bus 001 Device 012: ID 054c:0ce6 Sony Corp. DualSense Wireless Controller
The hex pair 054c:0ce6 is your VID:PID. Write these down — they go directly into the TOML config.
Find the hidraw node
$ ls /dev/hidraw*
/dev/hidraw0 /dev/hidraw1 /dev/hidraw2 /dev/hidraw3
$ cat /sys/class/hidraw/hidraw3/device/uevent
HID_ID=0003:0000054C:00000CE6
HID_NAME=Sony Interactive Entertainment Wireless Controller
HID_PHYS=usb-0000:08:00.3-2/input3
The HID_ID confirms VID/PID. The input3 at the end of HID_PHYS tells you this is interface 3.
Multiple interfaces
Many devices expose several USB interfaces. A DualSense has interfaces 0-3 (audio + HID). You need the one that carries gamepad data. Quick way to find it:
# Read a few bytes from each hidraw node while pressing buttons
for i in /dev/hidraw*; do
echo "=== $i ==="
sudo timeout 1 xxd -l 64 -c 32 "$i" 2>/dev/null || echo "(no data)"
done
The node that produces continuous output when you press buttons or move sticks is your target.
Step 2: Capture Raw HID Reports
Method 1: padctl-capture (recommended)
Discover by VID:PID — padctl-capture picks the first matching VID:PID hidraw
node (by ascending node index, regardless of interface number). For a
composite device whose interface-0 node is not the lowest hidraw index this may
select a different interface; pass --interface N to force a specific one:
sudo padctl-capture --vid 0x054c --pid 0x0ce6 --duration 30 --output capture.toml
Or open a specific node directly:
padctl-capture --device /dev/hidraw3 --duration 30 --output capture.toml
If a device exposes multiple HID interfaces and you want to target a specific one, use --interface N:
sudo padctl-capture --vid 0x054c --pid 0x0ce6 --interface 3 --duration 30 --output capture.toml
While capturing, do each action one at a time with a pause between:
- Leave controller idle for 3 seconds (this is your baseline)
- Press and release each face button (A, B, X, Y) one at a time
- Press and release each shoulder button (LB, RB, LT, RT)
- Move left stick to full left, full right, full up, full down
- Move right stick the same way
- Press each D-pad direction
- Press Start, Select, Home
Write down the order and approximate timing. You will cross-reference this with the capture data.
Method 2: Wireshark USB capture
sudo modprobe usbmon
Open Wireshark, select the usbmonN interface matching your USB bus (from lsusb output). Apply this display filter:
usb.transfer_type == 0x01 && usb.dst == "host"
This shows only interrupt IN transfers (device-to-host) — which is how gamepads send input reports.
Start capture, perform the same systematic button/axis sequence, then stop.
Method 3: Quick and dirty with xxd
For a fast look without any special tools:
sudo xxd -c 64 -g 1 /dev/hidraw3 | head -20
This prints raw reports in hex as they arrive. Move a stick or press a button to see bytes change.
Step 3: Analyze the Protocol
This is the core skill. You are looking at raw bytes and figuring out what each one means.
Determine report size and report ID
Look at the raw data. Every read from hidraw returns one complete report. Check the length — common sizes are 10, 20, 32, 49, 64, or 78 bytes.
If the first byte is constant across all reports, it is likely a report ID. For example, DualSense USB reports always start with 0x01:
01 80 80 80 80 00 00 08 00 00 ...
^^
Report ID 0x01
Some devices (like Flydigi Vader 5) use multi-byte magic headers:
5a a5 ef 00 00 00 00 00 00 ...
^^^^^^^^
3-byte magic prefix
Find the idle baseline
With nothing pressed and sticks centered, capture several reports. This is your baseline:
Idle DualSense USB report (64 bytes):
01 80 80 80 80 00 00 08 00 00 00 00 00 00 00 00
00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
Note bytes 1-4 are 80 80 80 80 — that is four axes centered at 0x80 (128).
Identify analog axes
Move only the left stick fully left, and compare with idle:
Idle: 01 [80] 80 80 80 00 00 08 ...
Left full: 01 [00] 80 80 80 00 00 08 ...
^^
Byte 1 changed: 0x80 → 0x00
Now fully right:
Right full: 01 [ff] 80 80 80 00 00 08 ...
^^
Byte 1: 0x80 → 0xFF
This tells you:
- Byte 1 = left stick X axis
- Type:
u8(unsigned, 0x00 = left, 0x80 = center, 0xFF = right) - Needs
transform = "scale(-32768, 32767)"to map to standard axis range
Repeat for left stick Y (byte 2), right stick X (byte 3), right stick Y (byte 4).
How to tell u8 vs i16le:
| Pattern | Type | Center | Range |
|---|---|---|---|
Single byte, idle = 0x80 | u8 | 128 | 0-255 |
Single byte, idle = 0x00 | i8 | 0 | -128 to 127 |
Two bytes, idle = 0x00 0x00 | i16le | 0 | -32768 to 32767 |
Two bytes, idle = 0x00 0x80 | u16le centered | 32768 | 0-65535 |
For i16le, you will see two adjacent bytes change together. Move the stick fully right:
8BitDo Ultimate (i16le axes):
Idle: 01 [00 00] [00 00] [00 00] [00 00] ...
Right full: 01 [ff 7f] [00 00] [00 00] [00 00] ...
^^^^^
0x7FFF = 32767 in little-endian = i16le max
Identify triggers
Triggers are usually u8 (0 = released, 0xFF = fully pressed). Slowly squeeze a trigger and watch which byte ramps from 0x00 to 0xFF:
LT released: ... 00 00 08 ...
LT half: ... 80 00 08 ...
LT full: ... ff 00 08 ...
^^
Byte 5 = LT, type u8
Identify buttons
Press one button at a time and XOR with the idle frame to find changed bits:
Idle byte 8: 08 = 0000 1000
Press Cross: 28 = 0010 1000
XOR: 20 = 0010 0000 → bit 5 changed
So the Cross/A button is bit 5 of byte 8.
Do this for every button. Build a table:
| Button | Byte | Bit (in byte) | Bit (in group) |
|---|---|---|---|
| Square/X | 8 | 4 | 4 |
| Cross/A | 8 | 5 | 5 |
| Circle/B | 8 | 6 | 6 |
| Triangle/Y | 8 | 7 | 7 |
| L1/LB | 9 | 0 | 8 |
| R1/RB | 9 | 1 | 9 |
| L3/LS | 9 | 6 | 14 |
| R3/RS | 9 | 7 | 15 |
The "bit in group" is calculated from the button_group source offset. If source = { offset = 8, size = 3 }, then bit indices are: byte 8 bits 0-7, byte 9 bits 8-15, byte 10 bits 16-23.
Identify D-pad
D-pads come in two flavors:
Hat switch (most common): A single nibble (4 bits) encodes direction as a number 0-8:
0=N 1=NE 2=E 3=SE 4=S 5=SW 6=W 7=NW 8=neutral
Look for a nibble in the button bytes that cycles through these values as you press D-pad directions. On DualSense, bits [3:0] of byte 8 are the hat:
Idle: 08 (1000) → hat = 8 (neutral)
Up: 00 (0000) → hat = 0 (north)
Right: 02 (0010) → hat = 2 (east)
Down: 04 (0100) → hat = 4 (south)
Left: 06 (0110) → hat = 6 (west)
Button bits: Four separate bits, one for each direction. Flydigi Vader 5 uses this:
map = { DPadUp = 0, DPadRight = 1, DPadDown = 2, DPadLeft = 3, ... }
Spot checksums
If the last 1-4 bytes change with every report even when nothing else changes, that is likely a checksum or sequence counter. DualSense Bluetooth has a CRC32 in the last 4 bytes:
Report bytes 74-77 change every frame, even when idle
→ CRC32 checksum over bytes 0-73
A single byte that increments by 1 each report is a sequence counter (common, usually ignored).
Tips and Tricks
Compare with similar devices
Devices from the same vendor often share report layouts. DualShock 4 and DualSense share the same structure with minor offset shifts (see devices/sony/dualshock4.toml vs devices/sony/dualsense.toml). If your device is a newer revision of a known one, start from the existing config and adjust offsets.
Finding output commands (rumble, LED)
In Wireshark, look for host-to-device interrupt or control transfers:
usb.transfer_type == 0x01 && usb.dst != "host"
Or look for SET_REPORT control transfers:
usb.setup.bRequest == 0x09
Trigger rumble from another driver or app and capture the outgoing bytes. The structure is usually: report ID + flags + motor values + padding.
Vendor-specific magic
Some devices (like Flydigi Vader 5) require an init sequence to enter extended mode. Signs that you need this:
- Reports are very short (< 10 bytes) and missing axes
- Reports change format after you send a specific command
- A reference driver sends a series of vendor commands on open
Look at how existing Linux drivers handle the device. Protocol facts (byte sequences, report formats) are not copyrightable (Feist v. Rural, 1991) — you may freely use byte offsets, field types, VID/PID, and bit positions found in any open-source driver. Do not copy source code or comment text verbatim.
Device TOML from InputPlumber
Reference Tables
Quick reference for writing padctl device configs.
Type Mapping
| Common HID representation | padctl type |
|---|---|
| unsigned byte | "u8" |
| signed byte | "i8" |
| 16-bit unsigned, little-endian | "u16le" |
| 16-bit signed, little-endian | "i16le" |
| 16-bit unsigned, big-endian | "u16be" |
| 16-bit signed, big-endian | "i16be" |
| single bit (boolean) | button_group entry |
| multi-bit enum (e.g. hat switch) | button_group per variant or hat field |
Axis Transform
| Raw type | Transform needed |
|---|---|
u8 centered at 0x80 | scale(-32768, 32767) |
i8 centered at 0 | scale(-32768, 32767) |
i16le centered at 0 | none (already full range) |
u8 trigger (0-255) | none |
Linux Input Event Codes
| padctl button | Linux code | Notes |
|---|---|---|
| A | BTN_SOUTH | Cross on PlayStation |
| B | BTN_EAST | Circle on PlayStation |
| X | BTN_WEST | Square on PlayStation |
| Y | BTN_NORTH | Triangle on PlayStation |
| LB | BTN_TL | L1 |
| RB | BTN_TR | R1 |
| Select | BTN_SELECT | Share/Create/View |
| Start | BTN_START | Options/Menu |
| Home | BTN_MODE | PS/Xbox/Guide |
| LS | BTN_THUMBL | L3 (stick click) |
| RS | BTN_THUMBR | R3 (stick click) |
| M1-M4 | BTN_TRIGGER_HAPPY1-4 | Back paddles / extra buttons |
MSB0 to LSB0 Bit Conversion
Some HID documentation and driver source code number bits in MSB0 order (bit 0 = most-significant bit). padctl button_group indices use LSB0 (bit 0 = least-significant bit).
Single byte:
lsb_bit = 7 - msb_bit (msb_bit in 0..=7)
Multi-byte group (source window of N bytes):
lsb_bit = (msb_bit / 8) * 8 + (7 - (msb_bit % 8))
Example — a 2-byte button field starting at offset 0:
| Button | MSB0 bit | byte | bit-in-byte | LSB0 index |
|---|---|---|---|---|
| DPadRight | 0 | 0 | 7 | 7 |
| DPadLeft | 1 | 0 | 6 | 6 |
| DPadDown | 2 | 0 | 5 | 5 |
| DPadUp | 3 | 0 | 4 | 4 |
| L3 | 4 | 0 | 3 | 3 |
| R3 | 5 | 0 | 2 | 2 |
| Btn6 | 6 | 0 | 1 | 1 |
| Btn7 | 7 | 0 | 0 | 0 |
| A | 8 | 1 | 7 | 15 |
| B | 9 | 1 | 6 | 14 |
| X | 10 | 1 | 5 | 13 |
| Y | 11 | 1 | 4 | 12 |
| LB | 12 | 1 | 3 | 11 |
| RB | 13 | 1 | 2 | 10 |
| LT | 14 | 1 | 1 | 9 |
| RT | 15 | 1 | 0 | 8 |
Common Pitfalls
- MSB0 vs LSB0 bit order — many HID reference drivers use MSB0 bit numbering. Copying bit indices directly without converting will cause buttons to trigger on wrong inputs. Always apply the conversion formula.
- Padding bytes — if the report is larger than the sum of declared fields, the extra bytes are padding. Do not declare fields for those offsets.
- Multiple report IDs on one interface — each report ID needs its own
[[report]]block with a[report.match]section. Withoutmatch, padctl tries to parse every incoming buffer with every report definition. - Split / non-contiguous fields — some devices store a single logical value across non-adjacent bytes (e.g., gyro_y with low byte at offset 18 and high byte at offset 20). padctl does not support split fields; these require a WASM plugin or a firmware mode that provides a contiguous layout.
- Endianness of multi-byte scalars — when referencing driver source code, check whether multi-byte fields are little-endian or big-endian. The default in many packed-struct frameworks is big-endian. Use
"i16be"/"u16be"for big-endian fields. - Analog stick center value — devices use either
u8(center = 0x80) ori8(center = 0). Both benefit fromtransform = "scale(-32768, 32767)"to fill the full axis range expected by uinput.
Code Contributions
Workflow
- Fork the repository and create a feature branch
- Make your changes
- Run all checks before submitting
- Open a pull request
Code Style
All Zig code must pass zig fmt:
zig build check-fmt
Testing
# Run all tests (Layer 0+1, no privileges required)
zig build test
# Run all checks (test + safe + fmt)
zig build check-all
# Run ThreadSanitizer tests explicitly; the pre-push hook runs this by default.
zig build test-tsan
Build Flags
| Flag | Default | Description |
|---|---|---|
-Dlibusb=false | true | Disable libusb-1.0 linkage (hidraw-only path) |
-Dwasm=false | true | Disable WASM plugin runtime |
-Dtest-coverage=true | false | Run tests with kcov coverage |
CI Auto-Validation
zig build test automatically validates every device TOML in the repository:
- TOML parse + semantic validation: syntax correctness, field value legality
- FieldTag coverage: all field names map to known FieldTag values
- ButtonId coverage: all button_group keys are valid ButtonId enum values
- VID/PID validity: all device configs contain valid VID/PID
Test Fixtures Are Single-Source
Files in devices/ and examples/mappings/ are the canonical source of
truth for both the user-facing manual and the e2e test suite. End-to-end
tests under src/test/*_e2e_test.zig MUST consume these fixtures via
device_mod.parseFile(...) (or @embedFile for non-TOML payloads) rather
than declaring an inline TOML literal.
Inline literals drift away from the canonical files over time: a field
gets renamed, a transform is added, or a fixture grows a new
[output.imu] block, and the inline copy keeps testing the old shape.
PR #193 began retiring inline vader5_toml literals after exactly this
drift was observed (the inline copy had gyro_y / gyro_z swapped
relative to devices/flydigi/vader5.toml). The last remaining inline copy
in interpreter_e2e_test.zig was removed in PR #209; no inline device
literals remain in the test suite.
When you add a new e2e test, prefer one of these patterns:
// device-config-driven test
const parsed = try device_mod.parseFile(allocator, "devices/flydigi/vader5.toml");
// mapping-config-driven test
const parsed = try mapping_mod.parseFile(allocator, "examples/mappings/comprehensive.toml");
If the test genuinely needs a config shape that no shipped fixture
provides, add a new fixture under src/test/fixtures/ and consume it via
@embedFile — do not paste the TOML into the test source.
Release Process
padctl releases are driven by annotated v*.*.* tags. The tag version must match
build.zig.zon; the release workflow checks this before building artifacts.
Checklist
-
Update
build.zig.zonto the new version and merge that change tomain. -
Create an annotated tag on the exact
maincommit to release:git fetch origin main --tags git tag -a v0.1.9 origin/main -m "v0.1.9" git push origin v0.1.9 -
Watch the Release workflow. The successful run should include:
- musl tarballs for
x86_64-linux-muslandaarch64-linux-musl - versioned
.debpackages and latest aliases SHA256SUMS.txtverify-release-artifactupdate-aur
The AUR update job copies
contrib/aur/padctl-bin/PKGBUILDinto the AUR checkout before setting the version and hashes, so packaging layout fixes must be made in the repository template first. - musl tarballs for
-
Verify the GitHub release after the workflow completes:
gh release view v0.1.9 --json isDraft,isPrerelease,assets -
If a release upload step runs inside a container, every
gh releasecommand must pass--repo BANANASJIM/padctl. The container checkout may not provide enough git metadata forghto infer the repository. -
Do not rerun an old failed tag workflow after merging release workflow fixes. Move or recreate the annotated tag so the new workflow run executes at the fixed commit.