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_extra/mouse_forward/mouse_back, disabled, or macro:<name>.

LT / RT as targets emit the digital trigger press plus a full analog pull (ABS_Z / ABS_RZ = 255) while the source button is held. Tap/double gesture legs targeting LT / RT emit only the digital press; use a plain remap or a hold leg for an analog pull.

[remap]
M1 = "RT"   # full analog pull (255) while M1 is held
M2 = "LT"

Note: BTN_* values (e.g. "BTN_SOUTH") are routed to the virtual mouse device, not the gamepad. To target a gamepad button use a friendly ButtonId name ("A", "Select", etc.) instead.

[remap]
M1 = "KEY_F13"
M2 = "mouse_side"
M3 = "disabled"
A = "B"
M4 = "macro:dodge_roll"

Array values (e.g. M1 = ["KEY_LEFTMETA", "KEY_1"]) are parsed and resolved as chord targets (2–4 keys) but are not yet dispatched — chord output is planned for a future release.

Gesture bindings (tap / hold / double-press)

A [remap] value may also be an inline table that binds different actions to short press, long press, and double press of the same button:

[remap]
A  = { tap = "KEY_X", hold = "KEY_Y", double = "KEY_Z" }
B  = { tap = "B", hold = "KEY_LEFTSHIFT" }
Y  = { tap = "Y", double = "KEY_F" }
RB = { tap = "RB", hold = "KEY_TAB", hold_ms = 400, double_ms = 200 }
KeyTypeDefaultMeaning
tapstringAction for a short press (fired on release).
holdstringAction fired once the button is held past hold_ms.
doublestringAction fired when a second press starts within double_ms of the first release.
hold_msinteger (1–5000)300Hold threshold in milliseconds.
double_msinteger (1–5000)250Double-press window in milliseconds.

At least one of tap / hold / double must be set. Each leg is a single target (ButtonId, KEY_*, mouse_*, or disabled); macro:<name> and chord arrays are not allowed inside a gesture. An empty table {} or an unknown key is rejected at parse time; out-of-range thresholds and a base-[remap] gesture key that collides with a [[layer]] trigger are rejected at validate time. Absent legs simply do nothing.

Latency trade-off: when double is set, tap cannot fire until the double-press window has elapsed (the engine must wait to see whether a second press arrives). Without double, tap fires immediately on release with zero added latency. Plain string and chord-array remap forms are unaffected and incur no extra latency.

[gyro]

Global gyro-to-mouse configuration.

[gyro]
mode = "mouse"
activate = "LS"
sensitivity = 2.0
deadzone = 300
smoothing = 0.4
curve = 1.0
invert_y = true
FieldTypeDefaultDescription
modestring"off""off", "mouse", or "joystick". In "joystick" mode the processed gyro signal is routed to a virtual stick axis instead of mouse REL_X/Y events.
targetstring"right_stick""right_stick" or "left_stick". Selects which stick axis receives the gyro output. Only used when mode = "joystick".
responsestring"rate""rate" keeps the existing gyro-rate behavior. "tilt" maps controller tilt to an absolute virtual stick position and is valid only with mode = "joystick".
axis_xstring"yaw" / "roll"Source for virtual stick X: "yaw", "pitch", "roll", or "none". Defaults to "yaw" in "rate" response and "roll" in "tilt" response. In "tilt" response, roll and pitch are estimated from accelerometer data; yaw resolves to neutral.
axis_ystring"pitch"Source for virtual stick Y: "yaw", "pitch", "roll", or "none".
degrees_fullfloat35.0In "tilt" response, the absolute tilt angle that maps to full stick deflection. Must be greater than 0 and no more than 180.
activatestringGate button: bare name ("LS") or hold_<BTN> form ("hold_RB") — both are equivalent. For analog triggers (LT/RT), also set trigger_threshold. Omit for always-active.
sensitivityfloatOverall sensitivity multiplier. In "mouse" mode it scales relative cursor motion; in "joystick"/"rate" mode it scales an absolute stick deflection and typically needs a much larger value (≈50–150) — see the gyro joystick guide.
sensitivity_xfloatX-axis sensitivity override
sensitivity_yfloatY-axis sensitivity override
deadzoneintegerRaw gyro deadzone threshold in "rate" response. In "tilt" response this is an output stick deadzone after angle conversion.
smoothingfloatSmoothing factor (0–1)
curvefloatAcceleration curve exponent
max_valfloat32767Input normalization ceiling: the raw gyro rate that normalizes to 1.0, before curve, sensitivity, and output scaling are applied. Lowering it (e.g. 2000) raises the normalized signal, so a moderate motion drives a larger output — equivalent to raising sensitivity, and useful in "joystick" mode to keep sensitivity numbers small.
invert_xboolInvert X axis
invert_yboolInvert Y axis
blend_stickboolfalseWhen true, gyro joystick output is added to the physical stick value (clamp(physical + gyro, -32767..32767)) instead of replacing it. Only applies when mode = "joystick". Ignored for mode = "mouse".
minimum_outputfloat0.0Minimum stick deflection magnitude while gyro is active, as a fraction of full scale (clamped to 0.01.0, where 1.020000 stick units). When the computed output magnitude is non-zero but below this floor, it is scaled up to exactly minimum_output while preserving direction. 0.0 disables the floor (default, no-op). A still controller stays at zero, and deadzone always wins (input absorbed by the deadzone produces zero output, never resurrected by the floor). Only applies when mode = "joystick"; ignored for mode = "mouse". Useful for escaping an in-game stick deadzone so small gyro motion still registers.

