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 switchpadctl 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=false to 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 .sframe sections to crt1.o startup objects, which Zig 0.15.x's linker does not yet handle. This is an upstream Zig limitation, not a padctl bug. Use Dockerfile.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 via block_kernel_drivers in device TOML configs. When run as root, padctl install also walks /sys/bus/usb/drivers/<driver>/unbind for 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):

  1. ~/.config/padctl/config.toml — user overrides (highest priority)
  2. /etc/padctl/config.toml — system-wide defaults (written by padctl 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:

  1. Detects immutable OS (checks for ostree or read-only /usr)
  2. Installs dependencies via Homebrew (Zig compiler, libusb) — no system reboot needed
  3. Clones and builds padctl from source with ReleaseSafe optimization
  4. Installs the daemon, systemd service, udev rules, and reconnect scripts
  5. Persists the selected mapping as a device binding in /etc/padctl/config.toml (auto-applies on every boot)
  6. Applies the mapping to the current session
  7. Verifies the installation

Safe to re-run for updates — it rebuilds and reinstalls while preserving your mapping configs in ~/.config/padctl/mappings/.

Script Options

FlagDescription
--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:

FileStandard (/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.service was 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:

DirectivePurpose
DeviceAllow=Resets device allowlist to permit all device access (see security note below)
ProtectHome=read-onlyAllows reading user mapping configs from ~/.config/padctl/mappings/
TimeoutStopSec=3Short stop timeout for processes stuck in uninterruptible I/O
KillMode=mixedSIGTERM 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:

LocationPriorityEditable without sudo
~/.config/padctl/mappings/First (highest)Yes
/etc/padctl/mappings/SecondNo (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):

  1. ~/.config/padctl/mappings/ — user overrides
  2. /etc/padctl/mappings/ — system-wide profiles
  3. /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:

  1. Apply the mapping at runtime (same as without --persist)
  2. Save to your user config (same as without --persist)
  3. Prompt for confirmation, then ask for your sudo password
  4. Copy the mapping file to /etc/padctl/mappings/
  5. 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:

  • --persist is not yet supported with --device (multi-controller setups). In multi-device sessions, auto-save and bare padctl switch resolve against the first connected device. Use padctl 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:

  1. ~/.config/padctl/config.toml — user overrides (highest priority, only available when HOME is set)
  2. /etc/padctl/config.toml — system-wide defaults (written by padctl install --mapping or padctl 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:

ValueEffect
"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" },
]
StepDescription
{ 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_threshold must be at the top level of the mapping file. Placing it inside [[layer]] is silently ignored. To use LT / RT as 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

CommandDescription
padctl dump enableTurn 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 disableTurn diagnostic logging off (default state). Same persistence semantics as enable.
padctl dump statusPrint 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 clearDelete 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:

PriorityPathSource
1$STATE_DIRECTORY/padctl.logSet 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.logNon-systemd invocations (e.g. the CLI running in the user's shell) with $XDG_STATE_HOME set.
3~/.local/state/padctl/padctl.logXDG fallback when $XDG_STATE_HOME is unset but $HOME is.
4/var/log/padctl/padctl.logLast-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/disable parses config.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-edit config.toml with content that matters (e.g. a forward-looking [experimental] block, inline comments documenting a choice), keep it in a sibling file, or drive padctl via SIGHUP after the edit instead of using the dump subcommand. The current known schema is version, [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:

FieldTypeDefaultDescription
suspend_grace_seci6415Seconds 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 install prints a warning about a missing devices/ directory.
  • After install, padctl scan or 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.service shows:
    Failed at step CAPABILITIES spawning /usr/bin/padctl: Operation not permitted
    Main process exited, code=exited, status=218/CAPABILITIES
    
  • The daemon never starts; padctl status returns cannot 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 build fails during linking:
    relocation R_X86_64_PC64 against symbol '__libc_start_main' can not be used when making a PIE object
    
    or similar R_X86_64_PC64 / .sframe section is unsupported errors.
  • 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]

FieldTypeRequiredDescription
namestringyesHuman-readable device name
vidintegeryesUSB vendor ID (hex literal ok: 0x054c)
pidintegeryesUSB product ID
modestringnoDevice mode identifier
block_kernel_driversstring[]noKernel 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]]

FieldTypeRequiredDescription
idintegeryesUSB interface number
classstringyes"hid" or "vendor"
ep_inintegernoIN endpoint number
ep_outintegernoOUT endpoint number

[device.init]

Optional initialization sequence sent after device open.

FieldTypeRequiredDescription
commandsstring[]yesHex byte strings sent in order
response_prefixinteger[]yesExpected response prefix bytes
enablestringnoHex byte string sent to activate extended mode (e.g. BT mode switch)
disablestringnoHex byte string sent on shutdown
interfaceintegernoInterface to send init commands on
report_sizeintegernoExpected report size after init

[[report]]

Describes one incoming HID report.

FieldTypeRequiredDescription
namestringyesReport name (unique within device)
interfaceintegeryesWhich interface this report arrives on
sizeintegeryesReport byte length

[report.match]

Disambiguates reports when multiple share an interface.

FieldTypeDescription
offsetintegerByte position to inspect
expectinteger[]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] }
FieldTypeDescription
offsetintegerByte offset in report
typestringData type (see below)
bitsinteger[3]Sub-byte extraction: [byte_offset, bit_offset, bit_count]
transformstringComma-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, the type field must be null, "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:

