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-git
A prebuilt binary package (padctl-bin) is also available in the AUR.
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
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. UseDockerfile.wave5(Debian bookworm + Zig 0.15.2 tarball, glibc 2.36) 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.
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"
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 starts the daemon if not running, restarts it if failed, and re-applies the active mapping. 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. When run as root,padctl installalso 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 the
devices/warning, systemd 257+status=218/CAPABILITIES, and the Arch glibc 2.43 build failure.
Verify
padctl scan
Lists all connected HID devices and shows whether a matching device config was found for each.
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. 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.
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] [--preset] # interactive mapping creator
padctl config edit [name] # open mapping in $VISUAL/$EDITOR
padctl config test [--config] [--mapping] # live input preview
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 rule (60-padctl.rules) and a DeviceAllow=/dev/uhid rw entry in the systemd unit 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 logged-in user access to supported devices without requiring root.
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/system/ | /etc/systemd/system/ |
| Service drop-in | (not created) | /etc/systemd/system/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 drop-in override with these changes:
| Directive | Purpose |
|---|---|
DeviceAllow= | Resets device allowlist to permit all device access (see security note below) |
ProtectHome=read-only | Allows reading user mapping configs from ~/.config/padctl/mappings/ |
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 base service restricts device access to hidraw, uinput, and input devices. The immutable drop-in clears this allowlist because libusb needs USB bus nodes (/dev/bus/usb/) for vendor-specific control transfers (init commands, rumble, LED control). The specific char-usb_device cgroup class was tested and does not provide sufficient access. Standard (non-immutable) installs are not affected and retain the restrictive allowlist.
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/ 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
Copy the example and edit it:
mkdir -p ~/.config/padctl/mappings/
cp /usr/share/padctl/config/example-mapping.toml ~/.config/padctl/mappings/my-config.toml
$EDITOR ~/.config/padctl/mappings/my-config.toml
Or use the interactive creator:
padctl config init
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 -u padctl.service -n 30
Note: padctl --validate is for device configs only.
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 |
"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 |
Available button names: A, B, X, Y, LB, RB, LT, RT, Start, Select, LS, RS, M1, M2, M3, M4, LM, RM, C, Z
Gyroscope ([gyro])
Translates gyroscope motion to mouse movement. 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".
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.
Two activation modes:
"hold"— active while the trigger button is held"toggle"— press once to enter, press again to exit
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.
# "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"
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"
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, two layers (hold + toggle), 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), 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. Currently this section is not preserved by padctl dump enable/disable's rewrite — edit config.toml directly and run padctl reload to pick up changes.
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
Common runtime failures that have generated repeat issue reports, with diagnostics and workarounds.
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, use the provided Dockerfile.wave5 (Debian bookworm + Zig 0.15.2, glibc 2.36)
for reproducible builds.
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 at install time, e.g. block_kernel_drivers = ["xpad"]. 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 |
[[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.
| 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_forward/mouse_back, disabled, or macro:<name>.
[remap]
M1 = "KEY_F13"
M2 = "mouse_side"
M3 = "disabled"
A = "B"
M4 = "macro:dodge_roll"
[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" or "mouse" |
activate | string | — | Button name to hold for activation (e.g. "LS", "hold_RB") |
sensitivity | float | — | Overall sensitivity multiplier |
sensitivity_x | float | — | X-axis sensitivity override |
sensitivity_y | float | — | Y-axis sensitivity override |
deadzone | integer | — | Raw gyro deadzone threshold |
smoothing | float | — | Smoothing factor (0–1) |
curve | float | — | Acceleration curve exponent |
max_val | float | — | Maximum output value cap |
invert_x | bool | — | Invert X axis |
invert_y | bool | — | Invert Y axis |
[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_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) or "toggle" |
tap | string | no | Button/key emitted on short press (when using hold 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_timeout | integer | no | Hold detection threshold in ms (1–5000) |
[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].
[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 |
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. |
# 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 TOML configs via padctl --doc-gen.
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 | 19 | i16le | — |
gyro_x | 17 | i16le | — |
gyro_y | 21 | i16le | negate |
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 | 5a a5 12 06 {strong:u8} {weak:u8} 00 00 00 00 00 00 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_HAPPY7 |
M4 | BTN_TRIGGER_HAPPY8 |
M1 | BTN_TRIGGER_HAPPY5 |
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 |
O | BTN_TRIGGER_HAPPY9 |
Y | BTN_WEST |
M3 | BTN_TRIGGER_HAPPY6 |
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 1, size 3 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 |
|---|---|---|---|
| 2 | hid | — | — |
Report: main (64 bytes, interface 2)
Match: byte[2] = 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 8 byte(s)
| Button | Bit Index |
|---|---|
M2 | 16 |
LT | 1 |
M1 | 15 |
DPadUp | 8 |
RT | 0 |
DPadRight | 9 |
B | 5 |
DPadLeft | 10 |
DPadDown | 11 |
X | 6 |
LS | 22 |
RS | 26 |
LB | 3 |
RB | 2 |
A | 7 |
Select | 12 |
Home | 13 |
Start | 14 |
Y | 4 |
M3 | 41 |
M4 | 42 |
Commands
| Name | Interface | Template |
|---|---|---|
rumble | 2 | 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 |
LB | BTN_TL |
A | BTN_SOUTH |
RB | BTN_TR |
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
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 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)
padctl-capture --device /dev/hidraw3 --duration 30 --output capture.bin
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.
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 + tsan + safe + fmt)
zig build check-all
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.