[stick.left] / [stick.right]

Per-stick mode configuration.

[stick.left]
mode = "gamepad"
deadzone = 128
sensitivity = 1.0

[stick.right]
mode = "mouse"
sensitivity = 2.5
deadzone = 100
suppress_gamepad = true
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 = "RB"
hold_timeout = 200
FieldTypeRequiredDescription
namestringyesUnique layer identifier
triggerstringyesButton name that activates this layer
activationstringno"hold" (default), "toggle", or "hold_toggle". hold_toggle starts like hold, but holding past hold_timeout toggles the layer sticky on/off instead of making it momentary.
tapstringnoButton/key emitted on short press (when using hold or hold_toggle activation). May be a ButtonId, KEY_*, mouse_*, or disabled. Cannot be macro:<name> — the layer tap dispatch path does not run macros, so tap = "macro:foo" is rejected at validate time (error.LayerTapCannotBeMacro). Use macro:<name> from [remap] / [layer.remap] instead.
holdstringnoPassthrough output emitted continuously while the layer is active, for every activation mode (hold / hold_toggle / toggle). A single ButtonId, KEY_*, mouse_*, or BTN_* target. Fires only after the layer activates — never on a short tap (tap still fires for the short press). Cannot be macro:<name> (error.LayerHoldCannotBeMacro). Released on every exit path (trigger release, layer/mapping switch, reset).
hold_timeoutintegernoHold detection threshold in ms (1–5000); default 200

[layer.remap]

Per-layer button remapping. Same syntax as top-level [remap].

[layer.remap]
RT = "mouse_left"
A = "KEY_R"

[layer.gyro]

Per-layer gyro override. Same fields as [gyro].

[layer.gyro]
mode = "mouse"
sensitivity = 8.0
deadzone = 40
smoothing = 0.4
invert_y = true

[layer.stick_left] / [layer.stick_right]

Per-layer stick overrides. Same fields as [stick.left]/[stick.right].

[layer.stick_right]
mode = "mouse"
sensitivity = 2.5
deadzone = 100
suppress_gamepad = true

[layer.dpad]

Per-layer d-pad override. Same fields as [dpad].

[layer.dpad]
mode = "arrows"
suppress_gamepad = true

[layer.adaptive_trigger]

Per-layer adaptive trigger override. Same fields as top-level [adaptive_trigger].

[layer.adaptive_trigger]
mode = "weapon"

[layer.adaptive_trigger.left]
start    = 30
end      = 120
strength = 200

[adaptive_trigger]

DualSense adaptive trigger configuration.

[adaptive_trigger]
mode = "feedback"

[adaptive_trigger.left]
position = 70
strength = 200

[adaptive_trigger.right]
position = 40
strength = 180
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
{ press = "KEY" }Sugar: emits down here and appends up at macro end (LIFO if multiple). Cannot be mixed with explicit down/up of the same button.

Macro fields:

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.
step_delayOptional. Per-macro implicit delay (ms) inserted between adjacent emitting steps (tap/down/up). Overrides the top-level macro_step_delay. Explicit delay steps and pause_for_release neighbours suppress insertion. 0 disables (explicit zero wins over the global default).

Implicit step delays

The top-level macro_step_delay = N (ms) sets a global default that is inserted between every pair of adjacent emitting steps (tap / down / up) in every macro. A per-macro step_delay overrides the global. Both default to 0 (no insertion, byte-identical to legacy behaviour).

Insertion rules:

  • Only inserted between two adjacent emitting steps (tap/down/up).
  • Never inserted adjacent to an explicit { delay = N } step (it already is a delay).
  • Never inserted adjacent to "pause_for_release" (treat it as a synchronization point).
  • Per-macro step_delay (including explicit 0) wins over global macro_step_delay.
macro_step_delay = 50   # global default for every macro

[[macro]]
name = "unarmed"
# step_delay omitted → inherits global 50
steps = [
    { down = "RB" },
    { down = "X" },
    "pause_for_release",
    { up = "X" },
    { up = "RB" },
]
# Effective after parse:
#   down RB, delay 50, down X, pause_for_release, up X, delay 50, up RB

Note: hold_timeout is a [[layer]] field (see above, default 200 ms). It is not a [[macro]] field — placing it under [[macro]] has no effect (the schema linter warns about it). To make a hold-activated layer respond faster, set hold_timeout on the [[layer]] block, e.g. hold_timeout = 50.

# Turbo: spam A while RM is held, 50 ms between presses.
[[macro]]
name = "spam_a"
repeat_delay_ms = 50
steps = [{ tap = "A" }]

# Combo: XYX every 100 ms while held.
[[macro]]
name = "xyx_combo"
repeat_delay_ms = 100
steps = [
    { tap = "X" },
    { delay = 30 },
    { tap = "Y" },
    { delay = 30 },
    { tap = "X" },
]

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

[chord_switch] — in-controller mapping switch

[chord_switch] lives in ~/.config/padctl/config.toml (the user config), not in a mapping file. It lets you switch the active mapping without touching a CLI: hold a modifier combination, then tap a selector button.

# ~/.config/padctl/config.toml
version = 1

[chord_switch]
modifier  = ["LM", "RM"]        # hold ALL of these to arm
selectors = ["A", "B", "X", "Y"] # tap one (while modifier held) to switch
hold_ms   = 120                  # debounce window in ms (default 80)
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.