TransformDescription
scale(min, max)Linearly scale the raw value to the target range
negateNegate the value (multiply by -1)
absTake the absolute value
clampClamp to the output axis range
deadzoneApply 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 }
FieldTypeDescription
source.offsetintegerStarting byte offset within the report
source.sizeintegerGroup 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)
maptableButton = 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.

FieldTypeDescription
algostringcrc32 sum8 xor
rangeinteger[2][start, end] byte range to checksum
seedintegerInitial seed value prepended to CRC calculation (e.g. 0xa1 for DualSense BT)
expect.offsetintegerWhere the checksum is stored in the report
expect.typestringStorage 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.

FieldTypeDescription
emulatestringPreset emulation profile
namestringuinput device name
vidintegerEmulated vendor ID
pidintegerEmulated 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
FieldTypeDefaultDescription
typestring"rumble"Force-feedback type. "rumble" is the only legacy value.
max_effectsint16Maximum number of concurrent FF effect slots
auto_stopbooltrueEnable 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.
backendstring"uinput""uinput" (rumble) or "uhid" (PID passthrough — see below).
kindstring"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
FieldTypeDefaultDescription
backendstring"uinput"Set to "uhid" for PID passthrough.
kindstring"rumble"Set to "pid" for PID passthrough.
clone_vid_pidboolfalseWhen 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:

backendkindResult
"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-pidff driver must be loaded, and the hid-universal-pidff quirk 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).

FieldTypeDescription
typestring"mouse" or "keyboard"
namestringuinput device name
keyboardboolCreate keyboard capability
buttonstableButton-to-event mapping

[output.touchpad]

Touchpad output device.

FieldTypeDescription
namestringuinput device name
x_min / x_maxintegerX axis range
y_min / y_maxintegerY axis range
max_slotsintegerMaximum 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, backend must 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.

FieldTypeRequiredDefaultDescription
backendstringno"uhid"Must be "uhid"; only legal value (validator rejects "uinput")
namestringnoUHID device name shown to userspace
vidintegernoinherits from [device].vidEmulated vendor ID
pidintegernoinherits from [device].pidEmulated product ID
accel_rangeint[2]no[-32768, 32767]Accelerometer output range [min, max]
gyro_rangeint[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+).

FieldTypeDescription
pluginstringPath to .wasm plugin file

[wasm.overrides]

FieldTypeDescription
process_reportboolPlugin 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
FieldTypeDefaultDescription
namestringMapping profile name. Used by padctl switch <name> and default_mapping in user config to identify this profile.
trigger_thresholdinteger (0–255)nullThreshold 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_indexinteger (0–255)nullSelector 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:

ValueFeel
50–80Light touch triggers
100–120Click-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
FieldTypeDefaultDescription
modestring"off""off" or "mouse"
activatestringButton name to hold for activation (e.g. "LS", "hold_RB")
sensitivityfloatOverall sensitivity multiplier
sensitivity_xfloatX-axis sensitivity override
sensitivity_yfloatY-axis sensitivity override
deadzoneintegerRaw gyro deadzone threshold
smoothingfloatSmoothing factor (0–1)
curvefloatAcceleration curve exponent
max_valfloatMaximum output value cap
invert_xboolInvert X axis
invert_yboolInvert 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
FieldTypeDefaultDescription
modestring"gamepad""gamepad", "mouse", or "scroll"
deadzoneintegerStick deadzone threshold
sensitivityfloatSensitivity multiplier
suppress_gamepadboolSuppress gamepad axis output when in mouse/scroll mode

[dpad]

D-pad mode configuration.

