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

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

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.

Custom prefix (e.g. for packaging):

sudo ./zig-out/bin/padctl install --prefix /usr --destdir "$DESTDIR"

Verify

padctl scan

Lists all connected HID devices and shows whether a matching device config was found for each.

Run with a Device

# Single config
sudo padctl --config /usr/share/padctl/devices/sony/dualsense.toml

# All configs in a directory (auto multi-device)
sudo padctl --config-dir /usr/share/padctl/devices/

Run as Service

sudo systemctl enable --now padctl.service

The service runs padctl in daemon mode, managing all matched devices with hotplug support.

Validate a Config

padctl --validate devices/sony/dualsense.toml

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

Generate Device Docs

padctl --doc-gen --config devices/sony/dualsense.toml

udev Permissions

padctl needs access to /dev/hidraw* and /dev/uinput. The padctl install command installs udev rules automatically. For manual installs:

sudo cp install/99-padctl.rules /etc/udev/rules.d/
sudo udevadm control --reload-rules
sudo udevadm trigger

The udev rules use TAG+="uaccess" to grant the logged-in user access to supported devices without requiring root.

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

[[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 }

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 crc8 sum8 xor none
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]

[output.force_feedback]
type = "rumble"
max_effects = 16

[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

[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"
FieldTypeDescription
namestringMapping profile name

[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, 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 = "L3"
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. "L3", "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)
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

Bind a macro in remap: M1 = "macro:dodge_roll"

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_z21i16le
gyro_x17i16le
gyro_y19i16le
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
rumble15aa5 1206 {strong:u8} {weak:u8} 0000 0000...

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
M4BTN_TRIGGER_HAPPY4
M1BTN_TRIGGER_HAPPY1
RMBTN_TRIGGER_HAPPY8
LMBTN_TRIGGER_HAPPY7
BBTN_EAST
LSBTN_THUMBL
RSBTN_THUMBR
XBTN_NORTH
ZBTN_TRIGGER_HAPPY6
LBBTN_TL
RBBTN_TR
ABTN_SOUTH
SelectBTN_SELECT
HomeBTN_MODE
CBTN_TRIGGER_HAPPY5
StartBTN_START
OBTN_TRIGGER_HAPPY9
YBTN_WEST
M3BTN_TRIGGER_HAPPY3

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 0, size 4 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
0hid

Report: main (64 bytes, interface 0)

Match: byte[1] = 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 4 byte(s)

ButtonBit Index
M213
LT1
M112
DPadUp8
RT0
DPadRight9
B5
LS3
RS2
X6
DPadLeft10
DPadDown11
A7
Home17
Select18
M415
Y4
M314
Start16

Commands

NameInterfaceTemplate
rumble08f 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
ABTN_SOUTH
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