[dpad]
mode = "gamepad"
FieldTypeDefaultDescription
modestring"gamepad""gamepad" or "arrows" (emits arrow keys)
suppress_gamepadboolSuppress 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
FieldTypeRequiredDescription
namestringyesUnique layer identifier
triggerstringyesButton name that activates this layer
activationstringno"hold" (default) or "toggle"
tapstringnoButton/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_timeoutintegernoHold 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
FieldTypeDefaultDescription
modestring"off""off", "feedback", "weapon", or "vibration"
command_prefixstring"adaptive_trigger_"Command template prefix in device config

[adaptive_trigger.left] / [adaptive_trigger.right]

FieldTypeDescription
positionintegerTrigger position threshold
strengthintegerResistance strength
startintegerStart position (weapon mode)
endintegerEnd position (weapon mode)
amplitudeintegerVibration amplitude
frequencyintegerVibration 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:

StepDescription
{ 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:

FieldDescription
nameIdentifier referenced from remap as macro:<name>
stepsOrdered step list
repeat_delay_msOptional. 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)
FieldTypeDefaultDescription
modifierarray of ButtonIdAll listed buttons must be held simultaneously to arm chord-switch mode. Missing or empty disables the feature.
selectorsarray of ButtonIdSelector at index i (0-based) activates the mapping that declares chord_index = i+1. Missing or empty disables the feature.
hold_msinteger80Debounce 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

IDClassEP INEP OUT
0hid

Report: usb (64 bytes, interface 0)

Match: byte[0] = 0x01

Fields

NameOffsetTypeTransform
lt9u8
left_x1i16le
rt10u8
right_x5i16le
left_y3i16lenegate
right_y7i16lenegate

Button Map

Source: offset 11, size 2 byte(s)

ButtonBit Index
LS9
RS10
X2
LB4
RB5
A0
Select6
Home8
Start7
Y3
B1

Output Capabilities

uinput device name: 8BitDo Ultimate Controller | VID 0x2dc8 | PID 0x6003

Axes

FieldCodeMinMaxFuzzFlat
ltABS_Z025500
left_xABS_X-327683276716128
rtABS_RZ025500
right_xABS_RX-327683276716128
left_yABS_Y-327683276716128
right_yABS_RY-327683276716128

Buttons

ButtonEvent Code
LSBTN_THUMBL
RSBTN_THUMBR
XBTN_WEST
LBBTN_TL
RBBTN_TR
ABTN_SOUTH
SelectBTN_SELECT
HomeBTN_MODE
StartBTN_START
YBTN_NORTH
BBTN_EAST

Flydigi Vader 4 Pro

VID:PID 0x37d7:0x3001

Vendor flydigi

Interfaces

IDClassEP INEP OUT
0hid

Report: extended (32 bytes, interface 0)

Match: byte[0] = 0x04

Fields

NameOffsetTypeTransform
right_y22u8scale(-32768, 32767), negate
accel_y13i16le
gyro_z29i16le
gyro_x26i16le
accel_z15i16le
accel_x11i16le
left_x17u8scale(-32768, 32767)
rt24u8
right_x21u8scale(-32768, 32767)
lt23u8
left_y19u8scale(-32768, 32767), negate

Button Map

Source: offset 7, size 4 byte(s)

ButtonBit Index
M23
M12
LT27
RT26
B18
LS25
RS24
X31
LB29
Home8
Select9
A19
RB28
M41
M30
Y16
Start30

Output Capabilities

uinput device name: Flydigi Vader 4 Pro | VID 0x37d7 | PID 0x3001

Axes

FieldCodeMinMaxFuzzFlat
ltABS_Z025500
left_xABS_X-327683276716128
rtABS_RZ025500
right_xABS_RX-327683276716128
left_yABS_Y-327683276716128
right_yABS_RY-327683276716128

Buttons

ButtonEvent Code
M2BTN_TRIGGER_HAPPY2
LTBTN_TL2
M1BTN_TRIGGER_HAPPY1
RTBTN_TR2
BBTN_EAST
LSBTN_THUMBL
RSBTN_THUMBR
XBTN_WEST
LBBTN_TL
RBBTN_TR
ABTN_SOUTH
SelectBTN_SELECT
HomeBTN_MODE
StartBTN_START
YBTN_NORTH
M3BTN_TRIGGER_HAPPY3
M4BTN_TRIGGER_HAPPY4

Flydigi Vader 5 Pro

VID:PID 0x37d7:0x2401

Vendor flydigi

Interfaces

IDClassEP INEP OUT
1hid

Report: extended (32 bytes, interface 1)

Match: byte[0] = 0x5a, 0xa5, 0xef

Fields

NameOffsetTypeTransform
left_y5i16lenegate
right_y9i16lenegate
accel_x23i16le
gyro_z19i16le
gyro_x17i16le
gyro_y21i16lenegate
accel_z27i16le
accel_y25i16le
left_x3i16le
rt16u8
lt15u8
right_x7i16le

Button Map

Source: offset 11, size 4 byte(s)

ButtonBit Index
M219
M421
M118
DPadUp0
DPadRight1
RM23
LM22
B5
DPadDown2
DPadLeft3
X7
LS14
RS15
LB10
A4
Select6
RB11
C16
Z17
Home27
Start9
O24
Y8
M320

Commands

NameInterfaceTemplate
rumble15a 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

FieldCodeMinMaxFuzzFlat
ltABS_Z025500
left_xABS_X-327683276716128
rtABS_RZ025500
right_xABS_RX-327683276716128
left_yABS_Y-327683276716128
right_yABS_RY-327683276716128

Buttons

ButtonEvent Code
M2BTN_TRIGGER_HAPPY7
M4BTN_TRIGGER_HAPPY8
M1BTN_TRIGGER_HAPPY5
BBTN_EAST
LSBTN_THUMBL
RSBTN_THUMBR
XBTN_NORTH
LBBTN_TL
RBBTN_TR
ABTN_SOUTH
SelectBTN_SELECT
HomeBTN_MODE
StartBTN_START
OBTN_TRIGGER_HAPPY9
YBTN_WEST
M3BTN_TRIGGER_HAPPY6

Force feedback: type=rumble, max_effects=16

HORI Horipad Steam

VID:PID 0x0f0d:0x00c5

Vendor hori

Interfaces

IDClassEP INEP OUT
0hid

Report: bt (287 bytes, interface 0)

Match: byte[0] = 0x07

Fields

NameOffsetTypeTransform
left_y2u8scale(-32768, 32767), negate
right_y4u8scale(-32768, 32767), negate
accel_x22i16le
gyro_z16i16le
gyro_x12i16le
gyro_y14i16le
accel_z18i16le
accel_y20i16le
left_x1u8scale(-32768, 32767)
rt8u8
lt9u8
right_x3u8scale(-32768, 32767)

Button Map

Source: offset 5, size 3 byte(s)

ButtonBit Index
M220
LT11
M114
RT10
B2
LS22
RS21
X0
LB13
A3
Select9
RB12
Home23
Start8
Y15
M416
M317

Output Capabilities

uinput device name: HORI Horipad Steam | VID 0x0f0d | PID 0x00c5

Axes

FieldCodeMinMaxFuzzFlat
ltABS_Z025500
left_xABS_X-327683276716128
rtABS_RZ025500
right_xABS_RX-327683276716128
left_yABS_Y-327683276716128
right_yABS_RY-327683276716128

Buttons

ButtonEvent Code
M2BTN_TRIGGER_HAPPY2
LTBTN_TL2
M1BTN_TRIGGER_HAPPY1
RTBTN_TR2
BBTN_EAST
LSBTN_THUMBL
RSBTN_THUMBR
XBTN_WEST
LBBTN_TL
RBBTN_TR
ABTN_SOUTH
SelectBTN_SELECT
HomeBTN_MODE
StartBTN_START
YBTN_NORTH
M3BTN_TRIGGER_HAPPY3
M4BTN_TRIGGER_HAPPY4

Lenovo Legion Go

VID:PID 0x17ef:0x6182

Vendor lenovo

Interfaces

IDClassEP INEP OUT
0hid

Report: xinput (60 bytes, interface 0)

Match: byte[0] = 0x04

Fields

NameOffsetTypeTransform
lt22u8
left_x14u8scale(-32768, 32767)
rt23u8
right_x16u8scale(-32768, 32767)
left_y15u8scale(-32768, 32767), negate
right_y17u8scale(-32768, 32767), negate

Button Map

Source: offset 18, size 3 byte(s)

ButtonBit Index
M220
M11
LT14
DPadUp4
DPadRight7
RT15
B9
LS2
RS3
DPadDown5
DPadLeft6
X10
LB12
Home0
A8
RB13
Select22
Start23
Y11
M321

Output Capabilities

uinput device name: Lenovo Legion Go | VID 0x17ef | PID 0x6182

Axes

FieldCodeMinMaxFuzzFlat
ltABS_Z025500
left_xABS_X-327683276716128
rtABS_RZ025500
right_xABS_RX-327683276716128
left_yABS_Y-327683276716128
right_yABS_RY-327683276716128

Buttons

ButtonEvent Code
M2BTN_TRIGGER_HAPPY2
LTBTN_TL2
M1BTN_TRIGGER_HAPPY1
RTBTN_TR2
BBTN_EAST
LSBTN_THUMBL
RSBTN_THUMBR
XBTN_WEST
LBBTN_TL
RBBTN_TR
ABTN_SOUTH
SelectBTN_SELECT
HomeBTN_MODE
StartBTN_START
YBTN_NORTH
M3BTN_TRIGGER_HAPPY3

Lenovo Legion Go S

VID:PID 0x1a86:0xe310

Vendor lenovo

Interfaces

IDClassEP INEP OUT
6hid

Report: gamepad (32 bytes, interface 6)

Match: byte[0] = 0x06

Fields

NameOffsetTypeTransform
lt12u8
left_x4i8scale(-32768, 32767)
rt13u8
right_x6i8scale(-32768, 32767)
left_y5i8scale(-32768, 32767), negate
right_y7i8scale(-32768, 32767), negate

Button Map

Source: offset 1, size 3 byte(s)

ButtonBit Index
M220
M11
LT9
DPadUp4
DPadRight7
RT8
B14
LS3
RS2
DPadDown5
DPadLeft6
X13
LB11
Home0
RB10
A15
Select23
Start22
Y12
M321

Commands

NameInterfaceTemplate
rumble604 00 08 00 {strong:u8} {weak:u8} 00 00 00...

Output Capabilities

uinput device name: Xbox Elite Series 2 | VID 0x045e | PID 0x0b00

Axes

FieldCodeMinMaxFuzzFlat
ltABS_Z025500
left_xABS_X-327683276716128
rtABS_RZ025500
right_xABS_RX-327683276716128
left_yABS_Y-327683276716128
right_yABS_RY-327683276716128

Buttons

ButtonEvent Code
M2BTN_TRIGGER_HAPPY2
M1BTN_TRIGGER_HAPPY1
BBTN_EAST
LSBTN_THUMBL
RSBTN_THUMBR
XBTN_NORTH
LBBTN_TL
RBBTN_TR
ABTN_SOUTH
SelectBTN_SELECT
HomeBTN_MODE
StartBTN_START
YBTN_WEST
M3BTN_TRIGGER_HAPPY3

Force feedback: type=rumble, max_effects=16

Xbox Elite Series 2

VID:PID 0x045e:0x0b00

Vendor microsoft

Interfaces

IDClassEP INEP OUT
0hid

Report: usb (64 bytes, interface 0)

Match: byte[0] = 0x01

Fields

NameOffsetTypeTransform
lt9u16lescale(0, 255)
left_x1i16le
rt11u16lescale(0, 255)
right_x5i16le
left_y3i16lenegate
right_y7i16lenegate

Button Map

Source: offset 13, size 3 byte(s)

ButtonBit Index
M21
M10
B9
LS16
RS17
X10
LB12
A8
RB13
Select14
Home18
Start15
M43
M32
Y11

Commands

NameInterfaceTemplate
rumble009 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

FieldCodeMinMaxFuzzFlat
ltABS_Z025500
left_xABS_X-327683276716128
rtABS_RZ025500
right_xABS_RX-327683276716128
left_yABS_Y-327683276716128
right_yABS_RY-327683276716128

Buttons

ButtonEvent Code
M2BTN_TRIGGER_HAPPY2
M1BTN_TRIGGER_HAPPY1
BBTN_EAST
LSBTN_THUMBL
RSBTN_THUMBR
XBTN_WEST
LBBTN_TL
RBBTN_TR
ABTN_SOUTH
SelectBTN_SELECT
HomeBTN_MODE
StartBTN_START
YBTN_NORTH
M3BTN_TRIGGER_HAPPY3
M4BTN_TRIGGER_HAPPY4

Force feedback: type=rumble, max_effects=4

Nintendo Switch Pro Controller

VID:PID 0x057e:0x2009

Vendor nintendo

Interfaces

IDClassEP INEP OUT
0hid

Report: bt_standard (49 bytes, interface 0)

Match: byte[0] = 0x30

Fields

NameOffsetTypeTransform
left_y_raw7u8
right_x_raw9u8
right_y_raw10u8
left_x_raw6u8

Button Map

Source: offset 3, size 3 byte(s)

ButtonBit Index
Capture13
LT23
DPadUp17
RT7
DPadRight18
B2
LS11
RS10
X1
DPadDown16
DPadLeft19
LB22
RB6
A3
Select8
Home12
Start9
Y0

Output Capabilities

uinput device name: Nintendo Switch Pro Controller | VID 0x057e | PID 0x2009

Axes

FieldCodeMinMaxFuzzFlat
left_xABS_X-327683276716128
right_xABS_RX-327683276716128
left_yABS_Y-327683276716128
right_yABS_RY-327683276716128

Buttons

ButtonEvent Code
CaptureBTN_MISC
LTBTN_TL2
RTBTN_TR2
BBTN_SOUTH
LSBTN_THUMBL
RSBTN_THUMBR
XBTN_NORTH
LBBTN_TL
RBBTN_TR
ABTN_EAST
SelectBTN_SELECT
HomeBTN_MODE
StartBTN_START
YBTN_WEST

Sony DualSense

VID:PID 0x054c:0x0ce6

Vendor sony

Interfaces

IDClassEP INEP OUT
3hid

Report: usb (64 bytes, interface 3)

Match: byte[0] = 0x01

Fields

NameOffsetTypeTransform
touch1_contact37u8
accel_x22i16le
sensor_timestamp28u32le
left_x1u8scale(-32768, 32767)
touch0_contact33u8
lt5u8
accel_y24i16le
accel_z26i16le
gyro_z20i16le
gyro_y18i16le
battery_levelbits[53,0,4]unsigned
right_x3u8scale(-32768, 32767)
gyro_x16i16le
rt6u8
left_y2u8scale(-32768, 32767), negate
right_y4u8scale(-32768, 32767), negate

Button Map

Source: offset 8, size 3 byte(s)

ButtonBit Index
LT10
Mic18
RT11
B6
LS14
RS15
X4
LB8
RB9
A5
Select12
Home16
Start13
Y7
TouchPad17

Report: bt (78 bytes, interface 3)

Match: byte[0] = 0x31

Fields

NameOffsetTypeTransform
touch1_contact38u8
accel_x23i16le
sensor_timestamp29u32le
left_x2u8scale(-32768, 32767)
touch0_contact34u8
lt6u8
accel_y25i16le
accel_z27i16le
gyro_z21i16le
gyro_y19i16le
battery_levelbits[54,0,4]unsigned
right_x4u8scale(-32768, 32767)
gyro_x17i16le
rt7u8
left_y3u8scale(-32768, 32767), negate
right_y5u8scale(-32768, 32767), negate

Button Map

Source: offset 9, size 3 byte(s)

ButtonBit Index
LT10
Mic18
RT11
B6
LS14
RS15
X4
LB8
RB9
A5
Select12
Home16
Start13
Y7
TouchPad17

Commands

NameInterfaceTemplate
led302 00 04 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 ...
rumble302 01 00 {weak:u8} {strong:u8} 00 00 00 00 00 00 00 00 00 00...
adaptive_trigger_feedback302 0c 00 00 00 00 00 00 00 00 00 01 {r_position:u8} {r_stren...
adaptive_trigger_vibration302 0c 00 00 00 00 00 00 00 00 00 06 {r_position:u8} {r_ampli...
adaptive_trigger_weapon302 0c 00 00 00 00 00 00 00 00 00 02 {r_start:u8} {r_end:u8} ...
adaptive_trigger_off302 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

FieldCodeMinMaxFuzzFlat
ltABS_Z025500
left_xABS_X-327683276716128
rtABS_RZ025500
right_xABS_RX-327683276716128
left_yABS_Y-327683276716128
right_yABS_RY-327683276716128

Buttons

ButtonEvent Code
MicBTN_MISC
BBTN_EAST
LSBTN_THUMBL
RSBTN_THUMBR
XBTN_WEST
LBBTN_TL
RBBTN_TR
ABTN_SOUTH
SelectBTN_SELECT
HomeBTN_MODE
StartBTN_START
YBTN_NORTH
TouchPadBTN_TOUCH

Force feedback: type=rumble, max_effects=16

Sony DualShock 4

VID:PID 0x054c:0x05c4

Vendor sony

Interfaces

IDClassEP INEP OUT
0hid

Report: usb (64 bytes, interface 0)

Match: byte[0] = 0x01

Fields

NameOffsetTypeTransform
touch1_contact37u8
accel_x20i16le
left_x1u8scale(-32768, 32767)
touch0_contact33u8
lt9u8
accel_y22i16le
accel_z24i16le
gyro_z18i16le
gyro_x14i16le
battery_levelbits[30,0,4]unsigned
right_x3u8scale(-32768, 32767)
rt10u8
gyro_y16i16le
left_y2u8scale(-32768, 32767), negate
right_y4u8scale(-32768, 32767), negate

Button Map

Source: offset 5, size 3 byte(s)

ButtonBit Index
LT10
RT11
B6
LS14
RS15
X4
LB8
RB9
A5
Select12
Home16
Start13
Y7
TouchPad17

Report: bt (78 bytes, interface 0)

Match: byte[0] = 0x11

Fields

NameOffsetTypeTransform
touch1_contact39u8
accel_x22i16le
left_x3u8scale(-32768, 32767)
touch0_contact35u8
lt11u8
accel_y24i16le
accel_z26i16le
gyro_z20i16le
gyro_x16i16le
battery_levelbits[32,0,4]unsigned
right_x5u8scale(-32768, 32767)
rt12u8
gyro_y18i16le
left_y4u8scale(-32768, 32767), negate
right_y6u8scale(-32768, 32767), negate

Button Map

Source: offset 7, size 3 byte(s)

ButtonBit Index
LT10
RT11
B6
LS14
RS15
X4
LB8
RB9
A5
Select12
Home16
Start13
Y7
TouchPad17

Commands

NameInterfaceTemplate
led005 ff 00 00 00 00 {r:u8} {g:u8} {b:u8} 00 00 00 00 00 00 00 ...
rumble005 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

FieldCodeMinMaxFuzzFlat
ltABS_Z025500
left_xABS_X-327683276716128
rtABS_RZ025500
right_xABS_RX-327683276716128
left_yABS_Y-327683276716128
right_yABS_RY-327683276716128

Buttons

ButtonEvent Code
LSBTN_THUMBL
RSBTN_THUMBR
XBTN_WEST
BBTN_EAST
LBBTN_TL
RBBTN_TR
ABTN_SOUTH
SelectBTN_SELECT
HomeBTN_MODE
StartBTN_START
YBTN_NORTH
TouchPadBTN_TOUCH

Force feedback: type=rumble, max_effects=16

Sony DualShock 4 v2

VID:PID 0x054c:0x09cc

Vendor sony

Interfaces

IDClassEP INEP OUT
0hid

Report: usb (64 bytes, interface 0)

Match: byte[0] = 0x01

Fields

NameOffsetTypeTransform
touch1_contact37u8
accel_x20i16le
left_x1u8scale(-32768, 32767)
touch0_contact33u8
lt9u8
accel_y22i16le
accel_z24i16le
gyro_z18i16le
gyro_x14i16le
battery_levelbits[30,0,4]unsigned
right_x3u8scale(-32768, 32767)
rt10u8
gyro_y16i16le
left_y2u8scale(-32768, 32767), negate
right_y4u8scale(-32768, 32767), negate

Button Map

Source: offset 5, size 3 byte(s)

ButtonBit Index
LT10
RT11
B6
LS14
RS15
X4
LB8
RB9
A5
Select12
Home16
Start13
Y7
TouchPad17

Report: bt (78 bytes, interface 0)

Match: byte[0] = 0x11

Fields

NameOffsetTypeTransform
touch1_contact39u8
accel_x22i16le
left_x3u8scale(-32768, 32767)
touch0_contact35u8
lt11u8
accel_y24i16le
accel_z26i16le
gyro_z20i16le
gyro_x16i16le
battery_levelbits[32,0,4]unsigned
right_x5u8scale(-32768, 32767)
rt12u8
gyro_y18i16le
left_y4u8scale(-32768, 32767), negate
right_y6u8scale(-32768, 32767), negate

Button Map

Source: offset 7, size 3 byte(s)

ButtonBit Index
LT10
RT11
B6
LS14
RS15
X4
LB8
RB9
A5
Select12
Home16
Start13
Y7
TouchPad17

Commands

NameInterfaceTemplate
led005 ff 00 00 00 00 {r:u8} {g:u8} {b:u8} 00 00 00 00 00 00 00 ...
rumble005 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

FieldCodeMinMaxFuzzFlat
ltABS_Z025500
left_xABS_X-327683276716128
rtABS_RZ025500
right_xABS_RX-327683276716128
left_yABS_Y-327683276716128
right_yABS_RY-327683276716128

Buttons

ButtonEvent Code
LSBTN_THUMBL
RSBTN_THUMBR
XBTN_WEST
BBTN_EAST
LBBTN_TL
RBBTN_TR
ABTN_SOUTH
SelectBTN_SELECT
HomeBTN_MODE
StartBTN_START
YBTN_NORTH
TouchPadBTN_TOUCH

Force feedback: type=rumble, max_effects=16

Valve Steam Deck

VID:PID 0x28de:0x1205

Vendor valve

Interfaces

IDClassEP INEP OUT
2hid

Report: main (64 bytes, interface 2)

Match: byte[2] = 0x09

Fields

NameOffsetTypeTransform
touch1_y22i16le
touch0_y18i16le
accel_x24i16le
touch0_x16i16le
left_x48i16le
lt44u16lescale(0, 255)
touch1_x20i16le
touch1_activebits[10,4,1]unsigned
touch0_activebits[10,3,1]unsigned
accel_y26i16le
accel_z28i16le
gyro_z34i16le
gyro_x30i16le
gyro_y32i16le
right_x52i16le
rt46u16lescale(0, 255)
left_y50i16lenegate
right_y54i16lenegate

Button Map

Source: offset 8, size 8 byte(s)

ButtonBit Index
M216
LT1
M115
DPadUp8
RT0
DPadRight9
B5
DPadLeft10
DPadDown11
X6
LS22
RS26
LB3
RB2
A7
Select12
Home13
Start14
Y4
M341
M442

Commands

NameInterfaceTemplate
rumble28f 00 {strong:u8} 00 00 10 00 01 00...

Output Capabilities

uinput device name: Valve Steam Deck | VID 0x28de | PID 0x1205

Axes

FieldCodeMinMaxFuzzFlat
ltABS_Z025500
left_xABS_X-327683276716128
rtABS_RZ025500
right_xABS_RX-327683276716128
left_yABS_Y-327683276716128
right_yABS_RY-327683276716128

Buttons

ButtonEvent Code
M2BTN_TRIGGER_HAPPY2
LTBTN_TL2
M1BTN_TRIGGER_HAPPY1
RTBTN_TR2
BBTN_EAST
LSBTN_THUMBL
RSBTN_THUMBR
XBTN_WEST
LBBTN_TL
ABTN_SOUTH
RBBTN_TR
SelectBTN_SELECT
HomeBTN_MODE
StartBTN_START
YBTN_NORTH
M3BTN_TRIGGER_HAPPY3
M4BTN_TRIGGER_HAPPY4

Force feedback: type=rumble, max_effects=4

Contributing

There are several ways to contribute to padctl:

Guides

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 typeTransform needed
u8 centered at 0x80scale(-32768, 32767)
i8 centered at 0scale(-32768, 32767)
i16le centered at 0none (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 negate in 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

  1. Validate locally:

    zig build && ./zig-out/bin/padctl --validate devices/<vendor>/<model>.toml
    

    Exit 0 = valid. Exit 1 = validation errors. Exit 2 = file not found or parse failure.

  2. Test: Run zig build test — the test framework auto-discovers all .toml files in devices/.

  3. 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

padctl-capture --device /dev/hidraw3 --duration 30 --output capture.bin

While capturing, do each action one at a time with a pause between:

  1. Leave controller idle for 3 seconds (this is your baseline)
  2. Press and release each face button (A, B, X, Y) one at a time
  3. Press and release each shoulder button (LB, RB, LT, RT)
  4. Move left stick to full left, full right, full up, full down
  5. Move right stick the same way
  6. Press each D-pad direction
  7. 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:

PatternTypeCenterRange
Single byte, idle = 0x80u81280-255
Single byte, idle = 0x00i80-128 to 127
Two bytes, idle = 0x00 0x00i16le0-32768 to 32767
Two bytes, idle = 0x00 0x80u16le centered327680-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:

ButtonByteBit (in byte)Bit (in group)
Square/X844
Cross/A855
Circle/B866
Triangle/Y877
L1/LB908
R1/RB919
L3/LS9614
R3/RS9715

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 representationpadctl 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 typeTransform needed
u8 centered at 0x80scale(-32768, 32767)
i8 centered at 0scale(-32768, 32767)
i16le centered at 0none (already full range)
u8 trigger (0-255)none

Linux Input Event Codes

padctl buttonLinux codeNotes
ABTN_SOUTHCross on PlayStation
BBTN_EASTCircle on PlayStation
XBTN_WESTSquare on PlayStation
YBTN_NORTHTriangle on PlayStation
LBBTN_TLL1
RBBTN_TRR1
SelectBTN_SELECTShare/Create/View
StartBTN_STARTOptions/Menu
HomeBTN_MODEPS/Xbox/Guide
LSBTN_THUMBLL3 (stick click)
RSBTN_THUMBRR3 (stick click)
M1-M4BTN_TRIGGER_HAPPY1-4Back 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:

ButtonMSB0 bitbytebit-in-byteLSB0 index
DPadRight0077
DPadLeft1066
DPadDown2055
DPadUp3044
L34033
R35022
Btn66011
Btn77000
A81715
B91614
X101513
Y111412
LB121311
RB131210
LT14119
RT15108

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. Without match, 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) or i8 (center = 0). Both benefit from transform = "scale(-32768, 32767)" to fill the full axis range expected by uinput.

Code Contributions

Workflow

  1. Fork the repository and create a feature branch
  2. Make your changes
  3. Run all checks before submitting
  4. 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

FlagDefaultDescription
-Dlibusb=falsetrueDisable libusb-1.0 linkage (hidraw-only path)
-Dwasm=falsetrueDisable WASM plugin runtime
-Dtest-coverage=truefalseRun 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.